feat(forklift): small fixes

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-05-28 20:48:13 +02:00
parent 3ecb6bfc4a
commit 5c7dd1953d
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
11 changed files with 133 additions and 57 deletions

View File

@ -18,7 +18,10 @@ defineProps({
:key="'net-' + idx"
class="mapping-entry"
>
<i class="icon icon-network" />
<i
class="icon icon-network"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
<div
@ -26,7 +29,10 @@ defineProps({
:key="'stor-' + idx"
class="mapping-entry"
>
<i class="icon icon-datastore" />
<i
class="icon icon-datastore"
aria-hidden="true"
/>
<span>{{ entry }}</span>
</div>
</div>

View File

@ -2,9 +2,12 @@
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import MappingColumn from './MappingColumn.vue';
const props = defineProps({
@ -29,6 +32,7 @@ const networkEntries = ref([]);
const storageEntries = ref([]);
const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const errors = ref([]);
const loading = ref(true);
// Restore from stepData
@ -39,12 +43,12 @@ if (props.stepData.storageEntries.length > 0) {
storageEntries.value = props.stepData.storageEntries;
}
const NAMESPACE = 'forklift';
const NAMESPACE = FORKLIFT_NAMESPACE;
const harvesterNetworkOptions = computed(() => {
const options = [
{ label: 'Pod Networking', value: 'pod' },
{ label: 'Ignored', value: 'ignored' },
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking'), value: 'pod' },
{ label: t('harvester.addons.vmMigration.configureMappings.networkMapping.options.ignored'), value: 'ignored' },
];
harvesterNetworks.value.forEach((net) => {
@ -167,7 +171,7 @@ const buildNetworkEntries = () => {
if (!networkMap[netKey]) {
networkMap[netKey] = {
name: net.name || 'Unknown',
name: net.name || t('harvester.addons.vmMigration.generic.unknown'),
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
@ -199,7 +203,7 @@ const buildStorageEntries = () => {
if (ds && ds.id) {
if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = {
name: ds.name || 'Unknown',
name: ds.name || t('harvester.addons.vmMigration.generic.unknown'),
id: ds.id,
type: ds.type || '',
capacity: 0,
@ -352,7 +356,7 @@ const saveAndReturn = async() => {
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : Math.random().toString(36).substring(2, 7) }`,
name: `${ props.providerName }-network-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
@ -364,7 +368,7 @@ const saveAndReturn = async() => {
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : Math.random().toString(36).substring(2, 7) }`,
name: `${ props.providerName }-storage-map-${ props.useAllProviderData ? 'default' : randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
@ -473,7 +477,7 @@ const init = async() => {
}
}
} catch (e) {
// Data resolution failed entries will use IDs as fallback
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedResolveDetails')];
}
}
@ -508,6 +512,12 @@ init();
v-else
class="configure-mappings"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>

View File

@ -11,6 +11,7 @@ import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
const CREATE_NEW = '__create_new__';
@ -143,7 +144,7 @@ watch(selectedProvider, (val) => {
createdSecret.value = null;
} else {
const provider = allProviders.value.find(
(p) => p.metadata.name === val && p.metadata.namespace === 'forklift'
(p) => p.metadata.name === val && p.metadata.namespace === FORKLIFT_NAMESPACE
);
if (provider) {
@ -212,7 +213,7 @@ const testConnection = async(buttonCb) => {
// Edit mode: update existing provider URL + secret, then poll
if (props.editMode && createdProvider.value) {
try {
const namespace = 'forklift';
const namespace = FORKLIFT_NAMESPACE;
createdProvider.value.spec.url = url.value;
await createdProvider.value.save();
@ -253,7 +254,7 @@ const testConnection = async(buttonCb) => {
connected = true;
break;
} else {
errorMsg = connectionCondition.message || 'Connection failed';
errorMsg = connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed');
break;
}
}
@ -263,7 +264,7 @@ const testConnection = async(buttonCb) => {
connected = true;
break;
} else if (readyCondition.status === 'False') {
errorMsg = readyCondition.message || 'Provider not ready';
errorMsg = readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady');
break;
}
}
@ -291,7 +292,7 @@ const testConnection = async(buttonCb) => {
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
if (isExistingProvider.value) {
try {
const namespace = 'forklift';
const namespace = FORKLIFT_NAMESPACE;
const maxAttempts = 15;
let attempts = 0;
let connected = false;
@ -316,7 +317,7 @@ const testConnection = async(buttonCb) => {
connected = true;
break;
} else {
errorMsg = connectionCondition.message || 'Connection failed';
errorMsg = connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed');
break;
}
}
@ -326,7 +327,7 @@ const testConnection = async(buttonCb) => {
connected = true;
break;
} else if (readyCondition.status === 'False') {
errorMsg = readyCondition.message || 'Provider not ready';
errorMsg = readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady');
break;
}
}
@ -363,7 +364,7 @@ const testConnection = async(buttonCb) => {
createdSecret.value = null;
}
const namespace = 'forklift';
const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
// Create Provider first so we have its UID for the ownerReference on the Secret
@ -440,7 +441,7 @@ const testConnection = async(buttonCb) => {
connected = true;
break;
} else {
errorMsg = connectionCondition.message || 'Connection failed';
errorMsg = connectionCondition.message || t('harvester.addons.vmMigration.errors.connectionFailed');
break;
}
}
@ -450,7 +451,7 @@ const testConnection = async(buttonCb) => {
connected = true;
break;
} else if (readyCondition.status === 'False') {
errorMsg = readyCondition.message || 'Provider not ready';
errorMsg = readyCondition.message || t('harvester.addons.vmMigration.errors.providerNotReady');
break;
}
}
@ -498,12 +499,17 @@ const testConnection = async(buttonCb) => {
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
try {
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
});
} catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadProviders')];
}
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
});
loading.value = false;
};

View File

@ -77,7 +77,10 @@ const selectOptions = computed(() => {
/>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
<i class="icon icon-right-arrow-alt" />
<i
class="icon icon-right-arrow-alt"
aria-hidden="true"
/>
</div>
<div class="mapping-target">
<LabeledSelect

View File

@ -7,7 +7,9 @@ import { RcItemCard } from '@components/RcItemCard';
import { LabeledInput } from '@components/Form/LabeledInput';
import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
const props = defineProps({
providerName: { type: String, default: '' },
@ -34,7 +36,7 @@ const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
const NAMESPACE = 'forklift';
const NAMESPACE = FORKLIFT_NAMESPACE;
const TARGET_NAMESPACE = 'default';
watch(planName, (val) => {
@ -202,7 +204,7 @@ const startMigrationAction = async() => {
const migration = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ planName.value }-migration-${ Math.random().toString(36).substring(2, 7) }`,
name: `${ planName.value }-migration-${ randomStr(5).toLowerCase() }`,
namespace: NAMESPACE,
ownerReferences: [planOwnerRef],
},
@ -333,14 +335,17 @@ defineExpose({ startMigration: startMigrationAction });
:id="vm.id"
:key="vm.id"
:variant="'small'"
:header="{ title: { text: vm.name }, statuses: [{ icon: 'icon-notify-tick', color: 'text-success' }] }"
:header="{ title: { text: vm.name } }"
>
<template #item-card-content>
<div class="vm-card-content">
<div class="vm-card-specs">
<span class="vm-os text-deemphasized">{{ vm.os }}</span>
<span class="vm-resources">
<i class="icon icon-disk" />
<i
class="icon icon-disk"
aria-hidden="true"
/>
{{ vm.cpus }} vCPU &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}
</span>
</div>

View File

@ -5,6 +5,7 @@ import {
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState';
import { useI18n } from '@shell/composables/useI18n';
@ -28,6 +29,7 @@ const datastoreMap = ref({});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const selectedVMIds = ref(new Set());
const errors = ref([]);
let skipNextSelectionEvent = false;
const lastFetchedAt = ref(null);
@ -81,13 +83,13 @@ if (props.stepData.tableRows.length > 0) {
// 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);
@ -116,7 +118,7 @@ const headers = [
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName',
sort: ['vmName'],
subLabel: 'Identifier',
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
{
name: 'os',
@ -141,14 +143,14 @@ const headers = [
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network',
sort: ['network'],
subLabel: 'Identifier',
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
{
name: 'datastore',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore',
sort: ['datastore'],
subLabel: 'Identifier',
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
},
];
@ -337,10 +339,12 @@ const refreshVMs = async() => {
const previousSelectedIds = new Set(selectedVMIds.value);
try {
errors.value = [];
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
tableRows.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedRefreshVms')];
}
selectedVMIds.value = new Set(
@ -387,6 +391,7 @@ const init = async() => {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadVms')];
}
loading.value = false;
@ -407,6 +412,12 @@ init();
v-else
class="select-vms-step"
>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
<router-link

View File

@ -94,6 +94,7 @@ export const CSI_SECRETS = {
};
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
export const FORKLIFT_NAMESPACE = 'forklift';
// Some harvester CRD type is not equal to model file name, define the mapping here
export const HARVESTER_CRD_MAP = {

View File

@ -1813,6 +1813,18 @@ harvester:
'descheduler': 'The virtual machine auto balance optimizes workload scheduling by evicting pods that are not optimally placed according to administrator-defined policies.'
vmMigration:
generic:
unknown: Unknown
identifier: Identifier
podNetwork: Pod Network
errors:
failedLoadVms: Failed to load virtual machines
failedRefreshVms: Failed to refresh virtual machines
failedLoadProviders: Failed to load providers
failedResolveDetails: Failed to resolve network and datastore details
failedDecodeCredentials: Failed to decode provider credentials
connectionFailed: Connection failed
providerNotReady: Provider not ready
labels:
dashboard: Migrations
provider: Providers
@ -1905,6 +1917,10 @@ harvester:
plan: Migration Plan
progress: Progress
mappings: Mappings
progress:
failed: Failed
migration: Migration
initializingMigration: Initializing migration
configureMappings:
title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources
@ -1916,6 +1932,9 @@ harvester:
description: Map VMware port groups to Harvester networks
placeholder: Choose a Harvester network
template: Use existing network mapping as template
options:
podNetworking: Pod Networking
ignored: Ignored
storageMapping:
title: Storage Mapping
description: Map VMware datastores to Harvester storage classes
@ -1939,6 +1958,16 @@ harvester:
warningTitle: "Cold Migration Warning"
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
startMigration: Start Migration Plan
plan:
states:
error: Error
canceled: Canceled
inProgress: In Progress
active: Active
actions:
start: Start
restart: Restart
stop: Stop
vmImport:
titles:

View File

@ -1,6 +1,7 @@
import HarvesterResource from './harvester';
import { HCI } from '../types';
import { PRODUCT_NAME } from '../config/harvester';
import { randomStr } from '@shell/utils/string';
export default class ForkliftPlan extends HarvesterResource {
get listLocation() {
@ -87,22 +88,22 @@ export default class ForkliftPlan extends HarvesterResource {
get stateDisplay() {
if (this.planFailed) {
return 'Error';
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCritical) {
return 'Error';
return this.t('harvester.addons.vmMigration.plan.states.error');
}
if (this.planCanceled) {
return 'Canceled';
return this.t('harvester.addons.vmMigration.plan.states.canceled');
}
if (this.isMigrating) {
return 'In Progress';
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
return 'Active';
return this.t('harvester.addons.vmMigration.plan.states.active');
}
get stateBackground() {
@ -141,7 +142,7 @@ export default class ForkliftPlan extends HarvesterResource {
action: 'startMigration',
enabled: true,
icon: 'icon icon-play',
label: this.isForkliftDashboard ? 'Restart' : 'Start',
label: this.isForkliftDashboard ? this.t('harvester.addons.vmMigration.plan.actions.restart') : this.t('harvester.addons.vmMigration.plan.actions.start'),
});
}
@ -150,7 +151,7 @@ export default class ForkliftPlan extends HarvesterResource {
action: 'stopMigration',
enabled: true,
icon: 'icon icon-pause',
label: 'Stop',
label: this.t('harvester.addons.vmMigration.plan.actions.stop'),
});
}
@ -215,7 +216,7 @@ export default class ForkliftPlan extends HarvesterResource {
const migration = await this.$dispatch('create', {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ this.metadata.name }-migration-${ Math.random().toString(36).substring(2, 7) }`,
name: `${ this.metadata.name }-migration-${ randomStr(5).toLowerCase() }`,
namespace,
ownerReferences: [
{
@ -252,7 +253,7 @@ export default class ForkliftPlan extends HarvesterResource {
remove() {
const opt = { ...arguments };
opt.params = { propagationPolicy: 'Background' };
opt.params = { propagationPolicy: 'Foreground' };
return this._remove(opt);
}

View File

@ -44,7 +44,7 @@ const rows = computed(() => {
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' }${ e.destination?.type === 'pod' ? 'Pod Network' : (e.destination?.name || '-') }`);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' }${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' }${ e.destination?.storageClass || '-' }`);
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
@ -73,33 +73,33 @@ const rows = computed(() => {
}
if (step.error && !errorMsg) {
const reasons = (step.error.reasons || []).join('; ') || 'Error';
const reasons = (step.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.plan.states.error');
errorMsg = `${ step.name || `Step ${ idx + 1 }` }: ${ reasons }`;
}
if (step.phase === 'Failed' && !errorMsg) {
errorMsg = `${ step.name || `Step ${ idx + 1 }` }: Failed`;
errorMsg = `${ step.name || `Step ${ idx + 1 }` }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
}
}
});
// Fallback to VM-level error if no step-level error found
if (!errorMsg && vm.error) {
const reasons = (vm.error.reasons || []).join('; ') || 'Failed';
const reasons = (vm.error.reasons || []).join('; ') || t('harvester.addons.vmMigration.dashboard.progress.failed');
errorMsg = `${ currentStep || vm.error.phase || 'Migration' }: ${ reasons }`;
errorMsg = `${ currentStep || vm.error.phase || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ reasons }`;
}
overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it
if (!errorMsg && plan.planFailed) {
errorMsg = `${ currentStep || 'Migration' }: Failed`;
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
}
return {
vmName: vm.name || vm.id || 'Unknown',
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: overallProgress,
currentStep,
@ -111,10 +111,10 @@ const rows = computed(() => {
// If no migration progress but we have VMs in the spec, show them at 0%
if (plan.vmProgress.length === 0 && plan.spec?.vms?.length > 0) {
plan.vmProgress = plan.spec.vms.map((vm) => ({
vmName: vm.name || vm.id || 'Unknown',
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '',
progress: 0,
currentStep: 'Initializing migration',
currentStep: t('harvester.addons.vmMigration.dashboard.progress.initializingMigration'),
errorMsg: '',
canceled: false,
}));

View File

@ -203,9 +203,13 @@ const init = async() => {
);
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';
try {
stepData.provider.username = atob(secret.data.user || '');
stepData.provider.password = atob(secret.data.password || '');
stepData.provider.skipTlsVerify = atob(secret.data.insecureSkipVerify || '') === 'true';
} catch (e) {
errors.value = [t('harvester.addons.vmMigration.errors.failedDecodeCredentials')];
}
stepData.provider.createdSecret = secret;
}
}