mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-06 06:06:05 +08:00
feat(forklift): Changed to Wizard
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
@@ -1,757 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { SCHEMA, STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../../../types';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter, currentRoute } from '../../../../utils/router';
|
||||
|
||||
const schema = {
|
||||
id: HCI.FORKLIFT_NETWORK_MAP,
|
||||
type: SCHEMA,
|
||||
attributes: {
|
||||
kind: HCI.FORKLIFT_NETWORK_MAP,
|
||||
namespaced: true
|
||||
},
|
||||
metadata: { name: HCI.FORKLIFT_NETWORK_MAP },
|
||||
};
|
||||
|
||||
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 errors = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
const NAMESPACE = 'forklift';
|
||||
const providerName = computed(() => currentRoute().query.provider || 'vsphere');
|
||||
|
||||
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.forklift.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.forklift.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;
|
||||
});
|
||||
|
||||
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: ds.capacity || 0,
|
||||
target: '',
|
||||
usedBy: [],
|
||||
_key: `stor-${ ds.id }`,
|
||||
};
|
||||
}
|
||||
|
||||
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(' \u2022 ');
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveMappings = async(buttonCb) => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
const providerRef = {
|
||||
source: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: providerName.value,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
destination: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: 'host',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
try {
|
||||
// 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,
|
||||
};
|
||||
|
||||
// Fetch the Provider to build ownerReference
|
||||
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
|
||||
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
|
||||
|
||||
const providerOwnerRef = provider ? [{
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: provider.metadata.name,
|
||||
uid: provider.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
}] : [];
|
||||
|
||||
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: {
|
||||
name: `${ providerName.value }-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: `${ providerName.value }-storage-map-${ Math.random().toString(36).substring(2, 7) }`,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: providerOwnerRef,
|
||||
},
|
||||
spec: storageMapSpec,
|
||||
});
|
||||
|
||||
await storageMap.save();
|
||||
|
||||
// Navigate to review page
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: {
|
||||
provider: providerName.value,
|
||||
vms: currentRoute().query.vms,
|
||||
networkMap: networkMap.metadata.name,
|
||||
storageMap: storageMap.metadata.name,
|
||||
}
|
||||
});
|
||||
|
||||
buttonCb(true);
|
||||
} catch (err) {
|
||||
errors.value = [err.message || err];
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
|
||||
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 = [];
|
||||
}
|
||||
|
||||
try {
|
||||
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||
} catch (e) {
|
||||
// Provider may already be in store from previous step
|
||||
}
|
||||
|
||||
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
|
||||
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
|
||||
|
||||
const vmsParam = currentRoute().query.vms;
|
||||
|
||||
if (vmsParam) {
|
||||
try {
|
||||
const vmIds = JSON.parse(vmsParam);
|
||||
const providerUid = provider?.metadata?.uid;
|
||||
const providerType = provider?.spec?.type || 'vsphere';
|
||||
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
|
||||
|
||||
const [allVms, networksData, datastoresData] = await Promise.all([
|
||||
fetch(`${ baseUrl }/vms`).then((r) => r.json()).catch(() => []),
|
||||
fetch(`${ baseUrl }/networks`).then((r) => r.json()).catch(() => []),
|
||||
fetch(`${ baseUrl }/datastores`).then((r) => r.json()).catch(() => []),
|
||||
]);
|
||||
|
||||
const networkNameMap = (Array.isArray(networksData) ? networksData : []).reduce((map, n) => {
|
||||
map[n.id] = n.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
const datastoreNameMap = (Array.isArray(datastoresData) ? datastoresData : []).reduce((map, d) => {
|
||||
map[d.id] = d.name;
|
||||
|
||||
return map;
|
||||
}, {});
|
||||
|
||||
const vmList = Array.isArray(allVms) ? allVms : (allVms?.data || []);
|
||||
|
||||
vms.value = vmIds.map((id) => {
|
||||
const found = vmList.find((vm) => vm.id === id);
|
||||
|
||||
if (found) {
|
||||
// Resolve network names
|
||||
if (found.networks) {
|
||||
found.networks = found.networks.map((n) => ({
|
||||
...n,
|
||||
name: n.name || networkNameMap[n.id] || n.id,
|
||||
}));
|
||||
}
|
||||
|
||||
// Resolve datastore names
|
||||
if (found.disks) {
|
||||
found.disks = found.disks.map((d) => ({
|
||||
...d,
|
||||
datastore: d.datastore ? {
|
||||
...d.datastore,
|
||||
name: d.datastore.name || datastoreNameMap[d.datastore.id] || d.datastore.id,
|
||||
} : d.datastore,
|
||||
}));
|
||||
}
|
||||
|
||||
return found;
|
||||
}
|
||||
|
||||
return {
|
||||
id, name: id, networks: [], disks: []
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
vms.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
buildNetworkEntries();
|
||||
buildStorageEntries();
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
class="configure-mappings"
|
||||
>
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.configureMappings.title')"
|
||||
:is-creatable="false"
|
||||
>
|
||||
<template #subHeader>
|
||||
<p class="text-muted line-height-20 mmt-5">
|
||||
{{ t('harvester.addons.forklift.configureMappings.description') }}
|
||||
</p>
|
||||
</template>
|
||||
</Masthead>
|
||||
|
||||
<div class="mappings-columns">
|
||||
<!-- Network Mapping -->
|
||||
<div class="mapping-column">
|
||||
<div>
|
||||
<h3 class="mapping-section-title">
|
||||
{{ t('harvester.addons.forklift.configureMappings.networkMapping.title') }}
|
||||
</h3>
|
||||
<p class="text-muted line-height-20">
|
||||
{{ t('harvester.addons.forklift.configureMappings.networkMapping.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="selectedNetworkTemplate"
|
||||
:label="t('harvester.addons.forklift.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.forklift.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.forklift.configureMappings.storageMapping.title') }}
|
||||
</h3>
|
||||
<p class="text-muted">
|
||||
{{ t('harvester.addons.forklift.configureMappings.storageMapping.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<LabeledSelect
|
||||
v-model:value="selectedStorageTemplate"
|
||||
:label="t('harvester.addons.forklift.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.forklift.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 class="actions-footer">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<AsyncButton
|
||||
:disabled="!canSave"
|
||||
:action-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
:waiting-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
:success-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
:error-label="t('harvester.addons.forklift.configureMappings.save')"
|
||||
@click="saveMappings"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.configure-mappings {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.actions-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.bg-light-gray {
|
||||
background-color: var(--category-active) !important;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,596 +0,0 @@
|
||||
<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 Masthead from '@shell/components/ResourceList/Masthead';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { SCHEMA, SECRET } from '@shell/config/types';
|
||||
import { randomStr } from '@shell/utils/string';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../../../types';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter } from '../../../../utils/router';
|
||||
|
||||
const schema = {
|
||||
id: HCI.FORKLIFT_PROVIDER,
|
||||
type: SCHEMA,
|
||||
attributes: {
|
||||
kind: HCI.FORKLIFT_PROVIDER,
|
||||
namespaced: true
|
||||
},
|
||||
metadata: { name: HCI.FORKLIFT_PROVIDER },
|
||||
};
|
||||
|
||||
const CREATE_NEW = '__create_new__';
|
||||
|
||||
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 createdProvider = ref(null);
|
||||
const createdSecret = ref(null);
|
||||
const loading = ref(true);
|
||||
const testPassed = ref(false);
|
||||
const testing = ref(false);
|
||||
const saving = ref(false);
|
||||
|
||||
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.forklift.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;
|
||||
}
|
||||
});
|
||||
|
||||
const cancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
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.forklift.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.forklift.configureProvider.testSuccess');
|
||||
testing.value = false;
|
||||
buttonCb(true);
|
||||
} else {
|
||||
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
} catch (err) {
|
||||
testError.value = err.message || t('harvester.addons.forklift.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.forklift.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.forklift.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.forklift.configureProvider.testFailed');
|
||||
testing.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveProvider = async(buttonCb) => {
|
||||
errors.value = [];
|
||||
saving.value = true;
|
||||
|
||||
if (!testPassed.value) {
|
||||
// Test hasn't passed yet — run it first
|
||||
await testConnection((success) => {
|
||||
if (success) {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: { provider: providerName.value }
|
||||
});
|
||||
} else {
|
||||
saving.value = false;
|
||||
buttonCb(false);
|
||||
}
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: { provider: providerName.value }
|
||||
});
|
||||
};
|
||||
|
||||
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();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="configure-provider">
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.configureProvider.title')"
|
||||
:is-creatable="false"
|
||||
>
|
||||
<template #subHeader>
|
||||
<div class="mmt-5">
|
||||
<p class="text-muted">
|
||||
{{ t('harvester.addons.forklift.configureProvider.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
</Masthead>
|
||||
|
||||
<h3 class="mmt-3 mmb-3 table-title">
|
||||
<b>{{ t('harvester.addons.forklift.configureProvider.connectionDetails') }}</b>
|
||||
</h3>
|
||||
|
||||
<Banner
|
||||
class="requirements-banner mt-0 mmb-3"
|
||||
color="info"
|
||||
>
|
||||
<div class="requirements-banner-content">
|
||||
<span class="requirements-banner-title">{{ t('harvester.addons.forklift.configureProvider.requirementsTitle') }}</span>
|
||||
<br>
|
||||
<span>{{ t('harvester.addons.forklift.configureProvider.requirementsText') }}</span>
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<div class="mb-20">
|
||||
<LabeledSelect
|
||||
v-model:value="selectedProvider"
|
||||
:label="t('harvester.addons.forklift.configureProvider.providerSelect')"
|
||||
:options="providerOptions"
|
||||
:reduce="(opt) => opt.value"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="!isExistingProvider"
|
||||
class="mb-20"
|
||||
>
|
||||
<LabeledInput
|
||||
v-model:value="providerName"
|
||||
:label="t('harvester.addons.forklift.configureProvider.name')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mb-20">
|
||||
<LabeledInput
|
||||
v-model:value="url"
|
||||
:label="t('harvester.addons.forklift.configureProvider.urlLabel')"
|
||||
:placeholder="t('harvester.addons.forklift.configureProvider.urlPlaceholder')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
<p class="text-muted mt-5">
|
||||
{{ t('harvester.addons.forklift.configureProvider.urlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="row mb-20">
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="username"
|
||||
:label="t('harvester.addons.forklift.fields.username')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="col span-6">
|
||||
<LabeledInput
|
||||
v-model:value="password"
|
||||
type="password"
|
||||
:label="t('harvester.addons.forklift.fields.password')"
|
||||
:disabled="isExistingProvider"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-20">
|
||||
<Checkbox
|
||||
v-model:value="skipTlsVerify"
|
||||
:label="t('harvester.addons.forklift.configureProvider.skipSsl')"
|
||||
:disabled="isExistingProvider"
|
||||
/>
|
||||
<p class="text-muted ml-20">
|
||||
{{ t('harvester.addons.forklift.configureProvider.skipSslHint') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="mb-20">
|
||||
<button
|
||||
v-if="testPassed"
|
||||
class="btn role-secondary test-passed-btn"
|
||||
disabled
|
||||
>
|
||||
<i class="icon icon-checkmark mr-10" /> {{ t('harvester.addons.forklift.configureProvider.testConnection') }}
|
||||
</button>
|
||||
<AsyncButton
|
||||
v-else
|
||||
mode="test"
|
||||
:disabled="!isFormValid || testing || saving"
|
||||
:action-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
:waiting-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
:success-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
:error-label="t('harvester.addons.forklift.configureProvider.testConnection')"
|
||||
class="btn role-secondary"
|
||||
@click="testConnection"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Banner
|
||||
v-if="testResult"
|
||||
color="success"
|
||||
class="mb-10"
|
||||
>
|
||||
{{ testResult }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-if="testError"
|
||||
color="error"
|
||||
class="mb-10"
|
||||
>
|
||||
{{ testError }}
|
||||
</Banner>
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
class="mb-10"
|
||||
>
|
||||
{{ err }}
|
||||
</Banner>
|
||||
|
||||
<div class="provider-actions">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<AsyncButton
|
||||
:disabled="!isFormValid || testing || saving"
|
||||
:action-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
:waiting-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
:success-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
:error-label="isExistingProvider ? t('harvester.addons.forklift.configureProvider.saveExisting') : t('harvester.addons.forklift.configureProvider.save')"
|
||||
@click="saveProvider"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.configure-provider {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.requirements-banner {
|
||||
font-weight: 400;
|
||||
|
||||
.requirements-banner-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.provider-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,654 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||
import AsyncButton from '@shell/components/AsyncButton';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import MappingsCell from '../../../../components/MappingsCell';
|
||||
import { SCHEMA } from '@shell/config/types';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../../../types';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter, currentRoute } from '../../../../utils/router';
|
||||
|
||||
const schema = {
|
||||
id: HCI.FORKLIFT_PLAN,
|
||||
type: SCHEMA,
|
||||
attributes: {
|
||||
kind: HCI.FORKLIFT_PLAN,
|
||||
namespaced: true
|
||||
},
|
||||
metadata: { name: HCI.FORKLIFT_PLAN },
|
||||
};
|
||||
|
||||
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);
|
||||
|
||||
const NAMESPACE = 'forklift';
|
||||
const TARGET_NAMESPACE = 'default';
|
||||
const providerName = computed(() => currentRoute().query.provider || 'vsphere');
|
||||
const networkMapName = computed(() => currentRoute().query.networkMap || '');
|
||||
const storageMapName = computed(() => currentRoute().query.storageMap || '');
|
||||
|
||||
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 cancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const startMigration = async(buttonCb) => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
try {
|
||||
// Rename NetworkMap and StorageMap to use the plan name
|
||||
const allNetworkMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [];
|
||||
const allStorageMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [];
|
||||
|
||||
const currentNetworkMap = allNetworkMaps.find((nm) => nm.metadata.name === networkMapName.value && nm.metadata.namespace === NAMESPACE);
|
||||
const currentStorageMap = allStorageMaps.find((sm) => sm.metadata.name === storageMapName.value && sm.metadata.namespace === NAMESPACE);
|
||||
|
||||
const newNetworkMapName = `${ planName.value }-network-map`;
|
||||
const newStorageMapName = `${ planName.value }-storage-map`;
|
||||
|
||||
// Recreate NetworkMap with new name
|
||||
let finalNetworkMapName = networkMapName.value;
|
||||
|
||||
if (currentNetworkMap) {
|
||||
const networkMapData = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: {
|
||||
name: newNetworkMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
spec: currentNetworkMap.spec,
|
||||
});
|
||||
|
||||
await networkMapData.save();
|
||||
await currentNetworkMap.remove();
|
||||
finalNetworkMapName = newNetworkMapName;
|
||||
}
|
||||
|
||||
// Recreate StorageMap with new name
|
||||
let finalStorageMapName = storageMapName.value;
|
||||
|
||||
if (currentStorageMap) {
|
||||
const storageMapData = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||
metadata: {
|
||||
name: newStorageMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
spec: currentStorageMap.spec,
|
||||
});
|
||||
|
||||
await storageMapData.save();
|
||||
await currentStorageMap.remove();
|
||||
finalStorageMapName = newStorageMapName;
|
||||
}
|
||||
|
||||
const planSpec = {
|
||||
provider: {
|
||||
source: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: providerName.value,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
destination: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Provider',
|
||||
name: 'host',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
},
|
||||
map: {
|
||||
network: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'NetworkMap',
|
||||
name: finalNetworkMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
storage: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'StorageMap',
|
||||
name: finalStorageMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
},
|
||||
targetNamespace: 'default',
|
||||
vms: vms.value.map((vm) => ({
|
||||
id: vm.id,
|
||||
name: vm.name || vm.id,
|
||||
})),
|
||||
warm: false,
|
||||
};
|
||||
|
||||
const plan = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_PLAN,
|
||||
metadata: {
|
||||
name: planName.value,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
spec: planSpec,
|
||||
});
|
||||
|
||||
await plan.save();
|
||||
|
||||
// Build ownerReference pointing to the Plan
|
||||
const planOwnerRef = {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Plan',
|
||||
name: plan.metadata.name,
|
||||
uid: plan.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
};
|
||||
|
||||
// Create Migration owned by Plan
|
||||
const migrationName = `${ planName.value }-migration-${ Math.random().toString(36).substring(2, 7) }`;
|
||||
|
||||
const migration = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_MIGRATION,
|
||||
metadata: {
|
||||
name: migrationName,
|
||||
namespace: NAMESPACE,
|
||||
ownerReferences: [planOwnerRef],
|
||||
},
|
||||
spec: {
|
||||
plan: {
|
||||
apiVersion: 'forklift.konveyor.io/v1beta1',
|
||||
kind: 'Plan',
|
||||
name: planName.value,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await migration.save();
|
||||
|
||||
// Set Plan as owner of the newly created NetworkMap and StorageMap
|
||||
const updatedNetworkMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [];
|
||||
const updatedStorageMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [];
|
||||
|
||||
const newNetworkMap = updatedNetworkMaps.find((nm) => nm.metadata.name === finalNetworkMapName && nm.metadata.namespace === NAMESPACE);
|
||||
const newStorageMap = updatedStorageMaps.find((sm) => sm.metadata.name === finalStorageMapName && sm.metadata.namespace === NAMESPACE);
|
||||
|
||||
if (newNetworkMap) {
|
||||
newNetworkMap.metadata.ownerReferences = [planOwnerRef];
|
||||
await newNetworkMap.save();
|
||||
}
|
||||
|
||||
if (newStorageMap) {
|
||||
newStorageMap.metadata.ownerReferences = [planOwnerRef];
|
||||
await newStorageMap.save();
|
||||
}
|
||||
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
|
||||
buttonCb(true);
|
||||
} catch (err) {
|
||||
errors.value = [err.message || err];
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER }).catch(() => {});
|
||||
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }).catch(() => {});
|
||||
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }).catch(() => {});
|
||||
|
||||
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
|
||||
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
|
||||
|
||||
const vmsParam = currentRoute().query.vms;
|
||||
|
||||
if (vmsParam) {
|
||||
try {
|
||||
const vmIds = JSON.parse(vmsParam);
|
||||
const providerUid = provider?.metadata?.uid;
|
||||
const providerType = provider?.spec?.type || 'vsphere';
|
||||
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/${ providerType }/${ providerUid }`;
|
||||
const allVms = await fetch(`${ baseUrl }/vms`).then((r) => r.json()).catch(() => []);
|
||||
const vmList = Array.isArray(allVms) ? allVms : (allVms?.data || []);
|
||||
|
||||
vms.value = vmIds.map((id) => {
|
||||
const found = vmList.find((vm) => vm.id === id);
|
||||
|
||||
return found || {
|
||||
id, name: id, networks: [], disks: [], cpuCount: 0, memoryMB: 0, guestName: ''
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
vms.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
// Build mapping display data from the actual NetworkMap/StorageMap resources
|
||||
const allNetworkMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [];
|
||||
const allStorageMaps = store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [];
|
||||
|
||||
const networkMap = allNetworkMaps.find((nm) => nm.metadata.name === networkMapName.value && nm.metadata.namespace === NAMESPACE);
|
||||
const storageMap = allStorageMaps.find((sm) => sm.metadata.name === storageMapName.value && sm.metadata.namespace === NAMESPACE);
|
||||
|
||||
if (networkMap?.spec?.map) {
|
||||
networkMappings.value = networkMap.spec.map.map((m) => {
|
||||
const source = m.source?.name || m.source?.id || 'Unknown';
|
||||
let target = '';
|
||||
|
||||
if (m.destination?.type === 'pod') {
|
||||
target = 'Pod Networking';
|
||||
} else if (m.destination?.type === 'ignored') {
|
||||
target = 'Ignored';
|
||||
} else if (m.destination?.type === 'multus' && m.destination?.name) {
|
||||
target = m.destination.namespace ? `${ m.destination.namespace }/${ m.destination.name }` : m.destination.name;
|
||||
}
|
||||
|
||||
// Find which VMs use this network
|
||||
const sourceId = m.source?.id;
|
||||
const usedBy = vms.value
|
||||
.filter((vm) => vm.networks?.some((n) => n.id === sourceId || n.name === m.source?.name))
|
||||
.map((vm) => vm.name || vm.id);
|
||||
|
||||
return {
|
||||
source, target, usedBy
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
if (storageMap?.spec?.map) {
|
||||
storageMappings.value = storageMap.spec.map.map((m) => {
|
||||
const source = m.source?.name || m.source?.id || 'Unknown';
|
||||
const target = m.destination?.storageClass || '';
|
||||
|
||||
// Find which VMs use this datastore
|
||||
const sourceId = m.source?.id;
|
||||
const usedBy = vms.value
|
||||
.filter((vm) => vm.disks?.some((d) => d.datastore?.id === sourceId || d.datastore?.name === m.source?.name))
|
||||
.map((vm) => vm.name || vm.id);
|
||||
|
||||
return {
|
||||
source, target, usedBy
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
class="review-migration"
|
||||
>
|
||||
<div
|
||||
class="review-migration-content"
|
||||
>
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.reviewMigration.title')"
|
||||
:is-creatable="false"
|
||||
class="line-height-32"
|
||||
>
|
||||
<template #subHeader>
|
||||
<p class="text-muted mt-5">
|
||||
{{ t('harvester.addons.forklift.reviewMigration.description') }}
|
||||
</p>
|
||||
</template>
|
||||
</Masthead>
|
||||
|
||||
<!-- Migration Details Summary -->
|
||||
<div class="migration-details">
|
||||
<h3 class="section-title m-0">
|
||||
{{ t('harvester.addons.forklift.reviewMigration.migrationDetails') }}
|
||||
</h3>
|
||||
<div class="span-9">
|
||||
<LabeledInput
|
||||
v-model:value="planName"
|
||||
:label="t('harvester.addons.forklift.reviewMigration.planName')"
|
||||
:placeholder="t('harvester.addons.forklift.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.forklift.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.forklift.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.forklift.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.forklift.reviewMigration.vcpu') }}</span>
|
||||
<span class="detail-value">{{ totalVCpu }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.forklift.reviewMigration.memory') }}</span>
|
||||
<span class="detail-value">{{ totalMemoryGB }} GB</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.forklift.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.forklift.reviewMigration.migrationMode') }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-value">
|
||||
{{ t('harvester.addons.forklift.reviewMigration.coldMigration') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Machines -->
|
||||
<div class="vm-section">
|
||||
<h3 class="section-title m-0">
|
||||
{{ t('harvester.addons.forklift.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 • {{ vm.memGB }} • {{ 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.forklift.reviewMigration.warningTitle') }}</span><br>
|
||||
{{ t('harvester.addons.forklift.reviewMigration.warningMessage') }}
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<!-- Error banner -->
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
</div>
|
||||
<!-- Actions -->
|
||||
<div class="actions-footer">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<AsyncButton
|
||||
:disabled="!planName"
|
||||
:action-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
|
||||
:waiting-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
|
||||
:success-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
|
||||
:error-label="t('harvester.addons.forklift.reviewMigration.startMigration')"
|
||||
@click="startMigration"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-migration {
|
||||
padding: 20px;
|
||||
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;
|
||||
}
|
||||
|
||||
.actions-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.line-height-32 {
|
||||
line-height: 32px;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -1,510 +0,0 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import Masthead from '@shell/components/ResourceList/Masthead';
|
||||
import SortableTable from '@shell/components/SortableTable';
|
||||
import { BadgeState } from '@components/BadgeState';
|
||||
import { SCHEMA } from '@shell/config/types';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../../../types';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter, currentRoute } from '../../../../utils/router';
|
||||
|
||||
const schema = {
|
||||
id: HCI.FORKLIFT_PROVIDER,
|
||||
type: SCHEMA,
|
||||
attributes: {
|
||||
kind: HCI.FORKLIFT_PROVIDER,
|
||||
namespaced: true
|
||||
},
|
||||
metadata: { name: HCI.FORKLIFT_PROVIDER },
|
||||
};
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const allProviders = ref([]);
|
||||
const provider = ref(null);
|
||||
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 providerName = computed(() => provider.value?.metadata?.name || currentRoute().query.provider || '');
|
||||
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.forklift.selectVms.columns.vmName',
|
||||
value: 'vmName',
|
||||
sort: ['vmName'],
|
||||
subLabel: 'Identifier',
|
||||
},
|
||||
{
|
||||
name: 'os',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.os',
|
||||
value: 'os',
|
||||
sort: ['os'],
|
||||
},
|
||||
{
|
||||
name: 'resources',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.resources',
|
||||
value: 'resources',
|
||||
sort: false,
|
||||
},
|
||||
{
|
||||
name: 'powerState',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.powerState',
|
||||
value: 'powerState',
|
||||
sort: ['powerState'],
|
||||
},
|
||||
{
|
||||
name: 'network',
|
||||
labelKey: 'harvester.addons.forklift.selectVms.columns.network',
|
||||
value: 'network',
|
||||
sort: ['network'],
|
||||
subLabel: 'Identifier',
|
||||
},
|
||||
{
|
||||
name: 'datastore',
|
||||
labelKey: 'harvester.addons.forklift.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));
|
||||
};
|
||||
|
||||
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 cancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const saveSelection = () => {
|
||||
const vmIds = selectedVMs.value.map((vm) => vm.id || vm.vmId || vm.metadata?.name);
|
||||
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
},
|
||||
query: {
|
||||
provider: providerName.value,
|
||||
vms: JSON.stringify(vmIds),
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
allProviders.value = await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
|
||||
|
||||
const queryProvider = currentRoute().query.provider;
|
||||
|
||||
if (queryProvider) {
|
||||
provider.value = allProviders.value.find(
|
||||
(p) => p.metadata.name === queryProvider && p.metadata.namespace === 'forklift'
|
||||
);
|
||||
}
|
||||
|
||||
if (provider.value) {
|
||||
try {
|
||||
const providerUid = provider.value.metadata.uid;
|
||||
const providerType = provider.value.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;
|
||||
}, {});
|
||||
} catch (e) {
|
||||
discoveredVMs.value = [];
|
||||
}
|
||||
}
|
||||
|
||||
tableRows.value = buildTableRows();
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
init();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
>
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.selectVms.title')"
|
||||
:is-creatable="false"
|
||||
>
|
||||
<template #subHeader>
|
||||
<p class="text-muted mmt-5">
|
||||
{{ t('harvester.addons.forklift.selectVms.discovered', { count: vmCount }) }}
|
||||
<a
|
||||
v-if="providerName"
|
||||
class="provider-link"
|
||||
>{{ providerName }}</a>
|
||||
<br>
|
||||
<span class="text-small">{{ t('harvester.addons.forklift.selectVms.lastSynced') }}</span>
|
||||
</p>
|
||||
</template>
|
||||
</Masthead>
|
||||
|
||||
<!-- 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.forklift.selectVms.availableVms') }}
|
||||
</h3>
|
||||
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.forklift.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.forklift.selectVms.selectAllBanner.allSelected') }}</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="clearSelection"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.clearSelection') }}
|
||||
</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span>{{ t('harvester.addons.forklift.selectVms.selectAllBanner.pageOnly') }}</span>
|
||||
<a
|
||||
role="button"
|
||||
@click.prevent="selectAllVMs"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.selectVms.selectAllBanner.selectAll', { count: vmCount }) }}
|
||||
</a>
|
||||
</template>
|
||||
</td>
|
||||
</tr>
|
||||
</Teleport>
|
||||
|
||||
<div class="actions-footer">
|
||||
<button
|
||||
class="btn role-secondary"
|
||||
@click="cancel"
|
||||
>
|
||||
{{ t('generic.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="btn role-primary"
|
||||
:disabled="selectedCount === 0"
|
||||
@click="saveSelection"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.selectVms.saveSelection') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.provider-link {
|
||||
color: var(--primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
: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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.actions-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 10px;
|
||||
margin-top: 30px;
|
||||
}
|
||||
</style>
|
||||
+9
-11
@@ -127,7 +127,7 @@ const rows = computed(() => {
|
||||
});
|
||||
|
||||
const createLocation = computed(() => ({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration-wizard`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
@@ -135,21 +135,21 @@ const createLocation = computed(() => ({
|
||||
}));
|
||||
|
||||
const headers = [
|
||||
{ ...STATE, labelKey: 'harvester.addons.forklift.dashboard.columns.status' },
|
||||
{ ...STATE, labelKey: 'harvester.addons.vmMigration.dashboard.columns.status' },
|
||||
{
|
||||
...NAME_COL,
|
||||
labelKey: 'harvester.addons.forklift.dashboard.columns.plan',
|
||||
labelKey: 'harvester.addons.vmMigration.dashboard.columns.plan',
|
||||
},
|
||||
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
|
||||
{
|
||||
name: 'progress',
|
||||
labelKey: 'harvester.addons.forklift.dashboard.columns.progress',
|
||||
labelKey: 'harvester.addons.vmMigration.dashboard.columns.progress',
|
||||
value: 'progress',
|
||||
width: 500,
|
||||
},
|
||||
{
|
||||
name: 'mappings',
|
||||
labelKey: 'harvester.addons.forklift.dashboard.columns.mappings',
|
||||
labelKey: 'harvester.addons.vmMigration.dashboard.columns.mappings',
|
||||
value: 'mappingsDisplay',
|
||||
},
|
||||
{ ...AGE },
|
||||
@@ -174,12 +174,12 @@ init();
|
||||
<Masthead
|
||||
:schema="schema"
|
||||
:resource="schema.id"
|
||||
:type-display="t('harvester.addons.forklift.dashboard.title')"
|
||||
:type-display="t('harvester.addons.vmMigration.dashboard.title')"
|
||||
>
|
||||
<template #subHeader>
|
||||
<div class="mmt-5">
|
||||
<p class="text-muted">
|
||||
{{ t('harvester.addons.forklift.dashboard.description') }}
|
||||
{{ t('harvester.addons.vmMigration.dashboard.description') }}
|
||||
</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -188,7 +188,7 @@ init();
|
||||
:to="createLocation"
|
||||
class="btn role-primary"
|
||||
>
|
||||
{{ t('harvester.addons.forklift.dashboard.createPlan') }}
|
||||
{{ t('harvester.addons.vmMigration.dashboard.createPlan') }}
|
||||
</router-link>
|
||||
</template>
|
||||
</Masthead>
|
||||
@@ -200,13 +200,11 @@ init();
|
||||
:groupable="false"
|
||||
:table-actions="false"
|
||||
:search="false"
|
||||
default-sort-by="state"
|
||||
:default-sort-descending="true"
|
||||
key-field="_key"
|
||||
>
|
||||
<template #header-left>
|
||||
<h3 class="table-title m-0">
|
||||
{{ t('harvester.addons.forklift.dashboard.tableTitle') }}
|
||||
{{ t('harvester.addons.vmMigration.dashboard.tableTitle') }}
|
||||
</h3>
|
||||
</template>
|
||||
<template #cell:name="{ row }">
|
||||
@@ -0,0 +1,294 @@
|
||||
<script setup>
|
||||
import { reactive, ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import CruResource from '@shell/components/CruResource';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import ConfigureProviderStep from '../../../../components/vm-migration/ConfigureProviderStep';
|
||||
import SelectVmsStep from '../../../../components/vm-migration/SelectVmsStep';
|
||||
import ConfigureMappingsStep from '../../../../components/vm-migration/ConfigureMappingsStep';
|
||||
import ReviewMigrationStep from '../../../../components/vm-migration/ReviewMigrationStep';
|
||||
import { PRODUCT_NAME } from '../../../../config/harvester';
|
||||
import { currentRouter } from '../../../../utils/router';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const cruRef = ref(null);
|
||||
const providerStepRef = ref(null);
|
||||
const mappingsStepRef = ref(null);
|
||||
const reviewStepRef = ref(null);
|
||||
|
||||
const providerName = ref('');
|
||||
const provider = ref(null);
|
||||
const selectedVMs = ref([]);
|
||||
const networkMapName = ref('');
|
||||
const storageMapName = ref('');
|
||||
const errors = ref([]);
|
||||
|
||||
const providerReady = ref(false);
|
||||
const providerFormValid = ref(false);
|
||||
const providerTesting = ref(false);
|
||||
const vmsReady = ref(false);
|
||||
const vmsLoading = ref(true);
|
||||
const mappingsReady = ref(false);
|
||||
const reviewReady = ref(false);
|
||||
|
||||
const dummyResource = ref({ save: () => Promise.resolve() });
|
||||
|
||||
const stepData = reactive({
|
||||
provider: {
|
||||
selectedProvider: '__create_new__',
|
||||
providerName: '',
|
||||
url: '',
|
||||
username: '',
|
||||
password: '',
|
||||
skipTlsVerify: false,
|
||||
testPassed: false,
|
||||
testResult: null,
|
||||
testError: null,
|
||||
createdProvider: null,
|
||||
createdSecret: null,
|
||||
},
|
||||
vms: {
|
||||
discoveredVMs: [],
|
||||
selectedVMIds: new Set(),
|
||||
tableRows: [],
|
||||
},
|
||||
mappings: {
|
||||
networkEntries: [],
|
||||
storageEntries: [],
|
||||
selectedNetworkTemplate: '__none__',
|
||||
selectedStorageTemplate: '__none__',
|
||||
},
|
||||
review: { planName: '' },
|
||||
});
|
||||
|
||||
const steps = reactive([
|
||||
{
|
||||
name: 'configure-provider',
|
||||
label: '',
|
||||
subtext: '',
|
||||
ready: false,
|
||||
},
|
||||
{
|
||||
name: 'select-vms',
|
||||
label: '',
|
||||
subtext: '',
|
||||
ready: false,
|
||||
},
|
||||
{
|
||||
name: 'configure-mappings',
|
||||
label: '',
|
||||
subtext: '',
|
||||
ready: false,
|
||||
},
|
||||
{
|
||||
name: 'review-migration',
|
||||
label: '',
|
||||
subtext: '',
|
||||
ready: false,
|
||||
},
|
||||
]);
|
||||
|
||||
steps[0].label = t('harvester.addons.vmMigration.wizard.steps.configureProvider.label');
|
||||
steps[0].subtext = t('harvester.addons.vmMigration.wizard.steps.configureProvider.description');
|
||||
steps[1].label = t('harvester.addons.vmMigration.wizard.steps.selectVms.label');
|
||||
steps[1].subtext = t('harvester.addons.vmMigration.wizard.steps.selectVms.description');
|
||||
steps[2].label = t('harvester.addons.vmMigration.wizard.steps.configureMappings.label');
|
||||
steps[2].subtext = t('harvester.addons.vmMigration.wizard.steps.configureMappings.description');
|
||||
steps[3].label = t('harvester.addons.vmMigration.wizard.steps.reviewMigration.label');
|
||||
steps[3].subtext = t('harvester.addons.vmMigration.wizard.steps.reviewMigration.description');
|
||||
|
||||
watch([providerFormValid, providerTesting], () => {
|
||||
steps[0].ready = providerFormValid.value && !providerTesting.value;
|
||||
}, { immediate: true });
|
||||
watch([vmsReady, vmsLoading], () => {
|
||||
steps[1].ready = vmsReady.value && !vmsLoading.value;
|
||||
});
|
||||
watch(mappingsReady, (val) => {
|
||||
steps[2].ready = val;
|
||||
});
|
||||
watch(reviewReady, (val) => {
|
||||
steps[3].ready = val;
|
||||
});
|
||||
|
||||
const wizardComponent = computed(() => cruRef.value?.$refs?.Wizard);
|
||||
|
||||
const pendingProceed = ref(false);
|
||||
|
||||
watch(
|
||||
() => wizardComponent.value?.activeStepIndex,
|
||||
(newIdx, oldIdx) => {
|
||||
if (oldIdx === 0 && newIdx === 1 && !providerReady.value) {
|
||||
wizardComponent.value.goToStep(1);
|
||||
pendingProceed.value = true;
|
||||
providerStepRef.value.clickTestButton();
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(providerReady, (val) => {
|
||||
if (val && pendingProceed.value) {
|
||||
pendingProceed.value = false;
|
||||
steps[0].ready = true;
|
||||
wizardComponent.value.goToStep(2);
|
||||
}
|
||||
});
|
||||
|
||||
const onProviderComplete = (data) => {
|
||||
providerName.value = data.providerName;
|
||||
provider.value = data.provider;
|
||||
providerReady.value = true;
|
||||
};
|
||||
|
||||
const onProviderFormValid = (valid) => {
|
||||
providerFormValid.value = valid;
|
||||
};
|
||||
|
||||
const onProviderTesting = (testing) => {
|
||||
providerTesting.value = testing;
|
||||
};
|
||||
|
||||
const onProviderReady = (ready) => {
|
||||
providerReady.value = ready;
|
||||
};
|
||||
|
||||
const onVmsComplete = (data) => {
|
||||
selectedVMs.value = data.selectedVMs;
|
||||
vmsReady.value = data.selectedVMs.length > 0;
|
||||
};
|
||||
|
||||
const onVmsLoading = (val) => {
|
||||
vmsLoading.value = val;
|
||||
};
|
||||
|
||||
const onMappingsReady = (ready) => {
|
||||
mappingsReady.value = ready;
|
||||
};
|
||||
|
||||
const onReviewReady = (ready) => {
|
||||
reviewReady.value = ready;
|
||||
};
|
||||
|
||||
// Clear downstream data when provider changes
|
||||
watch(() => stepData.provider.providerName, (newVal, oldVal) => {
|
||||
if (oldVal && newVal !== oldVal) {
|
||||
stepData.vms.discoveredVMs = [];
|
||||
stepData.vms.selectedVMIds = new Set();
|
||||
stepData.vms.tableRows = [];
|
||||
selectedVMs.value = [];
|
||||
vmsReady.value = false;
|
||||
|
||||
stepData.mappings.networkEntries = [];
|
||||
stepData.mappings.storageEntries = [];
|
||||
stepData.mappings.selectedNetworkTemplate = '__none__';
|
||||
stepData.mappings.selectedStorageTemplate = '__none__';
|
||||
mappingsReady.value = false;
|
||||
|
||||
stepData.review.planName = '';
|
||||
reviewReady.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Clear mappings and review when VM selection changes
|
||||
watch(selectedVMs, (newVal, oldVal) => {
|
||||
if (oldVal.length > 0 && JSON.stringify(newVal.map((v) => v.id).sort()) !== JSON.stringify(oldVal.map((v) => v.id).sort())) {
|
||||
stepData.mappings.networkEntries = [];
|
||||
stepData.mappings.storageEntries = [];
|
||||
stepData.mappings.selectedNetworkTemplate = '__none__';
|
||||
stepData.mappings.selectedStorageTemplate = '__none__';
|
||||
mappingsReady.value = false;
|
||||
|
||||
stepData.review.planName = '';
|
||||
reviewReady.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
const onFinish = async(buttonCb) => {
|
||||
try {
|
||||
await reviewStepRef.value.startMigration();
|
||||
buttonCb(true);
|
||||
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
} catch (err) {
|
||||
errors.value = [err instanceof Error ? err.message : String(err)];
|
||||
buttonCb(false);
|
||||
}
|
||||
};
|
||||
|
||||
const onCancel = () => {
|
||||
currentRouter().push({
|
||||
name: `${ PRODUCT_NAME }-c-cluster-vm-migration`,
|
||||
params: {
|
||||
product: store.getters['productId'],
|
||||
cluster: store.getters['clusterId'],
|
||||
}
|
||||
});
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CruResource
|
||||
ref="cruRef"
|
||||
:resource="dummyResource"
|
||||
:mode="'create'"
|
||||
:steps="steps"
|
||||
:errors="errors"
|
||||
:validation-passed="true"
|
||||
:can-yaml="false"
|
||||
:cancel-event="true"
|
||||
finish-mode="finish"
|
||||
class="wizard"
|
||||
@cancel="onCancel"
|
||||
@finish="onFinish"
|
||||
>
|
||||
<template #configure-provider>
|
||||
<ConfigureProviderStep
|
||||
ref="providerStepRef"
|
||||
:step-data="stepData.provider"
|
||||
@complete="onProviderComplete"
|
||||
@ready="onProviderReady"
|
||||
@form-valid="onProviderFormValid"
|
||||
@testing="onProviderTesting"
|
||||
/>
|
||||
</template>
|
||||
<template #select-vms>
|
||||
<SelectVmsStep
|
||||
:provider-name="providerName"
|
||||
:provider="provider"
|
||||
:step-data="stepData.vms"
|
||||
@complete="onVmsComplete"
|
||||
@loading="onVmsLoading"
|
||||
/>
|
||||
</template>
|
||||
<template #configure-mappings>
|
||||
<ConfigureMappingsStep
|
||||
ref="mappingsStepRef"
|
||||
:provider-name="providerName"
|
||||
:provider="provider"
|
||||
:selected-vms="selectedVMs"
|
||||
:step-data="stepData.mappings"
|
||||
@ready="onMappingsReady"
|
||||
/>
|
||||
</template>
|
||||
<template #review-migration>
|
||||
<ReviewMigrationStep
|
||||
ref="reviewStepRef"
|
||||
:provider-name="providerName"
|
||||
:provider="provider"
|
||||
:selected-vms="selectedVMs"
|
||||
:network-map-name="networkMapName"
|
||||
:storage-map-name="storageMapName"
|
||||
:mapping-entries="stepData.mappings"
|
||||
:step-data="stepData.review"
|
||||
@ready="onReviewReady"
|
||||
/>
|
||||
</template>
|
||||
</CruResource>
|
||||
</template>
|
||||
Reference in New Issue
Block a user