Compare commits

...
Author SHA1 Message Date
Andy Lee 1940b71541 build: test pr
Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:35:44 +08:00
Andy Lee 719c33fa12 ci: fix CI_BRANCH ternary so PR builds skip upload gate (#1062)
* ci: fix CI_BRANCH ternary so PR builds skip upload gate

Signed-off-by: Andy Lee <andy.lee@suse.com>

* ci: simplify CI_BRANCH expression comment

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:34:21 +08:00
Andy Lee a6bd7ec00e feat: add VMware VDDK image input field to VM migration provider (#1057)
* feat: add VMware VDDK image option to VM migration provider

Add an optional VMware VDDK Image field to the Configure Provider step of the VM migration wizard, bound to spec.settings.vddkInitImage when set and left unset when empty. Supports create and edit flows and populates from the existing provider when editing.

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: copilot review

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 14:10:44 +08:00
Andy Lee ac26fe2f0e ci: trigger standalone build when PR labeled with build-artifact (#1058)
* ci: trigger standalone build when PR labeled with build-artifact

Signed-off-by: Andy Lee <andy.lee@suse.com>

* ci: skip upload gate for PR-triggered standalone builds

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-28 13:56:11 +08:00
Andy Lee 790cade04d feat: validate migration plan name against existing providers and plans (#1053)
Show an error banner and disable the Create and Start action when the migration plan name matches an existing provider or migration plan name.

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-24 16:43:57 +08:00
Andy Lee 2903e86c02 refactor: new design for review page (#1051)
* refactor: new design for review page

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: customize empty table rows message

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: fixed the table column

Signed-off-by: Andy Lee <andy.lee@suse.com>

* style: update header tag and align fixed header actions in review migration step

Signed-off-by: Andy Lee <andy.lee@suse.com>

* style: remove unneeded overflow css

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
2026-07-24 10:24:22 +08:00
6 changed files with 472 additions and 190 deletions
@@ -13,13 +13,23 @@ on:
- '*-dev' - '*-dev'
types: types:
- merged - merged
- labeled
jobs: jobs:
build-validation: build-validation:
name: Build Test name: Build Test
# allow triggering when a PR is labeled with "build-artifact"
if: >-
github.event_name != 'pull_request' ||
github.event.action != 'labeled' ||
github.event.label.name == 'build-artifact'
uses: ./.github/workflows/run-lint.yaml uses: ./.github/workflows/run-lint.yaml
build: build:
name: Build and Upload Package name: Build and Upload Package
if: >-
github.event_name != 'pull_request' ||
github.event.action != 'labeled' ||
github.event.label.name == 'build-artifact'
uses: ./.github/workflows/build-and-publish-standalone.yaml uses: ./.github/workflows/build-and-publish-standalone.yaml
needs: needs:
- build-validation - build-validation
@@ -28,4 +38,4 @@ jobs:
packages: write packages: write
id-token: write id-token: write
with: with:
CI_BRANCH: ${{github.ref_name}} CI_BRANCH: ${{ github.event_name == 'push' && github.ref_name || '' }}
@@ -42,6 +42,7 @@ const {
username, username,
password, password,
skipTlsVerify, skipTlsVerify,
vddkInitImage,
testPassed, testPassed,
testResult, testResult,
testError, testError,
@@ -97,6 +98,7 @@ watch(selectedProvider, (val) => {
username.value = ''; username.value = '';
password.value = ''; password.value = '';
skipTlsVerify.value = false; skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null; createdProvider.value = null;
createdSecret.value = null; createdSecret.value = null;
} else { } else {
@@ -107,6 +109,7 @@ watch(selectedProvider, (val) => {
if (provider) { if (provider) {
providerName.value = provider.metadata.name; providerName.value = provider.metadata.name;
url.value = provider.spec?.url || ''; url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret; const secretRef = provider.spec?.secret;
@@ -239,6 +242,18 @@ const testConnection = async(buttonCb) => {
if (props.editMode && createdProvider.value) { if (props.editMode && createdProvider.value) {
try { try {
createdProvider.value.spec.url = url.value; createdProvider.value.spec.url = url.value;
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
createdProvider.value.spec.settings = {
...(createdProvider.value.spec.settings || {}),
vddkInitImage: vddkImage,
};
} else if (createdProvider.value.spec.settings) {
delete createdProvider.value.spec.settings.vddkInitImage;
}
await createdProvider.value.save(); await createdProvider.value.save();
const secretRef = createdProvider.value.spec?.secret; const secretRef = createdProvider.value.spec?.secret;
@@ -283,20 +298,28 @@ const testConnection = async(buttonCb) => {
const namespace = FORKLIFT_NAMESPACE; const namespace = FORKLIFT_NAMESPACE;
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`; const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const spec = {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
};
const vddkImage = vddkInitImage.value?.trim();
if (vddkImage) {
spec.settings = { vddkInitImage: vddkImage };
}
const provider = await store.dispatch(`${ inStore }/create`, { const provider = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PROVIDER, type: HCI.FORKLIFT_PROVIDER,
metadata: { metadata: {
name: providerName.value, name: providerName.value,
namespace, namespace,
}, },
spec: { spec,
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
}
}); });
await provider.save(); await provider.save();
@@ -420,17 +443,28 @@ defineExpose({ testConnection, clickTestButton });
/> />
</div> </div>
<div> <div class="row">
<LabeledInput <div class="col span-6">
v-model:value="url" <LabeledInput
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')" v-model:value="url"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')" :label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:disabled="isExistingProvider && !editMode" :placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
required :disabled="isExistingProvider && !editMode"
/> required
<p class="text-deemphasized mt-5"> />
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }} <p class="text-deemphasized mt-5">
</p> {{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="vddkInitImage"
:label="t('harvester.addons.vmMigration.configureProvider.vddkImageLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.vddkImagePlaceholder')"
:tooltip="t('harvester.addons.vmMigration.configureProvider.vddkImageTooltip')"
:disabled="isExistingProvider && !editMode"
/>
</div>
</div> </div>
<div class="row"> <div class="row">
@@ -2,11 +2,12 @@
import { ref, computed, watch } from 'vue'; import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex'; import { useStore } from 'vuex';
import Loading from '@shell/components/Loading'; import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { Banner } from '@components/Banner'; import { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard'; import { BadgeState } from '@components/BadgeState';
import { RcDropdown, RcDropdownItem, RcDropdownTrigger } from '@components/RcDropdown';
import { LabeledInput } from '@components/Form/LabeledInput'; import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect'; import LabeledSelect from '@shell/components/form/LabeledSelect';
import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n'; import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types'; import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types'; import { HCI } from '../../types';
@@ -23,17 +24,19 @@ const props = defineProps({
stepData: { type: Object, required: true }, stepData: { type: Object, required: true },
}); });
const emit = defineEmits(['ready']); const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
const vms = ref([]); const vms = computed(() => props.selectedVms);
const networkMappings = ref([]); const networkMappings = ref([]);
const storageMappings = ref([]); const storageMappings = ref([]);
const planName = ref(''); const planName = ref('');
const targetNamespace = ref(''); const targetNamespace = ref('');
const namespaceOptions = ref([]); const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]); const errors = ref([]);
const loading = ref(true); const loading = ref(true);
@@ -43,6 +46,24 @@ targetNamespace.value = props.stepData.targetNamespace || '';
const NS = FORKLIFT_NAMESPACE; const NS = FORKLIFT_NAMESPACE;
const nameConflictError = computed(() => {
const name = (planName.value || '').trim();
if (!name) {
return '';
}
if (existingProviderNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNameProviderConflict', { name });
}
if (existingPlanNames.value.includes(name)) {
return t('harvester.addons.vmMigration.reviewMigration.planNamePlanConflict', { name });
}
return '';
});
watch(planName, (val) => { watch(planName, (val) => {
props.stepData.planName = val; props.stepData.planName = val;
}, { immediate: true }); }, { immediate: true });
@@ -51,8 +72,8 @@ watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val; props.stepData.targetNamespace = val;
}); });
watch([planName, targetNamespace], ([name, namespace]) => { watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!name && !!namespace); emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true }); }, { immediate: true });
const formatNetworkTarget = (target = '') => { const formatNetworkTarget = (target = '') => {
@@ -87,7 +108,7 @@ const totalStorageGB = computed(() => {
return bytesToGB(totalBytes); return bytesToGB(totalBytes);
}); });
const vmCards = computed(() => { const vmRows = 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;
@@ -101,23 +122,93 @@ const vmCards = computed(() => {
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-'; 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 rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
const powerStateClass = powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off';
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));
const vmStorageMappings = storageMappings.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));
const networkMappingSort = vmNetworkMappings.map((m) => `${ m.source } ${ formatNetworkTarget(m.target) }`).join(', ');
const storageMappingSort = vmStorageMappings.map((m) => `${ m.source } ${ m.target }`).join(', ');
return { return {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id, id: vm.id,
name: vm.name || vm.id, name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os, os,
cpus, resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
memGB, resourcesSub: `${ memGB } • ${ diskDisplay }`,
diskDisplay, powerState,
powerStateClass,
networkMappings: vmNetworkMappings, networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings, storageMappings: vmStorageMappings,
networkMapping: networkMappingSort,
storageMapping: storageMappingSort,
}; };
}); });
}); });
const vmHeaders = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.vmName',
value: 'name',
sort: ['name'],
subLabel: t('harvester.addons.vmMigration.generic.identifier'),
width: 200,
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.os',
value: 'os',
sort: ['os'],
width: 140,
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.resources',
value: 'resources',
sort: false,
width: 120,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.powerState',
value: 'powerState',
sort: ['powerState'],
width: 100,
},
{
name: 'networkMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.networkMapping',
value: 'networkMapping',
sort: ['networkMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'storageMapping',
labelKey: 'harvester.addons.vmMigration.reviewMigration.columns.storageMapping',
value: 'storageMapping',
sort: ['storageMapping'],
subLabel: t('harvester.addons.vmMigration.reviewMigration.columns.sourceTarget'),
width: 220,
},
{
name: 'actions',
label: ' ',
align: 'right',
sort: false,
width: 40,
},
];
const onEditSelection = () => emit('edit-selection');
const onEditMappings = () => emit('edit-mappings');
const onRemoveVm = (id) => emit('remove-vm', id);
const startMigrationAction = async() => { const startMigrationAction = async() => {
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
@@ -201,8 +292,6 @@ const startMigrationAction = async() => {
}; };
const init = async() => { const init = async() => {
vms.value = props.selectedVms;
const inStore = store.getters['currentProduct'].inStore; const inStore = store.getters['currentProduct'].inStore;
try { try {
@@ -213,6 +302,22 @@ const init = async() => {
namespaceOptions.value = []; namespaceOptions.value = [];
} }
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {}
existingProviderNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
try {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
} catch (e) {}
existingPlanNames.value = (store.getters[`${ inStore }/all`](HCI.FORKLIFT_PLAN) || [])
.map((p) => p.metadata?.name)
.filter(Boolean);
if (props.mappingEntries) { if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({ networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id, source: entry.name || entry.id,
@@ -244,12 +349,31 @@ defineExpose({ startMigration: startMigrationAction });
<div <div
class="review-migration-content" class="review-migration-content"
> >
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary --> <!-- Migration Details Summary -->
<div class="migration-details"> <div class="migration-details">
<h3 class="section-title m-0"> <div class="section-header">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }} <h3 class="section-title m-0">
</h3> {{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<span class="section-description text-deemphasized">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetailsDescription') }}
</span>
</div>
<div class="name-row"> <div class="name-row">
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
<LabeledSelect <LabeledSelect
v-model:value="targetNamespace" v-model:value="targetNamespace"
class="namespace-select" class="namespace-select"
@@ -258,111 +382,145 @@ defineExpose({ startMigration: startMigrationAction });
:options="namespaceOptions" :options="namespaceOptions"
required required
/> />
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
</div> </div>
<div class="details-grid span-9">
<div class="detail-column"> <div class="summary-row">
<div class="detail-item"> <div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
<span class="detail-value detail-value-large">{{ vms.length }}</span> <span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
</div> <span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
</div> </div>
<div class="detail-column">
<div class="detail-item"> <div class="stats-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
</div> <span class="detail-value">{{ providerName }}</span>
<div class="detail-item"> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value">{{ providerName }}</span> <span class="detail-value">{{ vms.length }}</span>
</div> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
</div> <span class="detail-value">{{ totalVCpu }}</span>
<div class="detail-column"> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<div class="detail-item"> <span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span> <span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
</div> <span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
<div class="detail-item">
<span class="detail-value">{{ targetNamespace }}</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">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</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> </div>
</div> </div>
<!-- Virtual Machines --> <!-- Virtual Machines -->
<div class="vm-section"> <div class="vm-section">
<h3 class="section-title m-0"> <SortableTable
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }}) class="vm-sortable-table"
</h3> :rows="vmRows"
:headers="vmHeaders"
<div class="vm-cards-grid"> :search="false"
<RcItemCard :table-actions="false"
v-for="vm in vmCards" :row-actions="false"
:id="vm.id" :groupable="false"
:key="vm.id" :paging="true"
:variant="'small'" key-field="_key"
:header="{ title: { text: vm.name } }" >
> <template #header-left>
<template #item-card-content> <h4 class="section-title m-0">
<div class="vm-card-content"> {{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
<div class="vm-card-specs"> </h4>
<span class="vm-os text-deemphasized">{{ vm.os }}</span> </template>
<span class="vm-resources"> <template #header-right>
<i <a
class="icon icon-disk" href="#"
aria-hidden="true" class="edit-selection-link"
/> @click.prevent="onEditSelection"
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }} >
</span> {{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
</div> </a>
<MappingsCell </template>
:network-entries="vm.networkMappings.map(m => `${m.source} → ${formatNetworkTarget(m.target)}`)" <template #cell:vmName="{ row }">
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)" <div class="vm-name-cell">
/> <span class="vm-name">{{ row.name }}</span>
<span class="vm-id text-deemphasized">{{ row.identifier }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resources }}</span><br>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:networkMapping="{ row }">
<template v-if="row.networkMappings.length">
<div
v-for="(m, i) in row.networkMappings"
:key="'net-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ formatNetworkTarget(m.target) }}</span>
</div> </div>
</template> </template>
</RcItemCard> <span
</div> v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:storageMapping="{ row }">
<template v-if="row.storageMappings.length">
<div
v-for="(m, i) in row.storageMappings"
:key="'stor-' + i"
class="mapping-cell"
>
<span class="mapping-source">{{ m.source }}</span>
<span class="mapping-target text-deemphasized">&rarr; {{ m.target }}</span>
</div>
</template>
<span
v-else
class="text-deemphasized"
>-</span>
</template>
<template #cell:actions="{ row }">
<rc-dropdown :aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')">
<rc-dropdown-trigger
variant="ghost"
:aria-label="t('harvester.addons.vmMigration.reviewMigration.actions.menuLabel')"
>
<i class="icon icon-actions" />
</rc-dropdown-trigger>
<template #dropdownCollection>
<rc-dropdown-item @click="onEditMappings">
{{ t('harvester.addons.vmMigration.reviewMigration.actions.editMappings') }}
</rc-dropdown-item>
<rc-dropdown-item
class="text-error"
@click="onRemoveVm(row.id)"
>
{{ t('harvester.addons.vmMigration.reviewMigration.actions.removeFromPlan') }}
</rc-dropdown-item>
</template>
</rc-dropdown>
</template>
<template #no-rows>
<tr class="no-rows">
<td
:colspan="vmHeaders.length"
class="text-center"
>
{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelected') }}
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>{{ t('harvester.addons.vmMigration.reviewMigration.noVmSelectedLink') }}</a>
</td>
</tr>
</template>
</SortableTable>
</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 --> <!-- Error banner -->
<Banner <Banner
v-for="(err, i) in errors" v-for="(err, i) in errors"
@@ -390,89 +548,121 @@ defineExpose({ startMigration: startMigrationAction });
} }
.name-row { .name-row {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 12px;
.namespace-select {
max-width: 280px;
flex-shrink: 0;
}
.name-input {
flex: 1;
}
}
.details-grid {
display: grid; display: grid;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr; grid-template-columns: 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr); gap: 12px clamp(12px, 1vw, 96px);
gap: 12px clamp(64px, 8vw, 128px); align-items: start;
line-height: 20px;
} }
.detail-column { .summary-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
line-height: 20px;
margin-top: 12px;
}
.section-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 4px;
&.grid-column-2 { .section-description {
grid-column: span 2; font-size: 14px;
} }
} }
.detail-item { .migration-mode-column {
display: flex; display: flex;
flex-direction: row; flex-direction: column;
justify-content: space-between; align-items: flex-start;
gap: 8px;
font-size: 14px;
.detail-label { .cold-migration-badge {
font-size: 14px; padding: 2px 10px;
color: var(--muted); border-radius: 4px;
} opacity: 0.8;
font-weight: 600;
.detail-value { background: rgba(224, 191, 90, 0.16);
color: #E0BF5A;
font-size: 12px;
line-height: 18px;
font-weight: 600; font-weight: 600;
font-size: 14px;
&.detail-value-large { .theme-light & {
font-size: 24px; background: rgba(168, 128, 26, 0.16);
} }
} }
} }
.vm-cards-grid { .stats-column {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); grid-template-columns: auto auto;
gap: 16px; justify-content: start;
align-content: start;
column-gap: clamp(24px, 4vw, 64px);
row-gap: 8px;
} }
.vm-card-content { .detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
}
.edit-selection-link {
cursor: pointer;
color: var(--link);
font-size: 14px;
}
.vm-sortable-table {
:deep(.fixed-header-actions) {
align-items: end;
}
:deep(table) {
table-layout: fixed;
}
:deep(td) {
vertical-align: middle;
overflow: hidden;
}
:deep(th:first-child),
:deep(td:first-child) {
padding-left: 16px;
}
}
.vm-name-cell {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 16px;
line-height: 20px; .vm-name {
font-weight: 600;
}
.vm-id {
font-size: 12px;
}
} }
.vm-card-specs { .mapping-cell {
display: flex; .mapping-target {
flex-direction: row; margin-left: 4px;
gap: 16px; white-space: nowrap;
align-items: center;
.vm-os {
font-size: 14px;
} }
}
.vm-resources { .mapping-cell + .mapping-cell {
font-size: 14px; margin-top: 8px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
} }
.banner-title { .banner-title {
+26 -2
View File
@@ -1898,6 +1898,9 @@ harvester:
urlLabel: vCenter/ESXi URL urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk" urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https:// urlHint: Enter the full URL including https://
vddkImageLabel: VMware VDDK Image
vddkImagePlaceholder: "[your-private-registry]/vddk-image:latest"
vddkImageTooltip: Specify the VMware VDDK (Virtual Disk Development Kit) Image to accelerate and optimize VMware virtual machine migration.
skipSsl: Skip SSL certificate verification skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments skipSslHint: Not recommended for production environments
testConnection: testConnection:
@@ -1984,17 +1987,38 @@ harvester:
description: Confirm your migration settings before starting the transfer of VMs to the target cluster. description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name planName: Name
planNamePlaceholder: "e.g. my-migration-plan" planNamePlaceholder: "e.g. my-migration-plan"
planNameProviderConflict: 'The name “{name}” is already used by an existing provider. Please input a different plan name.'
planNamePlanConflict: 'The name “{name}” is already used by an existing migration plan. Please input a different plan name.'
migrationDetails: Migration Details migrationDetails: Migration Details
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
totalVms: Total VMs totalVms: Total VMs
vcpu: vCPU vcpu: vCPU
memory: Memory memory: Memory
storage: Storage storage: Storage
source: Source source: Source
provider: Provider
targetNamespace: Namespace targetNamespace: Namespace
targetNamespacePlaceholder: "Select target namespace" targetNamespacePlaceholder: "Select target namespace"
migrationMode: Migration Mode migrationMode: Migration Mode
coldMigration: Cold Migration coldMigration: Cold Migration
coldMigrationDescription: All source VMs will be powered off during migration.
coldMigrationHint: Plan a maintenance window and notify relevant stakeholders before starting.
virtualMachines: Virtual Machines virtualMachines: Virtual Machines
editSelection: Edit selection
noVmSelected: "No VMs selected. Return to "
noVmSelectedLink: "Step 2 to select virtual machines."
columns:
vmName: VM Name
os: Operating System
resources: Resources
powerState: Power State
networkMapping: Network Mapping
storageMapping: Storage Mapping
sourceTarget: "Source \u2192 Target"
actions:
menuLabel: Actions
editMappings: Edit Mappings
removeFromPlan: Remove from Plan
warningTitle: "Cold Migration Warning" 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" 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 startMigration: Start Migration Plan
@@ -2424,8 +2448,8 @@ advancedSettings:
typeLabel: typeLabel:
kubevirt.io.virtualmachine: |- kubevirt.io.virtualmachine: |-
{count, plural, {count, plural,
one { Virtual Machine } one { Andy Virtual Machine }
other { Virtual Machines } other { Andy Virtual Machines }
} }
harvesterhci.io.virtualmachineimage: |- harvesterhci.io.virtualmachineimage: |-
{count, plural, {count, plural,
@@ -46,6 +46,7 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -193,6 +194,7 @@ const init = async() => {
stepData.provider.selectedProvider = fetchedProvider.metadata.name; stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name; stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || ''; stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
stepData.provider.createdProvider = fetchedProvider; stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret; const secretRef = fetchedProvider.spec?.secret;
@@ -41,6 +41,7 @@ const stepData = reactive({
username: '', username: '',
password: '', password: '',
skipTlsVerify: false, skipTlsVerify: false,
vddkInitImage: '',
testPassed: false, testPassed: false,
testResult: null, testResult: null,
testError: null, testError: null,
@@ -173,6 +174,24 @@ const onReviewReady = (ready) => {
reviewReady.value = ready; reviewReady.value = ready;
}; };
const onEditSelection = () => {
wizardComponent.value?.goToStep(2);
};
const onEditMappings = () => {
wizardComponent.value?.goToStep(3);
};
const onRemoveVm = (id) => {
selectedVMs.value = selectedVMs.value.filter((vm) => vm.id !== id);
stepData.vms.selectedVMIds.delete(id);
vmsReady.value = selectedVMs.value.length > 0;
if (!vmsReady.value) {
reviewReady.value = false;
}
};
// Clear downstream data when provider changes // Clear downstream data when provider changes
watch(() => stepData.provider.providerName, (newVal, oldVal) => { watch(() => stepData.provider.providerName, (newVal, oldVal) => {
if (oldVal && newVal !== oldVal) { if (oldVal && newVal !== oldVal) {
@@ -191,13 +210,13 @@ watch(() => stepData.provider.providerName, (newVal, oldVal) => {
} }
}); });
// Clear mappings and review when VM selection changes // Clear mappings and review when VMs are added to the selection. Pure removals
// (e.g. "Remove from Plan" on the review step) keep the existing mappings and plan name.
watch(selectedVMs, (newVal, oldVal) => { watch(selectedVMs, (newVal, oldVal) => {
const newIds = new Set(newVal.map((v) => v.id));
const oldIds = new Set(oldVal.map((v) => v.id)); const oldIds = new Set(oldVal.map((v) => v.id));
const changed = newIds.size !== oldIds.size || [...newIds].some((id) => !oldIds.has(id)); const added = newVal.some((v) => !oldIds.has(v.id));
if (oldVal.length > 0 && changed) { if (oldVal.length > 0 && added) {
stepData.mappings.networkEntries = []; stepData.mappings.networkEntries = [];
stepData.mappings.storageEntries = []; stepData.mappings.storageEntries = [];
mappingsReady.value = false; mappingsReady.value = false;
@@ -290,6 +309,9 @@ const onCancel = () => {
:mapping-entries="stepData.mappings" :mapping-entries="stepData.mappings"
:step-data="stepData.review" :step-data="stepData.review"
@ready="onReviewReady" @ready="onReviewReady"
@edit-selection="onEditSelection"
@edit-mappings="onEditMappings"
@remove-vm="onRemoveVm"
/> />
</template> </template>
</CruResource> </CruResource>