feat(forklift): Changed to Wizard

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto
2026-07-07 13:37:10 +02:00
parent 7c8f172012
commit e703f34879
12 changed files with 1491 additions and 1309 deletions
@@ -0,0 +1,712 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const NO_TEMPLATE = '__none__';
const vms = ref([]);
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
const networkEntries = ref([]);
const storageEntries = ref([]);
const allNetworkMaps = ref([]);
const allStorageMaps = ref([]);
const selectedNetworkTemplate = ref(NO_TEMPLATE);
const selectedStorageTemplate = ref(NO_TEMPLATE);
const loading = ref(true);
// Restore from stepData
if (props.stepData.networkEntries.length > 0) {
networkEntries.value = props.stepData.networkEntries;
}
if (props.stepData.storageEntries.length > 0) {
storageEntries.value = props.stepData.storageEntries;
}
selectedNetworkTemplate.value = props.stepData.selectedNetworkTemplate;
selectedStorageTemplate.value = props.stepData.selectedStorageTemplate;
const NAMESPACE = 'forklift';
const harvesterNetworkOptions = computed(() => {
const options = [
{ label: 'Pod Networking', value: 'pod' },
{ label: 'Ignored', value: 'ignored' },
];
harvesterNetworks.value.forEach((net) => {
const ns = net.metadata?.namespace || '';
const name = net.metadata?.name || net.id;
const fullName = ns ? `${ ns }/${ name }` : name;
options.push({ label: fullName, value: fullName });
});
return options;
});
const storageClassOptions = computed(() => {
const options = storageClasses.value.map((sc) => ({
label: sc.metadata?.name || sc.id,
value: sc.metadata?.name || sc.id,
}));
if (!options.find((o) => o.value === 'harvester-longhorn')) {
options.unshift({ label: 'harvester-longhorn', value: 'harvester-longhorn' });
}
return options;
});
const networkTemplateOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
];
const currentIds = new Set(networkEntries.value.map((e) => e.id).filter(Boolean));
const sorted = [...allNetworkMaps.value].sort(
(a, b) => new Date(b.metadata.creationTimestamp) - new Date(a.metadata.creationTimestamp)
);
sorted.forEach((nm) => {
const mapSources = (nm.spec?.map || []).map((m) => m.source?.id).filter(Boolean);
const hasOverlap = mapSources.some((id) => currentIds.has(id));
if (hasOverlap) {
options.push({
label: nm.metadata.name,
value: nm.metadata.name,
});
}
});
return options;
});
const storageTemplateOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureMappings.noTemplate'), value: NO_TEMPLATE }
];
const currentIds = new Set(storageEntries.value.map((e) => e.id).filter(Boolean));
const sorted = [...allStorageMaps.value].sort(
(a, b) => new Date(b.metadata.creationTimestamp) - new Date(a.metadata.creationTimestamp)
);
sorted.forEach((sm) => {
const mapSources = (sm.spec?.map || []).map((m) => m.source?.id).filter(Boolean);
const hasOverlap = mapSources.some((id) => currentIds.has(id));
if (hasOverlap) {
options.push({
label: sm.metadata.name,
value: sm.metadata.name,
});
}
});
return options;
});
watch(selectedNetworkTemplate, (val) => {
if (val === NO_TEMPLATE) {
networkEntries.value.forEach((e) => {
e.target = '';
});
return;
}
const template = allNetworkMaps.value.find((nm) => nm.metadata.name === val);
if (!template?.spec?.map) {
return;
}
networkEntries.value.forEach((entry) => {
const match = template.spec.map.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination) {
const dest = match.destination;
if (dest.type === 'pod') {
entry.target = 'pod';
} else if (dest.type === 'ignored') {
entry.target = 'ignored';
} else if (dest.type === 'multus' && dest.name) {
entry.target = dest.namespace ? `${ dest.namespace }/${ dest.name }` : dest.name;
}
}
});
});
watch(selectedStorageTemplate, (val) => {
if (val === NO_TEMPLATE) {
storageEntries.value.forEach((e) => {
e.target = '';
});
return;
}
const template = allStorageMaps.value.find((sm) => sm.metadata.name === val);
if (!template?.spec?.map) {
return;
}
storageEntries.value.forEach((entry) => {
const match = template.spec.map.find(
(m) => m.source?.id === entry.id || m.source?.name === entry.name
);
if (match?.destination?.storageClass) {
entry.target = match.destination.storageClass;
}
});
});
const allNetworksMapped = computed(() => networkEntries.value.length > 0 && networkEntries.value.every((e) => !!e.target));
const allStorageMapped = computed(() => storageEntries.value.length > 0 && storageEntries.value.every((e) => !!e.target));
const canSave = computed(() => {
return networkEntries.value.length > 0 && storageEntries.value.length > 0 &&
allNetworksMapped.value && allStorageMapped.value;
});
watch(canSave, (val) => {
emit('ready', val);
});
// After restore, check if ready
if (canSave.value) {
emit('ready', true);
}
// Sync state back to stepData
watch(networkEntries, (val) => {
props.stepData.networkEntries = val;
}, { deep: true });
watch(storageEntries, (val) => {
props.stepData.storageEntries = val;
}, { deep: true });
watch(selectedNetworkTemplate, (val) => {
props.stepData.selectedNetworkTemplate = val;
});
watch(selectedStorageTemplate, (val) => {
props.stepData.selectedStorageTemplate = val;
});
const buildNetworkEntries = () => {
const networkMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.networks && vm.networks.length > 0) {
vm.networks.forEach((net) => {
const netKey = net.id || net.name || 'default';
if (!networkMap[netKey]) {
networkMap[netKey] = {
name: net.name || 'Unknown',
id: net.id || '',
vlanId: net.vlanId || '',
target: '',
usedBy: [],
_key: `net-${ netKey }`,
};
}
if (!networkMap[netKey].usedBy.includes(vmName)) {
networkMap[netKey].usedBy.push(vmName);
}
});
}
});
networkEntries.value = Object.values(networkMap);
};
const buildStorageEntries = () => {
const datastoreMap = {};
vms.value.forEach((vm) => {
const vmName = vm.name || vm.id;
if (vm.disks && vm.disks.length > 0) {
vm.disks.forEach((disk) => {
const ds = disk.datastore;
if (ds && ds.id) {
if (!datastoreMap[ds.id]) {
datastoreMap[ds.id] = {
name: ds.name || 'Unknown',
id: ds.id,
type: ds.type || '',
capacity: 0,
target: '',
usedBy: [],
_key: `stor-${ ds.id }`,
};
}
datastoreMap[ds.id].capacity += disk.capacity || 0;
if (!datastoreMap[ds.id].usedBy.includes(vmName)) {
datastoreMap[ds.id].usedBy.push(vmName);
}
}
});
}
});
storageEntries.value = Object.values(datastoreMap);
};
const formatStorageDetail = (entry) => {
const parts = [];
if (entry.type) {
parts.push(entry.type);
}
if (entry.capacity) {
const gb = entry.capacity / (1024 * 1024 * 1024);
if (gb >= 1024) {
parts.push(`${ (gb / 1024).toFixed(1) } TB`);
} else {
parts.push(`${ Math.round(gb) } GB`);
}
}
return parts.join(' • ');
};
const saveAndReturn = async() => {
const inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
// Create NetworkMap
const networkMapSpec = {
map: networkEntries.value.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,
},
};
}),
provider: providerRef,
};
const providerOwnerRef = props.provider ? [{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.provider.metadata.name,
uid: props.provider.metadata.uid,
blockOwnerDeletion: true,
}] : [];
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: {
name: `${ props.providerName }-network-map-${ Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: networkMapSpec,
});
await networkMap.save();
// Create StorageMap
const storageMapSpec = {
map: storageEntries.value.map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
})),
provider: providerRef,
};
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: {
name: `${ props.providerName }-storage-map-${ Math.random().toString(36).substring(2, 7) }`,
namespace: NAMESPACE,
ownerReferences: providerOwnerRef,
},
spec: storageMapSpec,
});
await storageMap.save();
return { networkMapName: networkMap.metadata.name, storageMapName: storageMap.metadata.name };
};
defineExpose({ saveMappings: saveAndReturn });
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
try {
harvesterNetworks.value = await store.dispatch(`${ inStore }/findAll`, { type: NETWORK_ATTACHMENT });
} catch (e) {
harvesterNetworks.value = [];
}
try {
storageClasses.value = await store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
} catch (e) {
storageClasses.value = [];
}
try {
allNetworkMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
} catch (e) {
allNetworkMaps.value = [];
}
try {
allStorageMaps.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
} catch (e) {
allStorageMaps.value = [];
}
vms.value = props.selectedVms;
if (networkEntries.value.length === 0) {
try {
const providerUid = props.provider?.metadata?.uid;
const providerType = props.provider?.spec?.type || 'vsphere';
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
const [networksData, datastoresData] = await Promise.all([
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
fetch(`${ baseUrl }/datastores?detail=1`).then((r) => r.json()).catch(() => []),
]);
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
const datastoreInfoMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
map[d.id] = { name: d.name, type: d.type || '' };
return map;
}, {});
vms.value = vms.value.map((vm) => {
const resolved = { ...vm };
if (resolved.networks) {
resolved.networks = resolved.networks.map((n) => ({
...n,
name: n.name || networkNameMap[n.id] || n.id,
}));
}
if (resolved.disks) {
resolved.disks = resolved.disks.map((d) => {
const dsInfo = d.datastore ? datastoreInfoMap[d.datastore.id] : null;
return {
...d,
datastore: d.datastore ? {
...d.datastore,
name: d.datastore.name || dsInfo?.name || d.datastore.id,
type: dsInfo?.type || '',
} : d.datastore,
};
});
}
return resolved;
});
} catch (e) {
// Name resolution failed — entries will use IDs as fallback
}
buildNetworkEntries();
}
if (storageEntries.value.length === 0) {
buildStorageEntries();
}
loading.value = false;
};
init();
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="configure-mappings"
>
<p class="text-muted line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>
<div class="mappings-columns">
<!-- Network Mapping -->
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.title') }}
</h3>
<p class="text-muted line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.description') }}
</p>
</div>
<LabeledSelect
v-model:value="selectedNetworkTemplate"
:label="t('harvester.addons.vmMigration.configureMappings.networkMapping.template')"
:options="networkTemplateOptions"
:reduce="(opt) => opt.value"
class="mb-10"
/>
<RcItemCard
v-for="entry in networkEntries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
class="bg-light-gray"
>
<template #item-card-content>
<div class="card-content-column">
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<span class="source-detail text-muted">VLAN {{ entry.vlanId || '0' }}</span>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-muted']">
<i class="icon icon-right-arrow-alt" />
</div>
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="harvesterNetworkOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
:searchable="true"
/>
</div>
</div>
<div>
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
<!-- Storage Mapping -->
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.title') }}
</h3>
<p class="text-muted">
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.description') }}
</p>
</div>
<LabeledSelect
v-model:value="selectedStorageTemplate"
:label="t('harvester.addons.vmMigration.configureMappings.storageMapping.template')"
:options="storageTemplateOptions"
:reduce="(opt) => opt.value"
class="mb-10"
/>
<RcItemCard
v-for="entry in storageEntries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
class="bg-light-gray"
>
<template #item-card-content>
<div class="card-content-column">
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<span
v-if="entry.type || entry.capacity"
class="source-detail text-muted"
>{{ formatStorageDetail(entry) }}</span>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-muted']">
<i class="icon icon-right-arrow-alt" />
</div>
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="storageClassOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:searchable="true"
/>
</div>
</div>
<div>
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-columns {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 64px;
}
.mapping-column {
display: flex;
gap: 12px;
flex-direction: column;
:deep(.item-card-header) {
display: none;
}
}
.mapping-section-title {
font-weight: 600;
margin: 0 0 5px 0;
line-height: 28px;
}
.card-content-row {
display: flex;
align-items: center;
gap: 40px;
width: 100%;
}
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source {
display: flex;
flex-direction: column;
min-width: 160px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 16px;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
}
}
.mapping-arrow {
font-size: 22px;
flex-shrink: 0;
}
.mapping-target {
flex: 1;
overflow: hidden;
}
.line-height-20 {
line-height: 20px;
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
.configure-mappings {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
}
</style>
@@ -0,0 +1,590 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import { LabeledInput } from '@components/Form/LabeledInput';
import { Checkbox } from '@components/Form/Checkbox';
import { Banner } from '@components/Banner';
import AsyncButton from '@shell/components/AsyncButton';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
const CREATE_NEW = '__create_new__';
const props = defineProps({ stepData: { type: Object, required: true } });
const emit = defineEmits(['complete', 'ready', 'form-valid', 'testing']);
const store = useStore();
const { t } = useI18n(store);
const allProviders = ref([]);
const allSecrets = ref([]);
const selectedProvider = ref(CREATE_NEW);
const providerName = ref('');
const url = ref('');
const username = ref('');
const password = ref('');
const skipTlsVerify = ref(false);
const testResult = ref(null);
const testError = ref(null);
const errors = ref([]);
const testBtnRef = ref(null);
const createdProvider = ref(null);
const createdSecret = ref(null);
const loading = ref(true);
const testPassed = ref(false);
const testing = ref(false);
// Restore state from stepData on mount
selectedProvider.value = props.stepData.selectedProvider;
providerName.value = props.stepData.providerName;
url.value = props.stepData.url;
username.value = props.stepData.username;
password.value = props.stepData.password;
skipTlsVerify.value = props.stepData.skipTlsVerify;
testPassed.value = props.stepData.testPassed;
testResult.value = props.stepData.testResult;
testError.value = props.stepData.testError;
createdProvider.value = props.stepData.createdProvider;
createdSecret.value = props.stepData.createdSecret;
// Sync state back to stepData
watch(selectedProvider, (val) => {
props.stepData.selectedProvider = val;
});
watch(providerName, (val) => {
props.stepData.providerName = val;
});
watch(url, (val) => {
props.stepData.url = val;
});
watch(username, (val) => {
props.stepData.username = val;
});
watch(password, (val) => {
props.stepData.password = val;
});
watch(skipTlsVerify, (val) => {
props.stepData.skipTlsVerify = val;
});
watch(testPassed, (val) => {
props.stepData.testPassed = val;
});
watch(testResult, (val) => {
props.stepData.testResult = val;
});
watch(testError, (val) => {
props.stepData.testError = val;
});
watch(createdProvider, (val) => {
props.stepData.createdProvider = val;
});
watch(createdSecret, (val) => {
props.stepData.createdSecret = val;
});
const isExistingProvider = computed(() => selectedProvider.value !== CREATE_NEW);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const providerOptions = computed(() => {
const options = [
{ label: t('harvester.addons.vmMigration.configureProvider.createNew'), value: CREATE_NEW }
];
allProviders.value.forEach((p) => {
if (!p.spec?.url) {
return;
}
const secretRef = p.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (!secret?.data?.user || !secret?.data?.password) {
return;
}
} else {
return;
}
options.push({
label: p.metadata.name,
value: p.metadata.name,
});
});
return options;
});
// When the provider selection changes, populate or clear the fields
watch(selectedProvider, (val) => {
testPassed.value = false;
testResult.value = null;
testError.value = null;
if (val === CREATE_NEW) {
providerName.value = '';
url.value = '';
username.value = '';
password.value = '';
skipTlsVerify.value = false;
createdProvider.value = null;
createdSecret.value = null;
} else {
const provider = allProviders.value.find(
(p) => p.metadata.name === val && p.metadata.namespace === 'forklift'
);
if (provider) {
providerName.value = provider.metadata.name;
url.value = provider.spec?.url || '';
const secretRef = provider.spec?.secret;
if (secretRef) {
const secret = allSecrets.value.find(
(s) => s.metadata.name === secretRef.name && s.metadata.namespace === secretRef.namespace
);
if (secret?.data) {
username.value = atob(secret.data.user || '');
password.value = atob(secret.data.password || '');
skipTlsVerify.value = atob(secret.data.insecureSkipVerify || '') === 'true';
}
}
createdProvider.value = provider;
}
}
});
// Reset test state when any field changes (only for create new)
watch([providerName, url, username, password], () => {
if (!isExistingProvider.value) {
testPassed.value = false;
testResult.value = null;
}
});
// Emit ready/complete when testPassed changes
watch(testPassed, (val) => {
emit('ready', val);
if (val) {
emit('complete', { providerName: providerName.value, provider: createdProvider.value });
}
}, { immediate: true });
// Emit form-valid when form validity changes
watch(isFormValid, (val) => {
emit('form-valid', val);
}, { immediate: true });
watch(testing, (val) => {
emit('testing', val);
}, { immediate: true });
const testConnection = async(buttonCb) => {
testResult.value = null;
testError.value = null;
testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.vmMigration.configureProvider.testMissingFields');
testing.value = false;
buttonCb(false);
return;
}
const inStore = store.getters['currentProduct'].inStore;
// For existing providers, just poll for Ready/ConnectionTestSucceeded status
if (isExistingProvider.value) {
try {
const namespace = 'forklift';
const maxAttempts = 15;
let attempts = 0;
let connected = false;
let errorMsg = '';
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempts++;
const refreshed = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: `${ namespace }/${ providerName.value }`,
opt: { force: true }
});
const conditions = refreshed?.status?.conditions || [];
const readyCondition = conditions.find((c) => c.type === 'Ready');
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
if (connectionCondition) {
if (connectionCondition.status === 'True') {
connected = true;
break;
} else {
errorMsg = connectionCondition.message || 'Connection failed';
break;
}
}
if (readyCondition) {
if (readyCondition.status === 'True') {
connected = true;
break;
} else if (readyCondition.status === 'False') {
errorMsg = readyCondition.message || 'Provider not ready';
break;
}
}
}
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
} catch (err) {
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
return;
}
// For new providers, create provider + secret then poll
try {
// Delete previous provider (cascades to secret via ownerReferences)
if (createdProvider.value) {
await createdProvider.value.remove();
createdProvider.value = null;
createdSecret.value = null;
} else if (createdSecret.value) {
await createdSecret.value.remove();
createdSecret.value = null;
}
const namespace = 'forklift';
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
// Create Provider first so we have its UID for the ownerReference on the Secret
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,
},
}
});
await provider.save();
createdProvider.value = provider;
// Create Secret with ownerReference already set (avoids an extra PUT)
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: { 'ui.forklift/created-for-resource-type': 'forklift.konveyor.io.provider' },
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: provider.metadata.name,
uid: provider.metadata.uid,
blockOwnerDeletion: true,
},
],
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
user: btoa(username.value),
password: btoa(password.value),
insecureSkipVerify: btoa(String(skipTlsVerify.value)),
url: btoa(url.value),
};
await newSecret.save();
createdSecret.value = newSecret;
const maxAttempts = 15;
let attempts = 0;
let connected = false;
let errorMsg = '';
while (attempts < maxAttempts) {
await new Promise((resolve) => setTimeout(resolve, 2000));
attempts++;
const refreshed = await store.dispatch(`${ inStore }/find`, {
type: HCI.FORKLIFT_PROVIDER,
id: `${ namespace }/${ providerName.value }`,
opt: { force: true }
});
const conditions = refreshed?.status?.conditions || [];
const readyCondition = conditions.find((c) => c.type === 'Ready');
const connectionCondition = conditions.find((c) => c.type === 'ConnectionTestSucceeded');
if (connectionCondition) {
if (connectionCondition.status === 'True') {
connected = true;
break;
} else {
errorMsg = connectionCondition.message || 'Connection failed';
break;
}
}
if (readyCondition) {
if (readyCondition.status === 'True') {
connected = true;
break;
} else if (readyCondition.status === 'False') {
errorMsg = readyCondition.message || 'Provider not ready';
break;
}
}
}
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.vmMigration.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
if (createdProvider.value) {
await createdProvider.value.remove();
createdProvider.value = null;
createdSecret.value = null;
} else if (createdSecret.value) {
await createdSecret.value.remove();
createdSecret.value = null;
}
testError.value = errorMsg || t('harvester.addons.vmMigration.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
} catch (err) {
if (createdProvider.value) {
try {
await createdProvider.value.remove();
} catch (e) {}
createdProvider.value = null;
}
if (createdSecret.value) {
try {
await createdSecret.value.remove();
} catch (e) {}
createdSecret.value = null;
}
testError.value = err.message || t('harvester.addons.vmMigration.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, {
type: SECRET,
opt: { labelSelector: `ui.forklift/created-for-resource-type=${ HCI.FORKLIFT_PROVIDER }` }
});
loading.value = false;
};
init();
const clickTestButton = () => {
testBtnRef.value?.$el?.click();
};
defineExpose({ testConnection, clickTestButton });
</script>
<template>
<div class="configure-provider-step">
<p class="text-muted line-height-20">
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
</p>
<div class="configure-provider-step-content">
<h3 class="table-title m-0">
<b>{{ t('harvester.addons.vmMigration.configureProvider.connectionDetails') }}</b>
</h3>
<Banner
class="requirements-banner m-0"
color="info"
>
<div class="requirements-banner-content">
<span class="requirements-banner-title">{{ t('harvester.addons.vmMigration.configureProvider.requirementsTitle') }}</span>
<br>
<span>{{ t('harvester.addons.vmMigration.configureProvider.requirementsText') }}</span>
</div>
</Banner>
<div class="configure-provider-step-form">
<div>
<LabeledSelect
v-model:value="selectedProvider"
:label="t('harvester.addons.vmMigration.configureProvider.providerSelect')"
:options="providerOptions"
:reduce="(opt) => opt.value"
/>
</div>
<div
v-if="!isExistingProvider"
>
<LabeledInput
v-model:value="providerName"
:label="t('harvester.addons.vmMigration.configureProvider.name')"
required
/>
</div>
<div>
<LabeledInput
v-model:value="url"
:label="t('harvester.addons.vmMigration.configureProvider.urlLabel')"
:placeholder="t('harvester.addons.vmMigration.configureProvider.urlPlaceholder')"
:disabled="isExistingProvider"
required
/>
<p class="text-muted mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="username"
:label="t('harvester.addons.vmMigration.fields.username')"
:disabled="isExistingProvider"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.vmMigration.fields.password')"
:disabled="isExistingProvider"
required
/>
</div>
</div>
<div>
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider"
/>
<p class="text-muted ml-20">
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
</p>
</div>
<div>
<AsyncButton
ref="testBtnRef"
mode="test"
:disabled="!isFormValid"
:action-label="t('harvester.addons.vmMigration.configureProvider.testConnection.action')"
:waiting-label="t('harvester.addons.vmMigration.configureProvider.testConnection.waiting')"
:success-label="t('harvester.addons.vmMigration.configureProvider.testConnection.success')"
:error-label="t('harvester.addons.vmMigration.configureProvider.testConnection.error')"
:action-color="'role-secondary'"
:waiting-color="'role-disabled'"
@click="testConnection"
/>
</div>
<Banner
v-if="testResult"
color="success"
>
{{ testResult }}
</Banner>
<Banner
v-if="testError"
color="error"
>
{{ testError }}
</Banner>
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
>
{{ err }}
</Banner>
</div>
</div>
</div>
</template>
<style lang="scss" scoped>
.requirements-banner {
font-weight: 400;
.requirements-banner-title {
font-weight: 600;
}
}
.configure-provider-step {
display: flex;
flex-direction: column;
gap: 24px;
.line-height-20 {
line-height: 20px;
}
.configure-provider-step-content {
display: flex;
flex-direction: column;
gap: 12px;
.configure-provider-step-form {
display: flex;
flex-direction: column;
gap: 20px;
}
}
}
</style>
@@ -0,0 +1,510 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard';
import { LabeledInput } from '@components/Form/LabeledInput';
import MappingsCell from '../MappingsCell';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
selectedVms: { type: Array, default: () => [] },
networkMapName: { type: String, default: '' },
storageMapName: { type: String, default: '' },
mappingEntries: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['ready']);
const store = useStore();
const { t } = useI18n(store);
const vms = ref([]);
const networkMappings = ref([]);
const storageMappings = ref([]);
const planName = ref('');
const errors = ref([]);
const loading = ref(true);
// Restore persisted state
planName.value = props.stepData.planName;
const NAMESPACE = 'forklift';
const TARGET_NAMESPACE = 'default';
watch(planName, (val) => {
props.stepData.planName = val;
emit('ready', !!val);
}, { immediate: true });
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
const totalMemoryGB = computed(() => {
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
return Math.round(totalMB / 1024);
});
const totalStorageGB = computed(() => {
let totalBytes = 0;
vms.value.forEach((vm) => {
if (vm.disks && vm.disks.length > 0) {
totalBytes += vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
});
return Math.round(totalBytes / (1024 * 1024 * 1024));
});
const vmCards = computed(() => {
return vms.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || 0;
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? `${ Math.round(memMB / 1024) } GB` : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-';
const os = vm.guestName || vm.guestOS || vm.os || '-';
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));
return {
id: vm.id,
name: vm.name || vm.id,
os,
cpus,
memGB,
diskDisplay,
networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings,
};
});
});
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 inStore = store.getters['currentProduct'].inStore;
const providerRef = {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: props.providerName,
namespace: NAMESPACE,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: NAMESPACE,
},
};
const networkMapName = `${ planName.value }-network-map`;
const storageMapName = `${ planName.value }-storage-map`;
const networkMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_NETWORK_MAP,
metadata: { name: networkMapName, namespace: NAMESPACE },
spec: { map: buildNetworkMapSpec(), provider: providerRef },
});
await networkMap.save();
const storageMap = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_STORAGE_MAP,
metadata: { name: storageMapName, namespace: NAMESPACE },
spec: { map: buildStorageMapSpec(), provider: providerRef },
});
await storageMap.save();
const plan = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_PLAN,
metadata: { name: planName.value, namespace: NAMESPACE },
spec: {
provider: providerRef,
map: {
network: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'NetworkMap',
name: networkMapName,
namespace: NAMESPACE,
},
storage: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'StorageMap',
name: storageMapName,
namespace: NAMESPACE,
},
},
targetNamespace: 'default',
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
warm: false,
},
});
await plan.save();
const planOwnerRef = {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: plan.metadata.name,
uid: plan.metadata.uid,
blockOwnerDeletion: true,
};
const migration = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ planName.value }-migration-${ Math.random().toString(36).substring(2, 7) }`,
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];
await networkMap.save();
storageMap.metadata.ownerReferences = [planOwnerRef];
await storageMap.save();
};
const init = () => {
vms.value = props.selectedVms;
if (props.mappingEntries) {
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
source: entry.name || entry.id,
target: entry.target || '',
usedBy: entry.usedBy || [],
}));
}
loading.value = false;
};
init();
defineExpose({ startMigration: startMigrationAction });
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="review-migration"
>
<div
class="review-migration-content"
>
<!-- Migration Details Summary -->
<div class="migration-details">
<h3 class="section-title m-0">
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
</h3>
<div class="span-9">
<LabeledInput
v-model:value="planName"
: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>
<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">{{ TARGET_NAMESPACE }}</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">{{ totalMemoryGB }} GB</span>
</div>
<div class="detail-item">
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
<span class="detail-value">{{ totalStorageGB }} GB</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>
<!-- 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 }, statuses: [{ icon: 'icon-notify-tick', color: 'text-success' }] }"
>
<template #item-card-content>
<div class="vm-card-content">
<div class="vm-card-specs">
<span class="vm-os text-muted">{{ vm.os }}</span>
<span class="vm-resources">
<i class="icon icon-disk" />
{{ vm.cpus }} vCPU &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}
</span>
</div>
<MappingsCell
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
/>
</div>
</template>
</RcItemCard>
</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 -->
<Banner
v-for="(err, i) in errors"
:key="i"
color="error"
:label="err"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
.review-migration {
gap: 36px;
}
.review-migration-content {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-title {
font-weight: 600;
}
.details-grid {
display: grid;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
gap: 12px 128px;
width: 747px;
line-height: 20px;
}
.detail-column {
display: flex;
flex-direction: column;
gap: 4px;
&.grid-column-2 {
grid-column: span 2;
}
}
.detail-item {
display: flex;
flex-direction: row;
justify-content: space-between;
.detail-label {
font-size: 14px;
color: var(--muted);
}
.detail-value {
font-weight: 600;
font-size: 14px;
&.detail-value-large {
font-size: 24px;
}
}
}
.vm-cards-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 16px;
}
.vm-card-content {
display: flex;
flex-direction: column;
gap: 16px;
line-height: 20px;
}
.vm-card-specs {
display: flex;
flex-direction: row;
gap: 16px;
align-items: center;
.vm-os {
font-size: 14px;
}
.vm-resources {
font-size: 14px;
color: var(--muted);
display: inline-flex;
align-items: center;
gap: 8px;
}
}
.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 {
font-weight: 600;
}
.migration-details {
display: flex;
flex-direction: column;
gap: 12px;
}
header {
margin: 0;
}
.vm-section {
display: flex;
flex-direction: column;
gap: 16px;
}
</style>
@@ -0,0 +1,584 @@
<script setup>
import {
ref, computed, watch, nextTick, onBeforeUnmount
} from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import SortableTable from '@shell/components/SortableTable';
import { BadgeState } from '@components/BadgeState';
import { useI18n } from '@shell/composables/useI18n';
const props = defineProps({
providerName: { type: String, default: '' },
provider: { type: Object, default: null },
stepData: { type: Object, required: true },
});
const emit = defineEmits(['complete', 'loading']);
const store = useStore();
const { t } = useI18n(store);
const discoveredVMs = ref([]);
const selectedVMs = ref([]);
const tableRows = ref([]);
const loading = ref(true);
const networkMap = ref({});
const datastoreMap = ref({});
const sortableTableRef = ref(null);
const allVMsSelected = ref(false);
const selectedVMIds = ref(new Set());
let skipNextSelectionEvent = false;
const lastFetchedAt = ref(null);
const now = ref(Date.now());
const formatElapsed = (ms) => {
const totalMinutes = Math.floor(ms / 60000);
const hours = Math.floor(totalMinutes / 60);
const minutes = totalMinutes % 60;
if (hours > 0 && minutes > 0) {
return `${ hours } ${ hours === 1 ? 'hour' : 'hours' } and ${ minutes } ${ minutes === 1 ? 'minute' : 'minutes' }`;
}
if (hours > 0) {
return `${ hours } ${ hours === 1 ? 'hour' : 'hours' }`;
}
return `${ Math.max(1, minutes) } ${ minutes <= 1 ? 'minute' : 'minutes' }`;
};
const lastSyncedTime = computed(() => {
if (!lastFetchedAt.value) {
return '';
}
return formatElapsed(now.value - lastFetchedAt.value);
});
const nowTimer = setInterval(() => {
now.value = Date.now();
}, 30000);
onBeforeUnmount(() => {
clearInterval(nowTimer);
});
// Restore from stepData
if (props.stepData.discoveredVMs.length > 0) {
discoveredVMs.value = props.stepData.discoveredVMs;
}
if (props.stepData.selectedVMIds.size > 0) {
selectedVMIds.value = props.stepData.selectedVMIds;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
}
if (props.stepData.tableRows.length > 0) {
tableRows.value = props.stepData.tableRows;
}
// Sync back to stepData
watch(discoveredVMs, (val) => {
props.stepData.discoveredVMs = val;
}, { deep: true });
watch(selectedVMIds, (val) => {
props.stepData.selectedVMIds = val;
}, { deep: true });
watch(tableRows, (val) => {
props.stepData.tableRows = val;
}, { deep: true });
const vmCount = computed(() => discoveredVMs.value.length);
const selectedCount = computed(() => selectedVMs.value.length);
const showSelectAllBanner = computed(() => {
if (allVMsSelected.value) {
return true;
}
const pagedRows = sortableTableRef.value?.pagedRows || [];
if (pagedRows.length === 0) {
return false;
}
const allPageSelected = pagedRows.every((row) => selectedVMIds.value.has(row._original?.id));
return allPageSelected && tableRows.value.length > pagedRows.length;
});
const theadElement = computed(() => sortableTableRef.value?.$el?.querySelector('thead'));
const headers = [
{
name: 'vmName',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.vmName',
value: 'vmName',
sort: ['vmName'],
subLabel: 'Identifier',
},
{
name: 'os',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.os',
value: 'os',
sort: ['os'],
},
{
name: 'resources',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.resources',
value: 'resources',
sort: false,
},
{
name: 'powerState',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.powerState',
value: 'powerState',
sort: ['powerState'],
},
{
name: 'network',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.network',
value: 'network',
sort: ['network'],
subLabel: 'Identifier',
},
{
name: 'datastore',
labelKey: 'harvester.addons.vmMigration.selectVms.columns.datastore',
value: 'datastore',
sort: ['datastore'],
subLabel: 'Identifier',
},
];
const buildTableRows = () => {
return discoveredVMs.value.map((vm) => {
const cpus = vm.cpuCount || vm.numCPU || '-';
const memMB = vm.memoryMB || vm.memory || 0;
const memGB = memMB ? `${ Math.round(memMB / 1024) } GB` : '-';
let totalDiskBytes = 0;
if (vm.disks && vm.disks.length > 0) {
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
}
const diskDisplay = totalDiskBytes ? `${ Math.round(totalDiskBytes / (1024 * 1024 * 1024)) } GB` : '-';
const rawPowerState = vm.powerState || vm.status?.phase || '-';
const powerState = rawPowerState.replace(/([a-z])([A-Z])/g, '$1 $2').replace(/^./, (c) => c.toUpperCase());
let networks = [];
if (vm.networks && vm.networks.length > 0) {
networks = vm.networks.map((n) => ({
name: n.name || networkMap.value[n.id] || n.id || n,
id: n.id || '',
vlanId: n.vlanId || '',
}));
}
const datastores = [];
if (vm.disks && vm.disks.length > 0) {
const seen = new Set();
vm.disks.forEach((d) => {
const dsId = d.datastore?.id || d.datastore?.name;
if (dsId && !seen.has(dsId)) {
seen.add(dsId);
datastores.push({
name: d.datastore?.name || datastoreMap.value[dsId] || dsId,
id: d.datastore?.id || '',
type: d.datastore?.type || '',
capacity: d.capacity || 0,
});
}
});
}
return {
_original: vm,
_key: vm.id || vm.vmId || vm.metadata?.name,
vmName: vm.name || vm.metadata?.name || '-',
vmId: vm.id || vm.vmId || vm.metadata?.name || '-',
os: vm.guestName || vm.guestOS || vm.os || '-',
resourcesDisplay: `${ cpus } vCPU`,
resourcesSub: `${ memGB } • ${ diskDisplay }`,
powerState,
powerStateClass: powerState.toLowerCase().includes('on') || powerState.toLowerCase().includes('running') ? 'power-on' : 'power-off',
networks,
network: networks.map((n) => n.name).join(', ') || '-',
datastores,
datastore: datastores.map((d) => d.name).join(', ') || '-',
};
});
};
const onSelect = (rows) => {
if (skipNextSelectionEvent) {
return;
}
const currentPageRows = sortableTableRef.value?.pagedRows || [];
const selectedKeys = new Set(rows.map((r) => r._key));
currentPageRows.forEach((row) => {
const vmId = row._original?.id;
if (selectedKeys.has(row._key)) {
selectedVMIds.value.add(vmId);
} else {
selectedVMIds.value.delete(vmId);
}
});
allVMsSelected.value = selectedVMIds.value.size === discoveredVMs.value.length;
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
emit('complete', { selectedVMs: selectedVMs.value });
};
const clearSelection = () => {
allVMsSelected.value = false;
selectedVMIds.value = new Set();
selectedVMs.value = [];
nextTick(() => {
const table = sortableTableRef.value;
if (table) {
table.clearSelection();
}
});
};
const selectAllVMs = () => {
allVMsSelected.value = true;
selectedVMIds.value = new Set(discoveredVMs.value.map((vm) => vm.id));
selectedVMs.value = discoveredVMs.value.slice();
};
watch(
() => sortableTableRef.value?.page,
() => {
skipNextSelectionEvent = true;
nextTick(() => {
const table = sortableTableRef.value;
if (!table) {
return;
}
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
skipNextSelectionEvent = false;
});
}
);
const refreshing = ref(false);
const isLoading = computed(() => loading.value || refreshing.value);
watch(isLoading, (val) => {
emit('loading', val);
}, { immediate: true });
const fetchVMs = async() => {
if (!props.provider) {
return;
}
const providerUid = props.provider.metadata.uid;
const providerType = props.provider.spec?.type || 'vsphere';
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
const [vmsResp, networksResp, datastoresResp] = await Promise.all([
fetch(`${ baseUrl }/vms`).then((r) => r.json()),
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).catch(() => []),
]);
if (Array.isArray(vmsResp)) {
discoveredVMs.value = vmsResp;
} else if (vmsResp?.data && Array.isArray(vmsResp.data)) {
discoveredVMs.value = vmsResp.data;
}
const networks = Array.isArray(networksResp) ? networksResp : (networksResp?.data || []);
const datastores = Array.isArray(datastoresResp) ? datastoresResp : (datastoresResp?.data || []);
networkMap.value = networks.reduce((map, n) => {
map[n.id] = n.name;
return map;
}, {});
datastoreMap.value = datastores.reduce((map, d) => {
map[d.id] = d.name;
return map;
}, {});
lastFetchedAt.value = Date.now();
tableRows.value = buildTableRows();
};
const refreshVMs = async() => {
refreshing.value = true;
skipNextSelectionEvent = true;
const previousSelectedIds = new Set(selectedVMIds.value);
try {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
tableRows.value = [];
}
selectedVMIds.value = new Set(
discoveredVMs.value
.filter((vm) => previousSelectedIds.has(vm.id))
.map((vm) => vm.id)
);
selectedVMs.value = discoveredVMs.value.filter((vm) => selectedVMIds.value.has(vm.id));
allVMsSelected.value = selectedVMIds.value.size > 0 && selectedVMIds.value.size === discoveredVMs.value.length;
props.stepData.discoveredVMs = discoveredVMs.value;
props.stepData.selectedVMIds = selectedVMIds.value;
props.stepData.tableRows = tableRows.value;
emit('complete', { selectedVMs: selectedVMs.value });
refreshing.value = false;
const table = sortableTableRef.value;
if (table) {
const rowsToReselect = (table.pagedRows || []).filter((row) => selectedVMIds.value.has(row._original?.id));
if (rowsToReselect.length > 0) {
table.update(rowsToReselect, []);
}
}
};
const init = async() => {
if (discoveredVMs.value.length > 0) {
if (!lastFetchedAt.value) {
lastFetchedAt.value = Date.now();
}
tableRows.value = buildTableRows();
loading.value = false;
return;
}
try {
await fetchVMs();
} catch (e) {
discoveredVMs.value = [];
}
loading.value = false;
};
// Emit initial complete if VMs are already selected (restored from stepData)
if (selectedVMs.value.length > 0) {
emit('complete', { selectedVMs: selectedVMs.value });
}
init();
</script>
<template>
<Loading v-if="refreshing" />
<Loading v-if="loading" />
<div
v-else
class="select-vms-step"
>
<p class="text-muted line-height-20">
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
<router-link
v-if="provider"
class="provider-link"
:to="provider._detailLocation"
>
{{ providerName }}
</router-link>
<br>
<span
v-if="lastSyncedTime"
>
{{ t('harvester.addons.vmMigration.selectVms.lastSynced', { time: lastSyncedTime }) }}
<a
role="button"
class="text-bold"
:class="{ disabled: refreshing }"
@click.prevent="refreshVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.refreshNow') }}
</a>
</span>
</p>
<!-- Discovered VMs table -->
<SortableTable
ref="sortableTableRef"
:rows="tableRows"
:headers="headers"
:search="true"
:table-actions="true"
:row-actions="false"
:groupable="false"
:paging="true"
:rows-per-page="20"
key-field="_key"
@selection="onSelect"
>
<template #header-left>
<div class="vm-table-title">
<h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
</div>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.vmName }}</span>
<span class="vm-id text-muted">{{ row.vmId }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resourcesDisplay }}</span><br>
<span class="text-muted">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
:label="row.powerState"
:color="row.powerStateClass === 'power-on' ? 'bg-warning' : 'bg-darker'"
/>
</template>
<template #cell:network="{ row }">
<div>
<div
v-for="(net, i) in row.networks"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ net.name }}</span><br>
<span class="text-muted">{{ net.id }}</span>
</div>
</div>
</template>
<template #cell:datastore="{ row }">
<div>
<div
v-for="(ds, i) in row.datastores"
:key="i"
:class="{ 'mt-4': i > 0 }"
>
<span>{{ ds.name }}</span><br>
<span class="text-muted">{{ ds.id }}</span>
</div>
</div>
</template>
</SortableTable>
<Teleport
v-if="showSelectAllBanner && theadElement"
:to="theadElement"
>
<tr class="select-all-banner-row">
<td
:colspan="headers.length + 1"
class="select-all-banner-cell"
>
<template v-if="allVMsSelected">
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.allSelected') }}</span>
<a
role="button"
@click.prevent="clearSelection"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.clearSelection') }}
</a>
</template>
<template v-else>
<span>{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.pageOnly') }}</span>
<a
role="button"
@click.prevent="selectAllVMs"
>
{{ t('harvester.addons.vmMigration.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
</a>
</template>
</td>
</tr>
</Teleport>
</div>
</template>
<style lang="scss" scoped>
.vm-table-title {
h4 {
margin: 0;
font-weight: 600;
}
.text-muted {
font-size: 13px;
}
}
.vm-name-cell {
display: flex;
flex-direction: column;
font-size: 14px;
.vm-name {
line-height: 20px;
}
.vm-id {
line-height: 20px;
}
}
.select-vms-step {
position: relative;
display: flex;
flex-direction: column;
gap: 20px;
.line-height-20 {
line-height: 20px;
}
}
:deep(.select-all-banner-row) {
.select-all-banner-cell {
text-align: center;
padding: 8px 16px;
background-color: var(--sortable-table-header-bg);
border-bottom: 1px solid var(--border);
font-size: 13px;
a {
color: var(--primary);
cursor: pointer;
font-weight: 600;
margin-left: 5px;
&:hover {
text-decoration: underline;
}
}
}
}
</style>