mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-10-03 20:56:06 +08:00
feat: forklift UI (#891)
* feat: forklift poc first commit Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: add migration and plan detail pages with progress tracking Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): UI based on the FIGMA Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fixed config based on new figma changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): New changes added based on new figma Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Add the succeded state Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * Merge the new APIs Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * 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> * feat(forklift): Added icons, critical state, fix grids, action button Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Change to use the proper provider instead of a hardcoded Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Added pagination UI Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): FIxed the routing for the extension Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): route and router doesnt work properly on extensions Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): fixed to fix the icon on extension Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Added overflow hidden to mapping target list Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Added better handling on the saving and testing Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Remove test flags Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Changed based on review Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): fixed title and button Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Design changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Changed to Wizard Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): added the provider wizard Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fixed some UI changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Removed wrong configs used for testing Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): update size Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes after Copilot Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes after Copilot Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): reverted settings.json changes Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small fixes after Copilot Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fix the way it is registered Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): Fixed the type for VMware Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat(forklift): small changes before rebase Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: Added new API for the vms Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: Fix some labels Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: Added some final fixes to structure Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: added loading to provider steps Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> * feat: added comments from copilot review Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com> --------- Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
@@ -0,0 +1,439 @@
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import { Banner } from '@components/Banner';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { LabeledInput } from '@components/Form/LabeledInput';
|
||||
import MappingsCell from '../MappingsCell';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../types';
|
||||
import { FORKLIFT_NAMESPACE } from '../../config/harvester-map';
|
||||
import {
|
||||
FORKLIFT_API_VERSION, buildNetworkMapEntries, buildStorageMapEntries, bytesToGB, mbToGB
|
||||
} from '../../utils/forklift';
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
provider: { type: Object, default: null },
|
||||
selectedVms: { type: Array, default: () => [] },
|
||||
mappingEntries: { type: Object, default: null },
|
||||
stepData: { type: Object, required: true },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['ready']);
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const vms = ref([]);
|
||||
const networkMappings = ref([]);
|
||||
const storageMappings = ref([]);
|
||||
const planName = ref('');
|
||||
const errors = ref([]);
|
||||
const loading = ref(true);
|
||||
|
||||
// Restore persisted state
|
||||
planName.value = props.stepData.planName;
|
||||
|
||||
const NAMESPACE = FORKLIFT_NAMESPACE;
|
||||
const TARGET_NAMESPACE = 'default';
|
||||
|
||||
watch(planName, (val) => {
|
||||
props.stepData.planName = val;
|
||||
emit('ready', !!val);
|
||||
}, { immediate: true });
|
||||
|
||||
const totalVCpu = computed(() => vms.value.reduce((sum, vm) => sum + (vm.cpuCount || vm.numCPU || 0), 0));
|
||||
|
||||
const totalMemoryGB = computed(() => {
|
||||
const totalMB = vms.value.reduce((sum, vm) => sum + (vm.memoryMB || vm.memory || 0), 0);
|
||||
|
||||
return mbToGB(totalMB);
|
||||
});
|
||||
|
||||
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 bytesToGB(totalBytes);
|
||||
});
|
||||
|
||||
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 ? t('harvester.addons.vmMigration.generic.memoryGb', { value: mbToGB(memMB) }) : '-';
|
||||
|
||||
let totalDiskBytes = 0;
|
||||
|
||||
if (vm.disks && vm.disks.length > 0) {
|
||||
totalDiskBytes = vm.disks.reduce((sum, d) => sum + (d.capacity || 0), 0);
|
||||
}
|
||||
|
||||
const diskDisplay = totalDiskBytes ? t('harvester.addons.vmMigration.generic.memoryGb', { value: bytesToGB(totalDiskBytes) }) : '-';
|
||||
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 startMigrationAction = async() => {
|
||||
const inStore = store.getters['currentProduct'].inStore;
|
||||
|
||||
const providerRef = {
|
||||
source: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Provider',
|
||||
name: props.providerName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
destination: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Provider',
|
||||
name: 'host',
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
};
|
||||
|
||||
const networkMapName = `${ planName.value }-network-map`;
|
||||
const storageMapName = `${ planName.value }-storage-map`;
|
||||
|
||||
const networkMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_NETWORK_MAP,
|
||||
metadata: { name: networkMapName, namespace: NAMESPACE },
|
||||
spec: { map: buildNetworkMapEntries(props.mappingEntries?.networkEntries || [], NAMESPACE), provider: providerRef },
|
||||
});
|
||||
|
||||
await networkMap.save();
|
||||
|
||||
const storageMap = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_STORAGE_MAP,
|
||||
metadata: { name: storageMapName, namespace: NAMESPACE },
|
||||
spec: { map: buildStorageMapEntries(props.mappingEntries?.storageEntries || []), provider: providerRef },
|
||||
});
|
||||
|
||||
await storageMap.save();
|
||||
|
||||
const plan = await store.dispatch(`${ inStore }/create`, {
|
||||
type: HCI.FORKLIFT_PLAN,
|
||||
metadata: { name: planName.value, namespace: NAMESPACE },
|
||||
spec: {
|
||||
provider: providerRef,
|
||||
map: {
|
||||
network: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'NetworkMap',
|
||||
name: networkMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
storage: {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'StorageMap',
|
||||
name: storageMapName,
|
||||
namespace: NAMESPACE,
|
||||
},
|
||||
},
|
||||
targetNamespace: TARGET_NAMESPACE,
|
||||
vms: vms.value.map((vm) => ({ id: vm.id, name: vm.name || vm.id })),
|
||||
warm: false,
|
||||
},
|
||||
});
|
||||
|
||||
await plan.save();
|
||||
|
||||
const planOwnerRef = {
|
||||
apiVersion: FORKLIFT_API_VERSION,
|
||||
kind: 'Plan',
|
||||
name: plan.metadata.name,
|
||||
uid: plan.metadata.uid,
|
||||
blockOwnerDeletion: true,
|
||||
};
|
||||
|
||||
networkMap.metadata.ownerReferences = [planOwnerRef];
|
||||
await networkMap.save();
|
||||
storageMap.metadata.ownerReferences = [planOwnerRef];
|
||||
await storageMap.save();
|
||||
|
||||
// Kick off the first migration through the model so the Migration payload
|
||||
// lives in a single place (also reused by the dashboard start/restart action).
|
||||
await plan.startMigration();
|
||||
};
|
||||
|
||||
const init = () => {
|
||||
vms.value = props.selectedVms;
|
||||
|
||||
if (props.mappingEntries) {
|
||||
networkMappings.value = (props.mappingEntries.networkEntries || []).map((entry) => ({
|
||||
source: entry.name || entry.id,
|
||||
target: entry.target || '',
|
||||
usedBy: entry.usedBy || [],
|
||||
}));
|
||||
|
||||
storageMappings.value = (props.mappingEntries.storageEntries || []).map((entry) => ({
|
||||
source: entry.name || entry.id,
|
||||
target: entry.target || '',
|
||||
usedBy: entry.usedBy || [],
|
||||
}));
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
|
||||
init();
|
||||
|
||||
defineExpose({ startMigration: startMigrationAction });
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Loading v-if="loading" />
|
||||
<div
|
||||
v-else
|
||||
class="review-migration"
|
||||
>
|
||||
<div
|
||||
class="review-migration-content"
|
||||
>
|
||||
<!-- Migration Details Summary -->
|
||||
<div class="migration-details">
|
||||
<h3 class="section-title m-0">
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.migrationDetails') }}
|
||||
</h3>
|
||||
<div class="span-9">
|
||||
<LabeledInput
|
||||
v-model:value="planName"
|
||||
:label="t('harvester.addons.vmMigration.reviewMigration.planName')"
|
||||
:placeholder="t('harvester.addons.vmMigration.reviewMigration.planNamePlaceholder')"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div class="details-grid span-9">
|
||||
<div class="detail-column">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.totalVms') }}</span>
|
||||
<span class="detail-value detail-value-large">{{ vms.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-column">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.source') }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-value">{{ providerName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-column">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.targetNamespace') }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-value">{{ TARGET_NAMESPACE }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-column">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.vcpu') }}</span>
|
||||
<span class="detail-value">{{ totalVCpu }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.memory') }}</span>
|
||||
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalMemoryGB }) }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.storage') }}</span>
|
||||
<span class="detail-value">{{ t('harvester.addons.vmMigration.generic.memoryGb', { value: totalStorageGB }) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="detail-column grid-column-2">
|
||||
<div class="detail-item">
|
||||
<span class="detail-label">{{ t('harvester.addons.vmMigration.reviewMigration.migrationMode') }}</span>
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="detail-value">
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.coldMigration') }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Virtual Machines -->
|
||||
<div class="vm-section">
|
||||
<h3 class="section-title m-0">
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.virtualMachines') }} ({{ vms.length }})
|
||||
</h3>
|
||||
|
||||
<div class="vm-cards-grid">
|
||||
<RcItemCard
|
||||
v-for="vm in vmCards"
|
||||
:id="vm.id"
|
||||
:key="vm.id"
|
||||
:variant="'small'"
|
||||
:header="{ title: { text: vm.name } }"
|
||||
>
|
||||
<template #item-card-content>
|
||||
<div class="vm-card-content">
|
||||
<div class="vm-card-specs">
|
||||
<span class="vm-os text-deemphasized">{{ vm.os }}</span>
|
||||
<span class="vm-resources">
|
||||
<i
|
||||
class="icon icon-disk"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
{{ t('harvester.addons.vmMigration.generic.vCpu', { count: vm.cpus }) }} • {{ 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.vmMigration.reviewMigration.warningTitle') }}</span><br>
|
||||
{{ t('harvester.addons.vmMigration.reviewMigration.warningMessage') }}
|
||||
</div>
|
||||
</Banner>
|
||||
|
||||
<!-- Error banner -->
|
||||
<Banner
|
||||
v-for="(err, i) in errors"
|
||||
:key="i"
|
||||
color="error"
|
||||
:label="err"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.review-migration {
|
||||
gap: 36px;
|
||||
}
|
||||
|
||||
.review-migration-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.details-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
|
||||
grid-template-rows: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 12px clamp(64px, 8vw, 128px);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.migration-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
header {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.vm-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user