feat(forklift): Fixed some UI changes

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-05-27 14:26:15 +02:00
parent de7598b7af
commit 1d26c48117
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
7 changed files with 281 additions and 226 deletions

View File

@ -2,11 +2,10 @@
import { ref, computed, watch } from 'vue';
import { useStore } from 'vuex';
import Loading from '@shell/components/Loading';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../types';
import MappingColumn from './MappingColumn.vue';
const props = defineProps({
providerName: { type: String, default: '' },
@ -268,7 +267,7 @@ const formatStorageDetail = (entry) => {
const buildNetworkMapSpec = (providerRef) => {
return {
map: networkEntries.value.map((entry) => {
map: networkEntries.value.filter((entry) => !!entry.target).map((entry) => {
if (entry.target === 'pod') {
return {
source: { name: entry.name, id: entry.id },
@ -302,7 +301,7 @@ const buildNetworkMapSpec = (providerRef) => {
const buildStorageMapSpec = (providerRef) => {
return {
map: storageEntries.value.map((entry) => ({
map: storageEntries.value.filter((entry) => !!entry.target).map((entry) => ({
source: { name: entry.name, id: entry.id },
destination: { storageClass: entry.target },
})),
@ -478,17 +477,24 @@ const init = async() => {
}
}
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
if (!hasExistingNetworkTargets) {
if (props.existingNetworkMap?.spec?.map) {
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
} else {
applyDefaultNetworkMap();
}
}
if (!hasExistingStorageTargets) {
if (props.existingStorageMap?.spec?.map) {
applyStorageMapTargets(props.existingStorageMap.spec.map);
} else {
applyDefaultStorageMap();
}
}
loading.value = false;
};
@ -502,114 +508,45 @@ init();
v-else
class="configure-mappings"
>
<p class="text-muted line-height-20">
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
</p>
<div class="mappings-columns">
<!-- Network Mapping -->
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.title') }}
</h3>
<p class="text-muted line-height-20">
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.description') }}
</p>
</div>
<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"
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
:entries="networkEntries"
:options="harvesterNetworkOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
:searchable="true"
/>
</div>
</div>
<div v-if="!useAllProviderData && entry.usedBy.length">
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
<!-- Storage Mapping -->
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.title') }}
</h3>
<p class="text-muted">
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.description') }}
</p>
</div>
<RcItemCard
v-for="entry in storageEntries"
:id="entry._key"
:key="entry._key"
:variant="'small'"
:header="{}"
class="bg-light-gray"
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<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"
<template #source-detail="{ entry }">
<span class="source-detail text-deemphasized">VLAN {{ entry.vlanId || '0' }}</span>
</template>
</MappingColumn>
<MappingColumn
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
:entries="storageEntries"
:options="storageClassOptions"
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
:searchable="true"
/>
</div>
</div>
<div v-if="!useAllProviderData && entry.usedBy.length">
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
:show-used-by="!useAllProviderData"
:clearable="useAllProviderData"
>
<template #source-detail="{ entry }">
<span
v-if="entry.type || entry.capacity"
class="source-detail text-deemphasized"
>{{ formatStorageDetail(entry) }}</span>
</template>
</RcItemCard>
</div>
</MappingColumn>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-columns {
display: flex;
flex-direction: column;
@ -629,85 +566,6 @@ init();
}
}
.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: 16px;
width: 100%;
}
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source {
display: flex;
flex-direction: column;
flex: 1;
min-width: 100px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
}
}
.mapping-arrow {
font-size: 22px;
flex-shrink: 0;
}
.mapping-target {
flex: 3;
min-width: 0;
overflow: hidden;
}
.line-height-20 {
line-height: 20px;
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
.configure-mappings {
display: flex;
flex-direction: column;
@ -718,4 +576,9 @@ init();
}
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
</style>

View File

@ -518,7 +518,7 @@ defineExpose({ testConnection, clickTestButton });
<template>
<div class="configure-provider-step">
<p class="text-muted line-height-20">
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
</p>
@ -567,7 +567,7 @@ defineExpose({ testConnection, clickTestButton });
:disabled="isExistingProvider && !editMode"
required
/>
<p class="text-muted mt-5">
<p class="text-deemphasized mt-5">
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
</p>
</div>
@ -598,7 +598,7 @@ defineExpose({ testConnection, clickTestButton });
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
:disabled="isExistingProvider && !editMode"
/>
<p class="text-muted ml-20">
<p class="text-deemphasized ml-20">
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
</p>
</div>
@ -670,5 +670,9 @@ defineExpose({ testConnection, clickTestButton });
gap: 20px;
}
}
:deep(.checkbox-label) {
color: var(--body-text);
}
}
</style>

View File

@ -0,0 +1,181 @@
<script setup>
import { computed } from 'vue';
import { useStore } from 'vuex';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import { RcItemCard } from '@components/RcItemCard';
import { useI18n } from '@shell/composables/useI18n';
const store = useStore();
const { t } = useI18n(store);
const props = defineProps({
title: { type: String, required: true },
description: { type: String, default: '' },
entries: { type: Array, default: () => [] },
options: { type: Array, default: () => [] },
placeholder: { type: String, default: '' },
showUsedBy: { type: Boolean, default: false },
clearable: { type: Boolean, default: false },
});
const selectOptions = computed(() => {
if (!props.clearable) {
return props.options;
}
return [
{
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
value: null,
kind: 'highlighted',
},
{
label: 'divider',
disabled: true,
kind: 'divider',
},
{
label: `${ props.placeholder }:`,
disabled: true,
kind: 'title',
},
...props.options,
];
});
</script>
<template>
<div class="mapping-column">
<div>
<h3 class="mapping-section-title">
{{ title }}
</h3>
<p
v-if="description"
class="text-deemphasized line-height-20"
>
{{ description }}
</p>
</div>
<RcItemCard
v-for="entry in entries"
: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>
<slot
name="source-detail"
:entry="entry"
/>
</div>
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
<i class="icon icon-right-arrow-alt" />
</div>
<div class="mapping-target">
<LabeledSelect
v-model:value="entry.target"
:options="selectOptions"
:placeholder="placeholder+'...'"
:searchable="true"
/>
</div>
</div>
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
<span class="used-by">
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
</span>
</div>
</div>
</template>
</RcItemCard>
</div>
</template>
<style lang="scss" scoped>
.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: 16px;
width: 100%;
}
.card-content-column {
display: flex;
flex-direction: column;
gap: 8px;
width: 100%;
}
.mapping-source {
display: flex;
flex-direction: column;
flex: 2;
min-width: 100px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
font-size: 16px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.source-detail {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.used-by {
margin-top: 4px;
font-size: 14px;
line-height: 24px;
}
}
.mapping-arrow {
font-size: 22px;
flex-shrink: 0;
}
.mapping-target {
flex: 3;
min-width: 0;
overflow: hidden;
}
.line-height-20 {
line-height: 20px;
}
.bg-light-gray {
background-color: var(--category-active) !important;
border: 0;
}
</style>

View File

@ -338,7 +338,7 @@ defineExpose({ startMigration: startMigrationAction });
<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-os text-deemphasized">{{ vm.os }}</span>
<span class="vm-resources">
<i class="icon icon-disk" />
{{ vm.cpus }} vCPU &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}

View File

@ -357,6 +357,8 @@ const refreshVMs = async() => {
emit('complete', { selectedVMs: selectedVMs.value });
refreshing.value = false;
await nextTick();
const table = sortableTableRef.value;
if (table) {
@ -366,6 +368,8 @@ const refreshVMs = async() => {
table.update(rowsToReselect, []);
}
}
skipNextSelectionEvent = false;
};
const init = async() => {
@ -403,7 +407,7 @@ init();
v-else
class="select-vms-step"
>
<p class="text-muted line-height-20">
<p class="text-deemphasized line-height-20">
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
<router-link
v-if="provider"
@ -446,18 +450,18 @@ init();
<h3 class="m-0">
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
</h3>
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
</div>
</template>
<template #cell:vmName="{ row }">
<div class="vm-name-cell">
<span class="vm-name">{{ row.vmName }}</span>
<span class="vm-id text-muted">{{ row.vmId }}</span>
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
</div>
</template>
<template #cell:resources="{ row }">
<span>{{ row.resourcesDisplay }}</span><br>
<span class="text-muted">{{ row.resourcesSub }}</span>
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
</template>
<template #cell:powerState="{ row }">
<BadgeState
@ -473,7 +477,7 @@ init();
:class="{ 'mt-4': i > 0 }"
>
<span>{{ net.name }}</span><br>
<span class="text-muted">{{ net.id }}</span>
<span class="text-deemphasized">{{ net.id }}</span>
</div>
</div>
</template>
@ -485,7 +489,7 @@ init();
:class="{ 'mt-4': i > 0 }"
>
<span>{{ ds.name }}</span><br>
<span class="text-muted">{{ ds.id }}</span>
<span class="text-deemphasized">{{ ds.id }}</span>
</div>
</div>
</template>
@ -531,7 +535,7 @@ init();
font-weight: 600;
}
.text-muted {
.text-deemphasized {
font-size: 13px;
}
}

View File

@ -1910,10 +1910,11 @@ harvester:
description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue
noTemplate: Start from scratch
removeMap: Remove Map
networkMapping:
title: Network Mapping
description: Map VMware port groups to Harvester networks
placeholder: Choose a Harvester network...
placeholder: Choose a Harvester network
template: Use existing network mapping as template
storageMapping:
title: Storage Mapping

View File

@ -132,7 +132,6 @@ export default class ForkliftPlan extends HarvesterResource {
}
get _availableActions() {
if (this.isForkliftDashboard) {
const canStop = this.isMigrating && !this.planCanceled;
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
const out = [];
@ -142,7 +141,7 @@ export default class ForkliftPlan extends HarvesterResource {
action: 'startMigration',
enabled: true,
icon: 'icon icon-play',
label: 'Start',
label: this.isForkliftDashboard ? 'Restart' : 'Start',
});
}
@ -155,6 +154,7 @@ export default class ForkliftPlan extends HarvesterResource {
});
}
if (this.isForkliftDashboard) {
out.push({
action: 'promptRemove',
altAction: 'remove',
@ -167,9 +167,11 @@ export default class ForkliftPlan extends HarvesterResource {
});
return out;
}
} else {
out.push(...super._availableActions);
return super._availableActions;
return out;
}
}
async stopMigration() {