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'
types:
- merged
- labeled
jobs:
build-validation:
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
build:
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
needs:
- build-validation
@@ -28,4 +38,4 @@ jobs:
packages: write
id-token: write
with:
CI_BRANCH: ${{github.ref_name}}
CI_BRANCH: ${{ github.event_name == 'push' && github.ref_name || '' }}
@@ -42,6 +42,7 @@ const {
username,
password,
skipTlsVerify,
vddkInitImage,
testPassed,
testResult,
testError,
@@ -97,6 +98,7 @@ watch(selectedProvider, (val) => {
username.value = '';
password.value = '';
skipTlsVerify.value = false;
vddkInitImage.value = '';
createdProvider.value = null;
createdSecret.value = null;
} else {
@@ -107,6 +109,7 @@ watch(selectedProvider, (val) => {
if (provider) {
providerName.value = provider.metadata.name;
url.value = provider.spec?.url || '';
vddkInitImage.value = provider.spec?.settings?.vddkInitImage || '';
const secretRef = provider.spec?.secret;
@@ -239,6 +242,18 @@ const testConnection = async(buttonCb) => {
if (props.editMode && createdProvider.value) {
try {
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();
const secretRef = createdProvider.value.spec?.secret;
@@ -283,20 +298,28 @@ const testConnection = async(buttonCb) => {
const namespace = FORKLIFT_NAMESPACE;
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`, {
type: HCI.FORKLIFT_PROVIDER,
metadata: {
name: providerName.value,
namespace,
},
spec: {
type: 'vsphere',
url: url.value,
secret: {
name: secretName,
namespace,
},
}
spec,
});
await provider.save();
@@ -420,17 +443,28 @@ defineExpose({ testConnection, clickTestButton });
/>
</div>
<div>
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-deemphasized mt-5">
{{ 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 class="row">
@@ -2,11 +2,12 @@
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
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 LabeledSelect from '@shell/components/form/LabeledSelect';
import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n';
import { NAMESPACE } from '@shell/config/types';
import { HCI } from '../../types';
@@ -23,17 +24,19 @@ const props = defineProps({
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready']);
const emit = defineEmits(['ready', 'edit-selection', 'edit-mappings', 'remove-vm']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const vms = computed(() => props.selectedVms);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const targetNamespace = ref('');
const namespaceOptions = ref([]);
const existingProviderNames = ref([]);
const existingPlanNames = ref([]);
const errors = ref([]);
const loading = ref(true);
@@ -43,6 +46,24 @@ targetNamespace.value = props.stepData.targetNamespace || '';
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) => {
props.stepData.planName = val;
}, { immediate: true });
@@ -51,8 +72,8 @@ watch(targetNamespace, (val) => {
props.stepData.targetNamespace = val;
});
watch([planName, targetNamespace], ([name, namespace]) => {
emit('ready', !!name && !!namespace);
watch([planName, targetNamespace, nameConflictError], () => {
emit('ready', !!planName.value && !!targetNamespace.value && !nameConflictError.value);
}, { immediate: true });
const formatNetworkTarget = (target = '') => {
@@ -87,7 +108,7 @@ const totalStorageGB = computed(() => {
return bytesToGB(totalBytes);
});
const vmCards = computed(() => {
const vmRows = computed(() => {
return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 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 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 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 {
_key: vm.id || vm.vmId || vm.metadata?.name,
id: vm.id,
name: vm.name || vm.id,
identifier: vm.id || vm.vmId || vm.metadata?.name || '-',
os,
cpus,
memGB,
diskDisplay,
resources: t('harvester.addons.vmMigration.generic.vCpu', { count: cpus }),
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass,
networkMappings: vmNetworkMappings,
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 inStore = store.getters['currentProduct'].inStore;
@@ -201,8 +292,6 @@ const startMigrationAction = async() => {
};
const init = async() => {
vms.value = props.selectedVms;
const inStore = store.getters['currentProduct'].inStore;
try {
@@ -213,6 +302,22 @@ const init = async() => {
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) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id,
@@ -244,12 +349,31 @@ defineExpose({ startMigration: startMigrationAction });
<div
class="review-migration-content"
>
<Banner
v-if="nameConflictError"
color="error"
:label="nameConflictError"
/>
<!-- Migration Details Summary -->
<div class="migration-details">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<div class="section-header">
<h3 class="section-title m-0">
{{ 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">
<LabeledInput
v-model:value="planName"
class="name-input"
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
required
/>
<LabeledSelect
v-model:value="targetNamespace"
class="namespace-select"
@@ -258,111 +382,145 @@ defineExpose({ startMigration: startMigrationAction });
:options="namespaceOptions"
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 class="details-grid span-9">
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value detail-value-large">{{ vms.length }}</span>
</div>
<div class="summary-row">
<div class="migration-mode-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
<span class="cold-migration-badge">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationDescription') }}</span>
<span class="text-deemphasized">{{ t('harvester.addons.vmMigration.reviewMigration.coldMigrationHint') }}</span>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
</div>
<div class="detail-item">
<span class="detail-value">{{ providerName }}</span>
</div>
</div>
<div class="detail-column">
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
</div>
<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 class="stats-column">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.provider') }}</span>
<span class="detail-value">{{ providerName }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
<span class="detail-value">{{ vms.length }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
<span class="detail-value">{{ totalVCpu }}</span>
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
<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>
<!-- Virtual Machines -->
<div class="vm-section">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h3>
<div class="vm-cards-grid">
<RcItemCard
v-for="vm in vmCards"
:id="vm.id"
:key="vm.id"
:variant="'small'"
: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"
aria-hidden="true"
/>
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}
</span>
</div>
<MappingsCell
:network-entries="vm.networkMappings.map(m => `${m.source} → ${formatNetworkTarget(m.target)}`)"
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
/>
<SortableTable
class="vm-sortable-table"
:rows="vmRows"
:headers="vmHeaders"
:search="false"
:table-actions="false"
:row-actions="false"
:groupable="false"
:paging="true"
key-field="_key"
>
<template #header-left>
<h4 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
</h4>
</template>
<template #header-right>
<a
href="#"
class="edit-selection-link"
@click.prevent="onEditSelection"
>
{{ t('harvester.addons.vmMigration.reviewMigration.editSelection') }}
</a>
</template>
<template #cell:vmName="{ row }">
<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>
</template>
</RcItemCard>
</div>
<span
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>
<!-- 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 -->
<Banner
v-for="(err, i) in errors"
@@ -390,89 +548,121 @@ defineExpose({ startMigration: startMigrationAction });
}
.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;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px clamp(64px, 8vw, 128px);
line-height: 20px;
grid-template-columns: 1fr 1fr;
gap: 12px clamp(12px, 1vw, 96px);
align-items: start;
}
.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;
flex-direction: column;
gap: 4px;
&.grid-column-2 {
grid-column: span 2;
.section-description {
font-size: 14px;
}
}
.detail-item {
.migration-mode-column {
display: flex;
flex-direction: row;
justify-content: space-between;
flex-direction: column;
align-items: flex-start;
gap: 8px;
font-size: 14px;
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
.cold-migration-badge {
padding: 2px 10px;
border-radius: 4px;
opacity: 0.8;
font-weight: 600;
background: rgba(224, 191, 90, 0.16);
color: #E0BF5A;
font-size: 12px;
line-height: 18px;
font-weight: 600;
font-size: 14px;
&.detail-value-large {
font-size: 24px;
.theme-light & {
background: rgba(168, 128, 26, 0.16);
}
}
}
.vm-cards-grid {
.stats-column {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
grid-template-columns: auto auto;
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;
flex-direction: column;
gap: 16px;
line-height: 20px;
.vm-name {
font-weight: 600;
}
.vm-id {
font-size: 12px;
}
}
.vm-card-specs {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
.vm-os {
font-size: 14px;
.mapping-cell {
.mapping-target {
margin-left: 4px;
white-space: nowrap;
}
}
.vm-resources {
font-size: 14px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
.mapping-cell + .mapping-cell {
margin-top: 8px;
}
.banner-title {
+26 -2
View File
@@ -1898,6 +1898,9 @@ harvester:
urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk"
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
skipSslHint: Not recommended for production environments
testConnection:
@@ -1984,17 +1987,38 @@ harvester:
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
planName: Name
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
migrationDetailsDescription: Name this plan and choose the namespace where migrated VMs will be created.
totalVms: Total VMs
vcpu: vCPU
memory: Memory
storage: Storage
source: Source
provider: Provider
targetNamespace: Namespace
targetNamespacePlaceholder: "Select target namespace"
migrationMode: Migration Mode
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
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"
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
@@ -2424,8 +2448,8 @@ advancedSettings:
typeLabel:
kubevirt.io.virtualmachine: |-
{count, plural,
one { Virtual Machine }
other { Virtual Machines }
one { Andy Virtual Machine }
other { Andy Virtual Machines }
}
harvesterhci.io.virtualmachineimage: |-
{count, plural,
@@ -46,6 +46,7 @@ const stepData = reactive({
username: '',
password: '',
skipTlsVerify: false,
vddkInitImage: '',
testPassed: false,
testResult: null,
testError: null,
@@ -193,6 +194,7 @@ const init = async() => {
stepData.provider.selectedProvider = fetchedProvider.metadata.name;
stepData.provider.providerName = fetchedProvider.metadata.name;
stepData.provider.url = fetchedProvider.spec?.url || '';
stepData.provider.vddkInitImage = fetchedProvider.spec?.settings?.vddkInitImage || '';
stepData.provider.createdProvider = fetchedProvider;
const secretRef = fetchedProvider.spec?.secret;
@@ -41,6 +41,7 @@ const stepData = reactive({
username: '',
password: '',
skipTlsVerify: false,
vddkInitImage: '',
testPassed: false,
testResult: null,
testError: null,
@@ -173,6 +174,24 @@ const onReviewReady = (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
watch(() => stepData.provider.providerName, (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) => {
const newIds = new Set(newVal.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.storageEntries = [];
mappingsReady.value = false;
@@ -290,6 +309,9 @@ const onCancel = () => {
:mapping-entries="stepData.mappings"
:step-data="stepData.review"
@ready="onReviewReady"
@edit-selection="onEditSelection"
@edit-mappings="onEditMappings"
@remove-vm="onRemoveVm"
/>
</template>
</CruResource>