Marcelo Fukumoto 740ca94841
feat(forklift): Applied fix to select the proper data on network. Added condition to show critical
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
2026-07-07 13:36:45 +02:00

624 lines
17 KiB
Vue

<script setup>
import { ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
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';
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 route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
const vms = ref([]);
const harvesterNetworks = ref([]);
const storageClasses = ref([]);
const networkEntries = ref([]);
const storageEntries = ref([]);
const errors = ref([]);
const loading = ref(true);
const NAMESPACE = 'forklift';
const providerName = computed(() => route.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 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 = async() => {
const inStore = store.getters['currentProduct'].inStore;
const allProviders = store.getters[`${ inStore }/all`](HCI.FORKLIFT_PROVIDER) || [];
const provider = allProviders.find((p) => p.metadata.name === providerName.value && p.metadata.namespace === NAMESPACE);
if (provider) {
await provider.remove();
}
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: route.params.product,
cluster: route.params.cluster,
}
});
};
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 with all mapping data
const networkMappingsData = networkEntries.value.map((entry) => ({
source: entry.name,
target: entry.target === 'pod' ? 'Pod Networking' : entry.target === 'ignored' ? 'Ignored' : entry.target,
usedBy: entry.usedBy,
}));
const storageMappingsData = storageEntries.value.map((entry) => ({
source: entry.name,
target: entry.target,
usedBy: entry.usedBy,
}));
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
params: {
product: route.params.product,
cluster: route.params.cluster,
},
query: {
provider: providerName.value,
vms: route.query.vms,
networkMap: networkMap.metadata.name,
storageMap: storageMap.metadata.name,
networkMappings: JSON.stringify(networkMappingsData),
storageMappings: JSON.stringify(storageMappingsData),
}
});
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 {
await store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
} catch (e) {
// Provider may already be in store from previous step
}
const vmsParam = route.query.vms;
if (vmsParam) {
try {
const vmIds = JSON.parse(vmsParam);
const baseUrl = `https://forklift-apir.13.48.147.135.sslip.io/providers/vsphere/67e67481-48f1-4d9b-8fb6-b4c5c58d3232`;
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>
<RcItemCard
v-for="entry in networkEntries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
>
<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-upgrade-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>
<RcItemCard
v-for="entry in storageEntries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
>
<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-upgrade-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: 1fr 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;
transform: rotate(90deg);
}
.mapping-target {
flex: 1;
}
.actions-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
.line-height-20 {
line-height: 20px;
}
</style>