feat: improve VM migration dashboard mapping labels and VM detail link (#1029) (#1030)

* feat: improve VM migration dashboard mapping labels and VM detail link

- Display Management Network / Ignored labels via i18n in mappings column and review card
- Add clickable external-link icon to migrated VM detail page after successful migration
- Add tooltip to the VM detail external-link icon



* refactor: modify duration column width



* refactor: copilot review comment



---------


(cherry picked from commit a10d6518271e999697340165c839df9e24fd75a8)

Signed-off-by: Andy Lee <andy.lee@suse.com>
Co-authored-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
mergify[bot] 2026-07-20 14:42:53 +08:00 committed by GitHub
parent 6e709bc00f
commit e991c9bcc8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 69 additions and 6 deletions

View File

@ -55,6 +55,18 @@ watch([planName, targetNamespace], ([name, namespace]) => {
emit('ready', !!name && !!namespace); emit('ready', !!name && !!namespace);
}, { immediate: true }); }, { immediate: true });
const formatNetworkTarget = (target = '') => {
if (target === 'pod') {
return t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking');
}
if (target === 'ignored') {
return t('harvester.addons.vmMigration.generic.ignored');
}
return target;
};
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0)); const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => { const totalMemoryGB = computed(() => {
@ -331,7 +343,7 @@ defineExpose({ startMigration: startMigrationAction });
</span> </span>
</div> </div>
<MappingsCell <MappingsCell
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)" :network-entries="vm.networkMappings.map(m => `${m.source} → ${formatNetworkTarget(m.target)}`)"
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)" :storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
/> />
</div> </div>

View File

@ -286,7 +286,7 @@ export const FORKLIFT_PLAN_VM_COUNT = {
labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount', labelKey: 'harvester.tableHeaders.vmMigrationPlanVmCount',
value: 'spec.vms.length', value: 'spec.vms.length',
sort: 'spec.vms.length', sort: 'spec.vms.length',
align: 'left', align: 'center',
}; };
// Plan reference column in forklift.konveyor.io.migration list page // Plan reference column in forklift.konveyor.io.migration list page

View File

@ -1837,6 +1837,7 @@ harvester:
unknown: Unknown unknown: Unknown
identifier: Identifier identifier: Identifier
podNetwork: Pod Network podNetwork: Pod Network
ignored: Ignored
usedBy: "Used by:" usedBy: "Used by:"
vlan: "VLAN {id}" vlan: "VLAN {id}"
vCpu: "{count} vCPU" vCpu: "{count} vCPU"
@ -1957,6 +1958,7 @@ harvester:
step: "Step {index}" step: "Step {index}"
vmCount: "{count, plural, one {# VM} other {# VMs}}" vmCount: "{count, plural, one {# VM} other {# VMs}}"
vmId: "id: {id}" vmId: "id: {id}"
checkMigratedVm: Click to check migrated VM details
configureMappings: configureMappings:
title: Set Mappings title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources description: Map VMware networks and datastores to Harvester and Longhorn target resources

View File

