feat: Added some final fixes to structure

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-07-07 13:18:52 +02:00
parent f270a7b1f8
commit a11369ad6d
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
10 changed files with 140 additions and 170 deletions

View File

@ -8,6 +8,7 @@ import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string'; import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { buildNetworkMapEntries, buildStorageMapEntries } from '../../utils/forklift';
import MappingColumn from './MappingColumn.vue'; import MappingColumn from './MappingColumn.vue';
const props = defineProps({ const props = defineProps({
@ -254,49 +255,15 @@ const formatStorageDetail = (entry) => {
return parts.join(' • '); return parts.join(' • ');
}; };
const buildNetworkMapSpec = (providerRef) => { const buildNetworkMapSpec = (providerRef) => ({
return { map: buildNetworkMapEntries(networkEntries.value, NAMESPACE),
map: networkEntries.value.filter((entry) => !!entry.target).map((entry) => { provider: providerRef,
if (entry.target === 'pod') { });
return {
source: { name: entry.name, id: entry.id },
destination: { type: 'pod' },
};
}
if (entry.target === 'ignored') { const buildStorageMapSpec = (providerRef) => ({
return { map: buildStorageMapEntries(storageEntries.value),
source: { name: entry.name, id: entry.id }, provider: providerRef,
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 buildStorageMapSpec = (providerRef) => {
return {
map: storageEntries.value.filter((entry) => !!entry.target).map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
})),
provider: providerRef,
};
};
const saveAndReturn = async() => { const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;

View File

@ -12,6 +12,7 @@ import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import { decodeSecretValue } from '../../utils/forklift';
const CREATE_NEW = '__create_new__'; const CREATE_NEW = '__create_new__';
@ -114,17 +115,9 @@ watch(selectedProvider, (val) => {
); );
if (secret?.data) { if (secret?.data) {
const decode = (val) => { username.value = decodeSecretValue(secret.data.user);
try { password.value = decodeSecretValue(secret.data.password);
return atob(val || ''); skipTlsVerify.value = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
} catch (e) {
return '';
}
};
username.value = decode(secret.data.user);
password.value = decode(secret.data.password);
skipTlsVerify.value = decode(secret.data.insecureSkipVerify) === 'true';
} }
} }

View File

@ -7,9 +7,11 @@ import { RcItemCard } from '@components/RcItemCard';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import MappingsCell from '../MappingsCell'; import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { randomStr } from '@shell/utils/string';
import { HCI } from '../../types'; import { HCI } from '../../types';
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map'; import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
import {
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
} from '../../utils/forklift';
const props = defineProps({ const props = defineProps({
providerName: { type: String, default: '' }, providerName: { type: String, default: '' },
@ -47,7 +49,7 @@ const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCoun
const totalMemoryGB = computed(() => { const totalMemoryGB = computed(() => {
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0); const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
return Math.round(totalMB / 1024); return mbToGB(totalMB);
}); });
const totalStorageGB = computed(() => { const totalStorageGB = computed(() => {
@ -59,14 +61,14 @@ const totalStorageGB = computed(() => {
} }
}); });
return Math.round(totalBytes / (1024 * 1024 * 1024)); return bytesToGB(totalBytes);
}); });
const vmCards = computed(() => { const vmCards = computed(() => {
return vms.value.map((vm) => { return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0; const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0; const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(memMB / 1024) }) : '-'; const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0; let totalDiskBytes = 0;
@ -74,7 +76,7 @@ const vmCards = computed(() => {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0); totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
} }
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(totalDiskBytes / (1024 * 1024 * 1024)) }) : '-'; const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-'; const os = vm.guestName || vm.guestOS || vm.os || '-';
const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id)); const vmNetworkMappings = networkMappings.value.filter((m) => m.usedBy && m.usedBy.includes(vm.name || vm.id));
@ -93,52 +95,18 @@ const vmCards = computed(() => {
}); });
}); });
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 startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
const providerRef = { const providerRef = {
source: { source: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider', kind: 'Provider',
name: props.providerName, name: props.providerName,
namespace: NAMESPACE, namespace: NAMESPACE,
}, },
destination: { destination: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider', kind: 'Provider',
name: 'host', name: 'host',
namespace: NAMESPACE, namespace: NAMESPACE,
@ -151,7 +119,7 @@ const startMigrationAction = async() => {
const networkMap = await store.dispatch(`${ inStore }/create`, { const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP, type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NAMESPACE }, metadata: { name: networkMapName, namespace: NAMESPACE },
spec: { map: buildNetworkMapSpec(), provider: providerRef }, spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
}); });
await networkMap.save(); await networkMap.save();
@ -159,7 +127,7 @@ const startMigrationAction = async() => {
const storageMap = await store.dispatch(`${ inStore }/create`, { const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP, type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NAMESPACE }, metadata: { name: storageMapName, namespace: NAMESPACE },
spec: { map: buildStorageMapSpec(), provider: providerRef }, spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
}); });
await storageMap.save(); await storageMap.save();
@ -171,19 +139,19 @@ const startMigrationAction = async() => {
provider: providerRef, provider: providerRef,
map: { map: {
network: { network: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap', kind: 'NetworkMap',
name: networkMapName, name: networkMapName,
namespace: NAMESPACE, namespace: NAMESPACE,
}, },
storage: { storage: {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap', kind: 'StorageMap',
name: storageMapName, name: storageMapName,
namespace: NAMESPACE, namespace: NAMESPACE,
}, },
}, },
targetNamespace: 'default', targetNamespace: TARGET_NAMESPACE,
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })), vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false, warm: false,
}, },
@ -192,36 +160,21 @@ const startMigrationAction = async() => {
await plan.save(); await plan.save();
const planOwnerRef = { const planOwnerRef = {
apiVersion: 'forklift.konveyor.io/v1beta1', apiVersion: FORKLIFT_API_VERSION,
kind: 'Plan', kind: 'Plan',
name: plan.metadata.name, name: plan.metadata.name,
uid: plan.metadata.uid, uid: plan.metadata.uid,
blockOwnerDeletion: true, blockOwnerDeletion: true,
}; };
const migration = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ planName.value }-migration-${ randomStr(5).toLowerCase() }`,
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]; networkMap.metadata.ownerReferences = [planOwnerRef];
await networkMap.save(); await networkMap.save();
storageMap.metadata.ownerReferences = [planOwnerRef]; storageMap.metadata.ownerReferences = [planOwnerRef];
await storageMap.save(); await storageMap.save();
// Kick off the first migration through the model so the Migration payload
// lives in a single place (also reused by the dashboard start/restart action).
await plan.startMigration();
}; };
const init = () => { const init = () => {
@ -464,32 +417,6 @@ defineExpose({ startMigration: startMigrationAction });
} }
} }
.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 { .banner-title {
font-weight: 600; font-weight: 600;
} }

View File

@ -8,6 +8,7 @@ import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { BadgeState } from '@components/BadgeState'; import { BadgeState } from '@components/BadgeState';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { bytesToGB, mbToGB } from '../../utils/forklift';
const props = defineProps({ const props = defineProps({
providerName: { type: String, default: '' }, providerName: { type: String, default: '' },
@ -139,7 +140,7 @@ const buildTableRows = () => {
return discoveredVMs.value.map((vm) => { return discoveredVMs.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || '-'; const cpus = vm.cpuCount || vm.numCPU || '-';
const memMB = vm.memoryMB || vm.memory || 0; const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(memMB / 1024) }) : '-'; const memGB = memMB ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
let totalDiskBytes = 0; let totalDiskBytes = 0;
@ -147,7 +148,7 @@ const buildTableRows = () => {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0); totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
} }
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: Math.round(totalDiskBytes / (1024 * 1024 * 1024)) }) : '-'; const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-'; const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase()); const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
@ -524,11 +525,6 @@ init();
<style lang="scss" scoped> <style lang="scss" scoped>
.vm-table-title { .vm-table-title {
h4 {
margin: 0;
font-weight: 600;
}
.text-deemphasized { .text-deemphasized {
font-size: 13px; font-size: 13px;
} }

View File

@ -250,9 +250,7 @@ export default class ForkliftPlan extends HarvesterResource {
* Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider ( Secret). * Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider ( Secret).
* Use foreground propagation to ensure children are deleted before the parent. * Use foreground propagation to ensure children are deleted before the parent.
*/ */
remove() { remove(opt = {}) {
const opt = { ...arguments };
opt.params = { propagationPolicy: 'Foreground' }; opt.params = { propagationPolicy: 'Foreground' };
return this._remove(opt); return this._remove(opt);

View File

@ -28,9 +28,7 @@ export default class ForkliftProvider extends HarvesterResource {
* Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap. * Kubernetes GC will automatically delete: Secret, NetworkMap, StorageMap.
* Use foreground propagation to ensure children are deleted before the parent. * Use foreground propagation to ensure children are deleted before the parent.
*/ */
remove() { remove(opt = {}) {
const opt = { ...arguments };
opt.params = { propagationPolicy: 'Foreground' }; opt.params = { propagationPolicy: 'Foreground' };
return this._remove(opt); return this._remove(opt);

View File

@ -150,7 +150,6 @@ const headers = [
{ {
name: 'mappings', name: 'mappings',
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings', labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
value: 'mappingsDisplay',
}, },
{ ...AGE }, { ...AGE },
]; ];

View File

@ -9,6 +9,7 @@ import ConfigureProviderStep from '@pkg/harvester/components/vm-migration/Config
import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue'; import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/ConfigureMappingsStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester'; import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter, currentRoute } from '@pkg/harvester/utils/router'; import { currentRouter, currentRoute } from '@pkg/harvester/utils/router';
import { decodeSecretValue } from '@pkg/harvester/utils/forklift';
import { HCI } from '@pkg/harvester/types'; import { HCI } from '@pkg/harvester/types';
const store = useStore(); const store = useStore();
@ -79,10 +80,19 @@ watch(mappingsReady, (val) => {
steps[1].ready = val; steps[1].ready = val;
}); });
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard); const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false); const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady`, mark the step ready and advance
// (goToStep(2)). If the test fails, the user simply stays on the provider step.
watch( watch(
() => wizardComponent.value?.activeStepIndex, () => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => { (newIdx, oldIdx) => {
@ -198,17 +208,9 @@ const init = async() => {
); );
if (secret?.data) { if (secret?.data) {
const decode = (val) => { stepData.provider.username = decodeSecretValue(secret.data.user);
try { stepData.provider.password = decodeSecretValue(secret.data.password);
return atob(val || ''); stepData.provider.skipTlsVerify = decodeSecretValue(secret.data.insecureSkipVerify) === 'true';
} catch (e) {
return '';
}
};
stepData.provider.username = decode(secret.data.user);
stepData.provider.password = decode(secret.data.password);
stepData.provider.skipTlsVerify = decode(secret.data.insecureSkipVerify) === 'true';
stepData.provider.createdSecret = secret; stepData.provider.createdSecret = secret;
} }
} }

View File

@ -98,10 +98,20 @@ watch(reviewReady, (val) => {
steps[3].ready = val; steps[3].ready = val;
}); });
// CruResource exposes its inner Wizard via $refs; we reach into it to drive the
// "test connection before advancing" handshake, since the provider must be verified
// before the user can leave the first step.
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard); const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
const pendingProceed = ref(false); const pendingProceed = ref(false);
// Handshake to gate step 1 -> 2 on a successful connection test:
// 1. When the user tries to advance off the provider step before it's ready,
// snap back (goToStep is 1-based, so goToStep(1) == the provider step) and
// programmatically trigger the async "Test connection" button.
// 2. Once the test flips `providerReady` (watcher below), mark the step ready and
// advance to the next step (goToStep(2)). If the test fails, `providerTesting`
// watcher clears `pendingProceed` and the user stays put.
watch( watch(
() => wizardComponent.value?.activeStepIndex, () => wizardComponent.value?.activeStepIndex,
(newIdx, oldIdx) => { (newIdx, oldIdx) => {

View File

@ -0,0 +1,80 @@
// Shared helpers for the Forklift VM-migration feature.
// Keep the map-spec builders here so the wizard step and the review step
// (and any future edit flow) stay in sync instead of drifting apart.
export const FORKLIFT_API_VERSION = 'forklift.konveyor.io/v1beta1';
/**
* Decode a base64 value stored in a k8s Secret, tolerating malformed input.
*/
export function decodeSecretValue(val) {
try {
return atob(val || '');
} catch (e) {
return '';
}
}
/**
* Bytes whole GiB (rounded). Returns 0 for falsy input.
*/
export function bytesToGB(bytes) {
return Math.round((bytes || 0) / (1024 * 1024 * 1024));
}
/**
* MiB whole GiB (rounded). Returns 0 for falsy input.
*/
export function mbToGB(mb) {
return Math.round((mb || 0) / 1024);
}
/**
* Build the `spec.map` entries for a Forklift NetworkMap from wizard entries.
* Entries without a chosen target are dropped so we never emit an invalid
* multus destination with an empty name.
*
* @param {Array} entries network mapping entries ({ name, id, target })
* @param {string} defaultNamespace namespace to use when the target has no `ns/` prefix
*/
export function buildNetworkMapEntries(entries = [], defaultNamespace) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => {
const source = { name: entry.name, id: entry.id };
if (entry.target === 'pod') {
return { source, destination: { type: 'pod' } };
}
if (entry.target === 'ignored') {
return { source, destination: { type: 'ignored' } };
}
const parts = entry.target.split('/');
const name = parts.length > 1 ? parts[1] : parts[0];
const namespace = parts.length > 1 ? parts[0] : defaultNamespace;
return {
source,
destination: {
type: 'multus', name, namespace
},
};
});
}
/**
* Build the `spec.map` entries for a Forklift StorageMap from wizard entries.
* Entries without a chosen target are dropped.
*
* @param {Array} entries storage mapping entries ({ name, id, target })
*/
export function buildStorageMapEntries(entries = []) {
return entries
.filter((entry) => !!entry.target)
.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
}));
}