@ -37,6 +37,7 @@ const inStore = computed(() => store.getters['currentProduct'].inStore);
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN)); const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP)); const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP)); const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
const allVMs = computed(() => store.getters[`${ inStore.value }/all`](HCI.VM));
const escapeHtml = (value = '') => String(value) const escapeHtml = (value = '') => String(value)
.replaceAll('&', '&amp;') .replaceAll('&', '&amp;')
@ -129,8 +130,8 @@ const rows = computed(() => {
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs); 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); 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' ? t('harvester.addons.vmMigration.generic.podNetwork') : (e.destination?.name || '-') }`); plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' }${ e.destination?.type === 'pod' ? t('harvester.addons.vmMigration.configureMappings.networkMapping.options.podNetworking') : (e.destination?.name || 'Ignored') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' }${ e.destination?.storageClass || '-' }`); plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || 'Ignored' }${ e.destination?.storageClass || 'Ignored' }`);
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-'; plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
@ -183,6 +184,24 @@ const rows = computed(() => {
errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`; errorMsg = `${ currentStep || t('harvester.addons.vmMigration.dashboard.progress.migration') }: ${ t('harvester.addons.vmMigration.dashboard.progress.failed') }`;
} }
const vmNamespace = vm.namespace || plan.spec?.targetNamespace || plan.metadata?.namespace;
const routeParams = currentRouter().currentRoute?.value?.params || {};
const product = routeParams.product || store.getters['productId'];
const cluster = routeParams.cluster || store.getters['clusterId'];
const vmNameCandidates = [vm.targetName, vm.name, vm.id].filter(Boolean);
const targetVm = allVMs.value.find((item) => vmNameCandidates.includes(item.metadata?.name) && (!vmNamespace || item.metadata?.namespace === vmNamespace));
const canNavigateToVm = overallProgress >= 100 && !errorMsg && !plan.planFailed && !plan.planCanceled && !!targetVm && !!product && !!cluster;
const vmDetailLocation = canNavigateToVm ? {
name: `${ PRODUCT_NAME }-c-cluster-resource-namespace-id`,
params: {
product,
cluster,
resource: HCI.VM,
namespace: targetVm.metadata.namespace,
id: targetVm.metadata.name,
}
} : null;
return { return {
vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'), vmName: vm.name || vm.id || t('harvester.addons.vmMigration.generic.unknown'),
vmId: vm.id || '', vmId: vm.id || '',
@ -191,6 +210,7 @@ const rows = computed(() => {
pipelineTooltip: formatPipelineTooltip(pipeline), pipelineTooltip: formatPipelineTooltip(pipeline),
errorMsg, errorMsg,
canceled: plan.planCanceled, canceled: plan.planCanceled,
vmDetailLocation,
}; };
}); });
@ -248,7 +268,7 @@ const headers = [
labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration', labelKey: 'harvester.addons.vmMigration.dashboard.columns.duration',
value: 'duration', value: 'duration',
sort: 'durationSeconds', sort: 'durationSeconds',
width: 120, width: 90,
}, },
{ ...AGE }, { ...AGE },
]; ];
@ -283,6 +303,17 @@ const init = async() => {
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }), store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }), store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]); ]);
// VMs are only needed to conditionally show the post-migration VM-detail
// link, so load them separately and non-fatally: a failure here (or the
// cost of findAll on large clusters) should not block plans/maps from
// rendering.
try {
await store.dispatch(`${ inStore.value }/findAll`, { type: HCI.VM });
} catch (e) {
// Intentionally ignored: the dashboard still works without the VM list,
// the migrated VM-detail link simply won't be shown.
}
} catch (e) { } catch (e) {
errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')]; errors.value = [e?.message || t('harvester.addons.vmMigration.errors.failedLoadPlans')];
} finally { } finally {
@ -366,7 +397,17 @@ init();
> >
<div class="vm-progress-header"> <div class="vm-progress-header">
<div class="vm-name-block"> <div class="vm-name-block">
<span class="vm-name">{{ vm.vmName }}</span> <span class="vm-name">
{{ vm.vmName }}
<router-link
v-if="vm.vmDetailLocation"
v-clean-tooltip="t('harvester.addons.vmMigration.dashboard.progress.checkMigratedVm')"
:to="vm.vmDetailLocation"
class="vm-detail-link"
>
<i class="icon icon-external-link" />
</router-link>
</span>
<span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span> <span class="text-muted vm-id">{{ t('harvester.addons.vmMigration.dashboard.progress.vmId', { id: vm.vmId }) }}</span>
</div> </div>
</div> </div>
@ -457,6 +498,14 @@ init();
.vm-name { .vm-name {
font-size: 14px; font-size: 14px;
display: inline-flex;
align-items: center;
gap: 4px;
}
.vm-detail-link {
display: inline-flex;
align-items: center;
} }
.vm-name-block { .vm-name-block {