feat(forklift): UI based on the FIGMA

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-05-06 02:51:22 +02:00
parent 7d8d74ce17
commit 2259910be1
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
26 changed files with 17508 additions and 2543 deletions

View File

@ -0,0 +1,53 @@
<script setup>
defineProps({
networkEntries: {
type: Array,
default: () => [],
},
storageEntries: {
type: Array,
default: () => [],
},
});
</script>
<template>
<div class="mappings-cell">
<div
v-for="(entry, idx) in networkEntries"
:key="'net-' + idx"
class="mapping-entry"
>
<i class="icon icon-cluster" />
<span>{{ entry }}</span>
</div>
<div
v-for="(entry, idx) in storageEntries"
:key="'stor-' + idx"
class="mapping-entry"
>
<i class="icon icon-storage" />
<span>{{ entry }}</span>
</div>
</div>
</template>
<style lang="scss" scoped>
.mappings-cell {
display: flex;
flex-direction: column;
gap: 8px;
}
.mapping-entry {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
color: var(--muted);
.icon {
font-size: 16px;
}
}
</style>

View File

@ -1341,10 +1341,8 @@ export function init($plugin, store) {
AGE
]);
configureType(HCI.FORKLIFT_PROVIDER, {
resource: HCI.FORKLIFT_PROVIDER,
resourceDetail: HCI.FORKLIFT_PROVIDER,
resourceEdit: HCI.FORKLIFT_PROVIDER,
location: {
resource: HCI.FORKLIFT_PROVIDER,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PROVIDER }
}
@ -1352,7 +1350,7 @@ export function init($plugin, store) {
virtualType({
name: HCI.FORKLIFT_PROVIDER,
labelKey: 'harvester.addons.forklift.labels.provider',
group: 'forklift',
group: 'forklift::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1370,10 +1368,8 @@ export function init($plugin, store) {
AGE
]);
configureType(HCI.FORKLIFT_NETWORK_MAP, {
resource: HCI.FORKLIFT_NETWORK_MAP,
resourceDetail: HCI.FORKLIFT_NETWORK_MAP,
resourceEdit: HCI.FORKLIFT_NETWORK_MAP,
location: {
resource: HCI.FORKLIFT_NETWORK_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_NETWORK_MAP }
}
@ -1381,7 +1377,7 @@ export function init($plugin, store) {
virtualType({
name: HCI.FORKLIFT_NETWORK_MAP,
labelKey: 'harvester.addons.forklift.labels.networkMap',
group: 'forklift',
group: 'forklift::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1399,10 +1395,8 @@ export function init($plugin, store) {
AGE
]);
configureType(HCI.FORKLIFT_STORAGE_MAP, {
resource: HCI.FORKLIFT_STORAGE_MAP,
resourceDetail: HCI.FORKLIFT_STORAGE_MAP,
resourceEdit: HCI.FORKLIFT_STORAGE_MAP,
location: {
resource: HCI.FORKLIFT_STORAGE_MAP,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_STORAGE_MAP }
}
@ -1410,7 +1404,7 @@ export function init($plugin, store) {
virtualType({
name: HCI.FORKLIFT_STORAGE_MAP,
labelKey: 'harvester.addons.forklift.labels.storageMap',
group: 'forklift',
group: 'forklift::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1429,10 +1423,8 @@ export function init($plugin, store) {
AGE
]);
configureType(HCI.FORKLIFT_PLAN, {
resource: HCI.FORKLIFT_PLAN,
resourceDetail: HCI.FORKLIFT_PLAN,
resourceEdit: HCI.FORKLIFT_PLAN,
location: {
resource: HCI.FORKLIFT_PLAN,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_PLAN }
}
@ -1440,7 +1432,7 @@ export function init($plugin, store) {
virtualType({
name: HCI.FORKLIFT_PLAN,
labelKey: 'harvester.addons.forklift.labels.plan',
group: 'forklift',
group: 'forklift::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
@ -1457,10 +1449,8 @@ export function init($plugin, store) {
AGE
]);
configureType(HCI.FORKLIFT_MIGRATION, {
resource: HCI.FORKLIFT_MIGRATION,
resourceDetail: HCI.FORKLIFT_MIGRATION,
resourceEdit: HCI.FORKLIFT_MIGRATION,
location: {
resource: HCI.FORKLIFT_MIGRATION,
location: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
@ -1468,19 +1458,38 @@ export function init($plugin, store) {
virtualType({
name: HCI.FORKLIFT_MIGRATION,
labelKey: 'harvester.addons.forklift.labels.migration',
group: 'forklift',
group: 'forklift::Advanced',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
params: { resource: HCI.FORKLIFT_MIGRATION }
}
});
configureType('forklift-create', { subTypes: [HCI.FORKLIFT_PLAN] });
virtualType({
name: 'forklift-create',
labelKey: 'harvester.addons.forklift.labels.plan',
group: 'forklift',
namespaced: true,
route: {
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {}
}
});
// Enable SideNav based on Forklift Addon Status
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'forklift',
types: [
'forklift-create',
]
});
registerAddonSideNav(store, PRODUCT_NAME, {
addonName: ADD_ONS.FORKLIFT_OPERATOR,
resourceType: HCI.ADD_ONS,
navGroup: 'forklift::Advanced',
types: [
HCI.FORKLIFT_PROVIDER,
HCI.FORKLIFT_NETWORK_MAP,

View File

@ -286,7 +286,7 @@ export const FORKLIFT_PLAN_VM_COUNT = {
labelKey: 'harvester.tableHeaders.forkliftPlanVmCount',
value: 'spec.vms.length',
sort: 'spec.vms.length',
align: 'center',
align: 'left',
};
// Plan reference column in forklift.konveyor.io.migration list page

View File

@ -1,383 +0,0 @@
<script>
import CreateEditView from '@shell/mixins/create-edit-view';
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import LabelValue from '@shell/components/LabelValue';
import { Banner } from '@components/Banner';
import PercentageBar from '@shell/components/PercentageBar';
import BadgeState from '@components/BadgeState';
import { mapGetters } from 'vuex';
export default {
name: 'ForkliftMigrationDetail',
components: {
BadgeState,
Banner,
CruResource,
LabelValue,
PercentageBar,
Tabbed,
Tab,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
computed: {
...mapGetters({ t: 'i18n/t' }),
planName() {
return this.value.spec?.plan?.name || '-';
},
planNamespace() {
return this.value.spec?.plan?.namespace || '-';
},
migrationStarted() {
return this.value.status?.started || '-';
},
migrationCompleted() {
return this.value.status?.completed || '-';
},
migrationConditions() {
return this.value.status?.conditions || [];
},
overallStatus() {
const succeeded = this.migrationConditions.find((c) => c.type === 'Succeeded' && c.status === 'True');
const running = this.migrationConditions.find((c) => c.type === 'Running' && c.status === 'True');
const failed = this.migrationConditions.find((c) => c.type === 'Failed' && c.status === 'True');
if (succeeded) {
return { text: this.t('harvester.addons.forklift.detail.status.succeeded'), color: 'bg-success' };
}
if (failed) {
return { text: this.t('harvester.addons.forklift.detail.status.failed'), color: 'bg-error' };
}
if (running) {
return { text: this.t('harvester.addons.forklift.detail.status.running'), color: 'bg-info' };
}
return { text: this.t('harvester.addons.forklift.detail.status.pending'), color: 'bg-darker' };
},
vms() {
return this.value.status?.vms || [];
},
},
methods: {
pipelineSteps(vm) {
return vm.pipeline || [];
},
stepProgress(step) {
if (step.phase === 'Completed') {
return 100;
}
if (step.phase === 'Pending') {
return 0;
}
const total = step.progress?.total || 0;
const completed = step.progress?.completed || 0;
if (total === 0) {
return step.phase === 'Running' ? 50 : 0;
}
return Math.round((completed / total) * 100);
},
stepBadgeColor(phase) {
switch (phase) {
case 'Completed':
return 'bg-success';
case 'Running':
return 'bg-info';
case 'Pending':
return 'bg-darker';
default:
return 'bg-warning';
}
},
vmOverallProgress(vm) {
const steps = vm.pipeline || [];
if (steps.length === 0) {
return 0;
}
const completed = steps.filter((s) => s.phase === 'Completed').length;
return Math.round((completed / steps.length) * 100);
},
formatBytes(mb) {
if (mb >= 1024) {
return `${ (mb / 1024).toFixed(1) } GB`;
}
return `${ mb } MB`;
},
formatDiskProgress(step) {
const unit = step.annotations?.unit || 'MB';
const completed = step.progress?.completed || 0;
const total = step.progress?.total || 0;
if (unit === 'MB') {
return `${ this.formatBytes(completed) } / ${ this.formatBytes(total) }`;
}
return `${ completed } / ${ total }`;
},
},
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:can-yaml="false"
>
<div class="row mb-20">
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.detail.plan')"
:value="planName"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.detail.planNamespace')"
:value="planNamespace"
/>
</div>
<div class="col span-3">
<LabelValue :name="t('harvester.addons.forklift.detail.status.label')">
<template #value>
<BadgeState
:label="overallStatus.text"
:color="overallStatus.color"
/>
</template>
</LabelValue>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.detail.started')"
:value="migrationStarted"
/>
</div>
</div>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
v-for="(vm, vmIdx) in vms"
:key="vm.id"
:name="'vm-' + vmIdx"
:label="vm.name || vm.id"
:weight="100 - vmIdx"
>
<div class="row mb-20">
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.detail.vmId')"
:value="vm.id"
/>
</div>
<div class="col span-3">
<LabelValue :name="t('harvester.addons.forklift.detail.vmPhase')">
<template #value>
<BadgeState
:label="vm.phase"
:color="stepBadgeColor(vm.phase)"
/>
</template>
</LabelValue>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.detail.vmStarted')"
:value="vm.started || '-'"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.detail.vmCompleted')"
:value="vm.completed || '-'"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabelValue :name="t('harvester.addons.forklift.detail.overallProgress')">
<template #value>
<PercentageBar :value="vmOverallProgress(vm)" />
</template>
</LabelValue>
</div>
</div>
<h3 class="mb-10">
{{ t('harvester.addons.forklift.detail.pipelineSteps') }}
</h3>
<div class="pipeline">
<div
v-for="(step, stepIdx) in pipelineSteps(vm)"
:key="stepIdx"
class="pipeline-step"
>
<div class="step-header">
<div class="step-name">
<span class="step-index">{{ stepIdx + 1 }}.</span>
{{ step.name }}
</div>
<BadgeState
:label="step.phase"
:color="stepBadgeColor(step.phase)"
/>
</div>
<p class="text-muted step-description">
{{ step.description }}
</p>
<PercentageBar
:value="stepProgress(step)"
class="mb-5"
/>
<div
v-if="step.annotations && step.annotations.unit"
class="step-disk-progress text-muted"
>
{{ formatDiskProgress(step) }}
</div>
<div
v-if="step.tasks && step.tasks.length"
class="step-tasks mt-10"
>
<div
v-for="(task, taskIdx) in step.tasks"
:key="taskIdx"
class="task-row"
>
<span class="task-name text-muted">{{ task.name }}</span>
<span
v-if="task.progress"
class="task-progress text-muted"
>
{{ formatBytes(task.progress.completed || 0) }} / {{ formatBytes(task.progress.total || 0) }}
</span>
<BadgeState
v-if="task.phase"
:label="task.phase"
:color="stepBadgeColor(task.phase)"
/>
</div>
</div>
</div>
</div>
<Banner
v-if="vm.conditions && vm.conditions.length"
:color="vm.conditions[0].type === 'Succeeded' ? 'success' : 'info'"
:label="vm.conditions[0].message"
class="mt-20"
/>
</Tab>
</Tabbed>
</CruResource>
</template>
<style lang="scss" scoped>
.pipeline {
display: flex;
flex-direction: column;
gap: 15px;
}
.pipeline-step {
border: 1px solid var(--border);
border-radius: var(--border-radius);
padding: 15px;
}
.step-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.step-name {
font-weight: 600;
}
.step-index {
color: var(--muted);
margin-right: 5px;
}
.step-description {
font-size: 13px;
margin-bottom: 10px;
}
.step-disk-progress {
font-size: 12px;
text-align: right;
}
.step-tasks {
border-top: 1px solid var(--border);
padding-top: 8px;
}
.task-row {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
font-size: 12px;
.task-name {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-progress {
white-space: nowrap;
}
}
</style>

View File

@ -1,541 +0,0 @@
<script>
import CreateEditView from '@shell/mixins/create-edit-view';
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import LabelValue from '@shell/components/LabelValue';
import { Banner } from '@components/Banner';
import PercentageBar from '@shell/components/PercentageBar';
import BadgeState from '@components/BadgeState';
import { mapGetters } from 'vuex';
export default {
name: 'ForkliftPlanDetail',
components: {
BadgeState,
Banner,
CruResource,
LabelValue,
PercentageBar,
Tabbed,
Tab,
},
mixins: [CreateEditView],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
computed: {
...mapGetters({ t: 'i18n/t' }),
sourceProvider() {
return this.value.spec?.provider?.source?.name || '-';
},
destProvider() {
return this.value.spec?.provider?.destination?.name || '-';
},
targetNamespace() {
return this.value.spec?.targetNamespace || '-';
},
networkMapName() {
return this.value.spec?.map?.network?.name || '-';
},
storageMapName() {
return this.value.spec?.map?.storage?.name || '-';
},
plannedVMs() {
return this.value.spec?.vms || [];
},
planConditions() {
return this.value.status?.conditions || [];
},
overallStatus() {
const succeeded = this.planConditions.find((c) => c.type === 'Succeeded' && c.status === 'True');
const executing = this.planConditions.find((c) => c.type === 'Executing' && c.status === 'True');
const failed = this.planConditions.find((c) => c.type === 'Failed' && c.status === 'True');
if (succeeded) {
return { text: this.t('harvester.addons.forklift.detail.status.succeeded'), color: 'bg-success' };
}
if (failed) {
return { text: this.t('harvester.addons.forklift.detail.status.failed'), color: 'bg-error' };
}
if (executing) {
return { text: this.t('harvester.addons.forklift.detail.status.executing'), color: 'bg-info' };
}
return { text: this.t('harvester.addons.forklift.detail.status.ready'), color: 'bg-darker' };
},
currentMigration() {
return this.value.status?.migration || null;
},
currentMigrationVMs() {
return this.currentMigration?.vms || [];
},
migrationHistory() {
return this.currentMigration?.history || [];
},
hasMigrationInProgress() {
return this.currentMigration?.started && !this.currentMigration?.completed;
},
},
methods: {
vmOverallProgress(vm) {
const steps = vm.pipeline || [];
if (steps.length === 0) {
return 0;
}
const completed = steps.filter((s) => s.phase === 'Completed').length;
return Math.round((completed / steps.length) * 100);
},
stepProgress(step) {
if (step.phase === 'Completed') {
return 100;
}
if (step.phase === 'Pending') {
return 0;
}
const total = step.progress?.total || 0;
const completed = step.progress?.completed || 0;
if (total === 0) {
return step.phase === 'Running' ? 50 : 0;
}
return Math.round((completed / total) * 100);
},
stepBadgeColor(phase) {
switch (phase) {
case 'Completed':
return 'bg-success';
case 'Running':
return 'bg-info';
case 'Pending':
return 'bg-darker';
default:
return 'bg-warning';
}
},
formatBytes(mb) {
if (mb >= 1024) {
return `${ (mb / 1024).toFixed(1) } GB`;
}
return `${ mb } MB`;
},
formatDiskProgress(step) {
const unit = step.annotations?.unit || 'MB';
const completed = step.progress?.completed || 0;
const total = step.progress?.total || 0;
if (unit === 'MB') {
return `${ this.formatBytes(completed) } / ${ this.formatBytes(total) }`;
}
return `${ completed } / ${ total }`;
},
historyStatus(entry) {
const conditions = entry.conditions || [];
const succeeded = conditions.find((c) => c.type === 'Succeeded');
const executing = conditions.find((c) => c.type === 'Executing');
const failed = conditions.find((c) => c.type === 'Failed');
if (succeeded) {
return { text: this.t('harvester.addons.forklift.detail.status.succeeded'), color: 'bg-success' };
}
if (failed) {
return { text: this.t('harvester.addons.forklift.detail.status.failed'), color: 'bg-error' };
}
if (executing) {
return { text: this.t('harvester.addons.forklift.detail.status.executing'), color: 'bg-info' };
}
return { text: this.t('harvester.addons.forklift.detail.status.unknown'), color: 'bg-darker' };
},
},
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:can-yaml="false"
>
<!-- Plan Overview -->
<div class="row mb-20">
<div class="col span-3">
<LabelValue :name="t('harvester.addons.forklift.detail.status.label')">
<template #value>
<BadgeState
:label="overallStatus.text"
:color="overallStatus.color"
/>
</template>
</LabelValue>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.sourceProvider')"
:value="sourceProvider"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.destProvider')"
:value="destProvider"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.targetNamespace')"
:value="targetNamespace"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.networkMap')"
:value="networkMapName"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.storageMap')"
:value="storageMapName"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.detail.vmCount')"
:value="String(plannedVMs.length)"
/>
</div>
</div>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<!-- Current Migration Tab -->
<Tab
name="current"
:label="t('harvester.addons.forklift.detail.tabs.currentMigration')"
:weight="3"
>
<template v-if="currentMigration">
<div class="row mb-20">
<div class="col span-4">
<LabelValue
:name="t('harvester.addons.forklift.detail.migrationName')"
:value="currentMigration.history && currentMigration.history.length ? currentMigration.history[currentMigration.history.length - 1].migration.name : '-'"
/>
</div>
<div class="col span-4">
<LabelValue
:name="t('harvester.addons.forklift.detail.started')"
:value="currentMigration.started || '-'"
/>
</div>
<div class="col span-4">
<LabelValue
:name="t('harvester.addons.forklift.detail.completed')"
:value="currentMigration.completed || '-'"
/>
</div>
</div>
<!-- VM Progress -->
<div
v-for="vm in currentMigrationVMs"
:key="vm.id"
class="vm-progress-card mb-20"
>
<div class="vm-card-header">
<div class="vm-card-title">
<span class="vm-name">{{ vm.name || vm.id }}</span>
<span
v-if="vm.name"
class="text-muted ml-10"
>({{ vm.id }})</span>
</div>
<BadgeState
:label="vm.phase"
:color="stepBadgeColor(vm.phase)"
/>
</div>
<div class="row mb-10">
<div class="col span-12">
<PercentageBar :value="vmOverallProgress(vm)" />
</div>
</div>
<div class="pipeline-steps">
<div
v-for="(step, idx) in (vm.pipeline || [])"
:key="idx"
class="step-row"
>
<div class="step-info">
<span class="step-num">{{ idx + 1 }}.</span>
<span class="step-name">{{ step.name }}</span>
<span class="text-muted step-desc">{{ step.description }}</span>
</div>
<div class="step-status">
<span
v-if="step.annotations && step.annotations.unit"
class="text-muted step-data mr-10"
>
{{ formatDiskProgress(step) }}
</span>
<BadgeState
:label="step.phase"
:color="stepBadgeColor(step.phase)"
/>
</div>
<PercentageBar
:value="stepProgress(step)"
class="step-bar"
/>
</div>
</div>
<Banner
v-if="vm.conditions && vm.conditions.length"
:color="vm.conditions[0].type === 'Succeeded' ? 'success' : 'info'"
:label="vm.conditions[0].message"
class="mt-10"
/>
</div>
</template>
<Banner
v-else
color="info"
:label="t('harvester.addons.forklift.detail.noCurrentMigration')"
/>
</Tab>
<!-- Migration History Tab -->
<Tab
name="history"
:label="t('harvester.addons.forklift.detail.tabs.history')"
:weight="2"
>
<template v-if="migrationHistory.length">
<div
v-for="(entry, idx) in migrationHistory"
:key="idx"
class="history-entry mb-15"
>
<div class="history-header">
<div>
<strong>{{ entry.migration?.name || t('harvester.addons.forklift.detail.migrationRun', { idx: idx + 1 }) }}</strong>
</div>
<BadgeState
:label="historyStatus(entry).text"
:color="historyStatus(entry).color"
/>
</div>
<div class="history-details">
<div class="row">
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.sourceProvider')"
:value="entry.provider?.source?.name || '-'"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.destProvider')"
:value="entry.provider?.destination?.name || '-'"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.networkMap')"
:value="entry.map?.network?.name || '-'"
/>
</div>
<div class="col span-3">
<LabelValue
:name="t('harvester.addons.forklift.fields.storageMap')"
:value="entry.map?.storage?.name || '-'"
/>
</div>
</div>
<Banner
v-if="entry.conditions && entry.conditions.length"
:color="entry.conditions[0].type === 'Succeeded' ? 'success' : entry.conditions[0].type === 'Failed' ? 'error' : 'info'"
:label="entry.conditions[0].message"
class="mt-10"
/>
</div>
</div>
</template>
<Banner
v-else
color="info"
:label="t('harvester.addons.forklift.detail.noHistory')"
/>
</Tab>
<!-- Planned VMs Tab -->
<Tab
name="vms"
:label="t('harvester.addons.forklift.titles.vms')"
:weight="1"
>
<table class="sortable-table">
<thead>
<tr>
<th>{{ t('harvester.addons.forklift.fields.vmId') }}</th>
<th>{{ t('harvester.addons.forklift.fields.vmName') }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="(vm, idx) in plannedVMs"
:key="idx"
>
<td>{{ vm.id }}</td>
<td>{{ vm.name || '-' }}</td>
</tr>
</tbody>
</table>
</Tab>
</Tabbed>
</CruResource>
</template>
<style lang="scss" scoped>
.vm-progress-card {
border: 1px solid var(--border);
border-radius: var(--border-radius);
padding: 15px;
}
.vm-card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.vm-card-title {
font-size: 15px;
font-weight: 600;
}
.pipeline-steps {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 15px;
}
.step-row {
border: 1px solid var(--border);
border-radius: var(--border-radius);
padding: 10px 12px;
}
.step-info {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 5px;
}
.step-num {
color: var(--muted);
font-weight: 600;
min-width: 20px;
}
.step-name {
font-weight: 600;
}
.step-desc {
font-size: 12px;
}
.step-status {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 5px;
}
.step-data {
font-size: 12px;
}
.step-bar {
margin-top: 2px;
}
.history-entry {
border: 1px solid var(--border);
border-radius: var(--border-radius);
padding: 15px;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.sortable-table {
width: 100%;
border-collapse: collapse;
th, td {
padding: 8px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
}
}
</style>

View File

@ -1,119 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { HCI } from '../types';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftMigration',
emits: ['update:value'],
components: {
CruResource,
LabeledSelect,
NameNsDescription,
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allPlans = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
},
data() {
if (!this.value.spec) {
this.value.spec = { plan: {} };
}
return {
allPlans: [],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
planOptions() {
return this.allPlans.map((p) => ({
label: p.metadata.name,
value: p.metadata.name,
}));
},
fvExtraRules() {
return { nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined };
},
isFormValid() {
return this.fvFormIsValid && !!this.value.spec.plan?.name;
}
},
methods: {
updatePlan(val) {
const plan = this.allPlans.find((p) => p.metadata.name === val);
this.value.spec.plan = {
name: val,
namespace: plan?.metadata?.namespace || this.value.metadata.namespace || 'default',
};
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<div class="row mb-20 mt-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.plan.name"
:options="planOptions"
:label="t('harvester.addons.forklift.fields.plan')"
:mode="mode"
required
@update:value="updatePlan"
/>
</div>
</div>
</CruResource>
</template>

View File

@ -1,199 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import ForkliftProvider from '../mixins/forklift-provider';
import { HCI } from '../types';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftNetworkMap',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
},
mixins: [CreateEditView, FormValidation, ForkliftProvider],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
},
data() {
if (!this.value.spec) {
this.value.spec = {
map: [{ source: { id: '' }, destination: { type: 'pod' } }],
provider: {
source: {},
destination: {},
},
};
}
return {
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
destinationTypeOptions() {
return [
{ label: this.t('harvester.addons.forklift.fields.destNetworkPod'), value: 'pod' },
{ label: this.t('harvester.addons.forklift.fields.destNetworkMultus'), value: 'multus' },
];
},
fvExtraRules() {
return { nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined };
},
isFormValid() {
return this.fvFormIsValid &&
!!this.value.spec.provider?.source?.name &&
!!this.value.spec.provider?.destination?.name;
}
},
methods: {
addMapping() {
this.value.spec.map.push({ source: { id: '' }, destination: { type: 'pod' } });
},
removeMapping(idx) {
this.value.spec.map.splice(idx, 1);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="providers"
:label="t('harvester.addons.forklift.titles.providers')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.source.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.sourceProvider')"
:mode="mode"
required
@update:value="updateSourceProvider"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.destination.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.destProvider')"
:mode="mode"
required
@update:value="updateDestProvider"
/>
</div>
</div>
</Tab>
<Tab
name="mappings"
:label="t('harvester.addons.forklift.titles.networkMappings')"
:weight="2"
>
<div
v-for="(entry, idx) in value.spec.map"
:key="idx"
class="row mb-20"
>
<div class="col span-5">
<LabeledInput
v-model:value="entry.source.id"
:label="t('harvester.addons.forklift.fields.sourceNetworkId')"
:placeholder="t('harvester.addons.forklift.placeholders.sourceNetworkId')"
:mode="mode"
/>
</div>
<div class="col span-5">
<LabeledSelect
v-model:value="entry.destination.type"
:options="destinationTypeOptions"
:label="t('harvester.addons.forklift.fields.destNetworkType')"
:mode="mode"
/>
</div>
<div class="col span-2">
<button
type="button"
class="btn role-link"
:disabled="value.spec.map.length <= 1"
@click="removeMapping(idx)"
>
{{ t('harvester.addons.forklift.actions.remove') }}
</button>
</div>
</div>
<button
type="button"
class="btn role-tertiary"
@click="addMapping"
>
{{ t('harvester.addons.forklift.actions.addMapping') }}
</button>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -1,303 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { Checkbox } from '@components/Form/Checkbox';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import ForkliftProvider from '../mixins/forklift-provider';
import { HCI } from '../types';
import { FORKLIFT_API_VERSION } from '../config/harvester-map';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftPlan',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
Checkbox,
},
mixins: [CreateEditView, FormValidation, ForkliftProvider],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
this.allNetworkMaps = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
this.allStorageMaps = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
},
data() {
if (!this.value.spec) {
this.value.spec = {
map: {
network: {},
storage: {},
},
provider: {
source: {},
destination: {},
},
targetNamespace: 'default',
migrateSharedDisks: true,
vms: [{ id: '', name: '' }],
};
}
if (!this.value.metadata.annotations) {
this.value.metadata.annotations = {};
}
this.value.metadata.annotations.populatorLabels = 'True';
return {
allProviders: [],
allNetworkMaps: [],
allStorageMaps: [],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.targetNamespace', rules: ['targetNsRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
providerOptions() {
return this.allProviders.map((p) => ({
label: `${ p.metadata.name } (${ p.spec?.type || 'unknown' })`,
value: p.metadata.name,
}));
},
networkMapOptions() {
return this.allNetworkMaps.map((m) => ({
label: m.metadata.name,
value: m.metadata.name,
}));
},
storageMapOptions() {
return this.allStorageMaps.map((m) => ({
label: m.metadata.name,
value: m.metadata.name,
}));
},
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
targetNsRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.targetNamespace') }) : undefined,
};
},
isFormValid() {
return this.fvFormIsValid &&
!!this.value.spec.provider?.source?.name &&
!!this.value.spec.provider?.destination?.name;
}
},
methods: {
addVm() {
this.value.spec.vms.push({ id: '', name: '' });
},
removeVm(idx) {
this.value.spec.vms.splice(idx, 1);
},
updateNetworkMap(val) {
this.value.spec.map.network = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'NetworkMap',
name: val,
namespace: this.value.metadata.namespace || 'default',
};
},
updateStorageMap(val) {
this.value.spec.map.storage = {
apiVersion: FORKLIFT_API_VERSION,
kind: 'StorageMap',
name: val,
namespace: this.value.metadata.namespace || 'default',
};
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="providers"
:label="t('harvester.addons.forklift.titles.providers')"
:weight="4"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.source.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.sourceProvider')"
:mode="mode"
required
@update:value="updateSourceProvider"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.destination.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.destProvider')"
:mode="mode"
required
@update:value="updateDestProvider"
/>
</div>
</div>
</Tab>
<Tab
name="mappings"
:label="t('harvester.addons.forklift.titles.mappings')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.map.network.name"
:options="networkMapOptions"
:label="t('harvester.addons.forklift.fields.networkMap')"
:mode="mode"
required
@update:value="updateNetworkMap"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.map.storage.name"
:options="storageMapOptions"
:label="t('harvester.addons.forklift.fields.storageMap')"
:mode="mode"
required
@update:value="updateStorageMap"
/>
</div>
</div>
</Tab>
<Tab
name="vms"
:label="t('harvester.addons.forklift.titles.vms')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="value.spec.targetNamespace"
:label="t('harvester.addons.forklift.fields.targetNamespace')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.targetNamespace')"
required
/>
</div>
<div class="col span-6">
<Checkbox
v-model:value="value.spec.migrateSharedDisks"
:label="t('harvester.addons.forklift.fields.migrateSharedDisks')"
:mode="mode"
/>
</div>
</div>
<div
v-for="(vm, idx) in value.spec.vms"
:key="idx"
class="row mb-20"
>
<div class="col span-5">
<LabeledInput
v-model:value="vm.id"
:label="t('harvester.addons.forklift.fields.vmId')"
:placeholder="t('harvester.addons.forklift.placeholders.vmId')"
:mode="mode"
/>
</div>
<div class="col span-5">
<LabeledInput
v-model:value="vm.name"
:label="t('harvester.addons.forklift.fields.vmName')"
:placeholder="t('harvester.addons.forklift.placeholders.vmName')"
:mode="mode"
/>
</div>
<div class="col span-2">
<button
type="button"
class="btn role-link"
:disabled="value.spec.vms.length <= 1"
@click="removeVm(idx)"
>
{{ t('harvester.addons.forklift.actions.remove') }}
</button>
</div>
</div>
<button
type="button"
class="btn role-tertiary"
@click="addVm"
>
{{ t('harvester.addons.forklift.actions.addVm') }}
</button>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -1,346 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import { RadioGroup } from '@components/Form/Radio';
import { Checkbox } from '@components/Form/Checkbox';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import { SECRET } from '@shell/config/types';
import { randomStr } from '@shell/utils/string';
import { mapGetters } from 'vuex';
const PROVIDER_TYPES = [
{ labelKey: 'harvester.addons.forklift.fields.providerTypeVsphere', value: 'vsphere' },
{ labelKey: 'harvester.addons.forklift.fields.providerTypeOpenstack', value: 'openstack' },
{ labelKey: 'harvester.addons.forklift.fields.providerTypeOpenshift', value: 'openshift' },
];
export default {
name: 'EditForkliftProvider',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
RadioGroup,
Checkbox,
},
mixins: [CreateEditView, FormValidation],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allSecrets = await this.$store.dispatch(`${ inStore }/findAll`, { type: SECRET });
},
data() {
if (!this.value.spec) {
this.value.spec = {
type: 'vsphere',
url: '',
secret: {},
};
}
const initialMode = this.value.spec.secret?.name ? 'existing' : 'new';
return {
allSecrets: [],
authMode: initialMode,
newUsername: '',
newPassword: '',
newCaCert: '',
skipTlsVerify: true,
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
{ path: 'spec.url', rules: ['urlRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
providerTypes() {
return PROVIDER_TYPES.map((pt) => ({
label: this.t(pt.labelKey),
value: pt.value,
}));
},
authModeOptions() {
return [
{ label: this.t('harvester.addons.forklift.fields.createSecret'), value: 'new' },
{ label: this.t('harvester.addons.forklift.fields.useSecret'), value: 'existing' }
];
},
secretOptions() {
const currentNamespace = this.value.metadata.namespace || 'default';
return this.allSecrets
.filter((s) => s.metadata.namespace === currentNamespace)
.map((s) => ({
label: s.nameDisplay,
value: s.metadata.name
}));
},
fvExtraRules() {
return {
nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined,
urlRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.url') }) : undefined,
};
},
isFormValid() {
if (!this.fvFormIsValid) {
return false;
}
// Local openshift provider doesn't need auth or URL
if (this.value.spec.type === 'openshift') {
return true;
}
if (this.authMode === 'new') {
if (!this.newUsername || !this.newPassword) {
return false;
}
} else if (!this.value.spec.secret?.name) {
return false;
}
return true;
}
},
methods: {
usernameRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.username') }) : undefined;
},
passwordRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.password') }) : undefined;
},
secretRule(val) {
return !val ? this.t('validation.required', { key: this.t('harvester.addons.forklift.fields.selectSecret') }) : undefined;
},
async saveProvider(buttonCb) {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
// Local openshift provider uses empty secret
if (this.value.spec.type === 'openshift') {
this.value.spec.secret = {};
this.value.spec.url = '';
await this.save(buttonCb);
return;
}
if (this.authMode === 'new') {
const secretName = `${ this.value.metadata.name }-creds-${ randomStr(4).toLowerCase() }`;
const namespace = this.value.metadata.namespace || 'default';
const newSecret = await this.$store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: {
createdForProviderType: this.value.spec.type,
createdForResourceType: 'providers',
}
}
});
newSecret['_type'] = 'Opaque';
newSecret['data'] = {
user: btoa(this.newUsername),
password: btoa(this.newPassword),
insecureSkipVerify: btoa(String(this.skipTlsVerify)),
url: btoa(this.value.spec.url),
};
if (this.newCaCert) {
newSecret.data.cacert = btoa(this.newCaCert);
}
await newSecret.save();
this.value.spec.secret = {
name: secretName,
namespace,
};
}
await this.save(buttonCb);
} catch (err) {
this.errors = [err];
buttonCb(false);
}
}
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="saveProvider"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="basic"
:label="t('harvester.addons.forklift.titles.basic')"
:weight="2"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.type"
:options="providerTypes"
:label="t('harvester.addons.forklift.fields.providerType')"
:mode="mode"
required
/>
</div>
<div
v-if="value.spec.type !== 'openshift'"
class="col span-6"
>
<LabeledInput
v-model:value="value.spec.url"
:label="t('harvester.addons.forklift.fields.url')"
:placeholder="t('harvester.addons.forklift.placeholders.url')"
:mode="mode"
:rules="fvGetAndReportPathRules('spec.url')"
required
/>
</div>
</div>
</Tab>
<Tab
v-if="value.spec.type !== 'openshift'"
name="auth"
:label="t('harvester.addons.forklift.titles.auth')"
:weight="1"
>
<div class="row mb-20">
<div class="col span-12">
<RadioGroup
v-model:value="authMode"
name="authMode"
:options="authModeOptions"
:mode="mode"
/>
</div>
</div>
<div v-if="authMode === 'new'">
<div class="row mb-20">
<div class="col span-6">
<LabeledInput
v-model:value="newUsername"
:label="t('harvester.addons.forklift.fields.username')"
:mode="mode"
:rules="[usernameRule]"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="newPassword"
type="password"
:label="t('harvester.addons.forklift.fields.password')"
:mode="mode"
:rules="[passwordRule]"
required
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<LabeledInput
v-model:value="newCaCert"
type="multiline"
:label="t('harvester.addons.forklift.fields.caCert')"
:placeholder="t('harvester.addons.forklift.placeholders.caCert')"
:min-height="100"
:mode="mode"
/>
</div>
</div>
<div class="row mb-20">
<div class="col span-12">
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.forklift.fields.insecureSkipVerify')"
:mode="mode"
/>
</div>
</div>
</div>
<div v-if="authMode === 'existing'">
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
v-model:value="value.spec.secret.name"
:options="secretOptions"
:label="t('harvester.addons.forklift.fields.selectSecret')"
:mode="mode"
:rules="[secretRule]"
required
/>
</div>
</div>
</div>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -1,208 +0,0 @@
<script>
import CruResource from '@shell/components/CruResource';
import Tabbed from '@shell/components/Tabbed';
import Tab from '@shell/components/Tabbed/Tab';
import { LabeledInput } from '@components/Form/LabeledInput';
import LabeledSelect from '@shell/components/form/LabeledSelect';
import NameNsDescription from '@shell/components/form/NameNsDescription';
import CreateEditView from '@shell/mixins/create-edit-view';
import FormValidation from '@shell/mixins/form-validation';
import ForkliftProvider from '../mixins/forklift-provider';
import { HCI } from '../types';
import { STORAGE_CLASS } from '@shell/config/types';
import { mapGetters } from 'vuex';
export default {
name: 'EditForkliftStorageMap',
emits: ['update:value'],
components: {
CruResource,
Tabbed,
Tab,
LabeledInput,
LabeledSelect,
NameNsDescription,
},
mixins: [CreateEditView, FormValidation, ForkliftProvider],
inheritAttrs: false,
props: {
value: {
type: Object,
required: true,
},
mode: {
type: String,
required: true,
},
},
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
try {
this.allStorageClasses = await this.$store.dispatch(`${ inStore }/findAll`, { type: STORAGE_CLASS });
} catch {
this.allStorageClasses = [];
}
},
data() {
if (!this.value.spec) {
this.value.spec = {
map: [{ source: { id: '' }, destination: { storageClass: '' } }],
provider: {
source: {},
destination: {},
},
};
}
return {
allStorageClasses: [],
fvFormRuleSets: [
{ path: 'metadata.name', rules: ['nameRequired'] },
],
};
},
computed: {
...mapGetters({ t: 'i18n/t' }),
storageClassOptions() {
return this.allStorageClasses.map((sc) => ({
label: sc.metadata.name,
value: sc.metadata.name,
}));
},
fvExtraRules() {
return { nameRequired: (val) => !val ? this.t('validation.required', { key: this.t('harvester.fields.name') }) : undefined };
},
isFormValid() {
return this.fvFormIsValid &&
!!this.value.spec.provider?.source?.name &&
!!this.value.spec.provider?.destination?.name;
}
},
methods: {
addMapping() {
this.value.spec.map.push({ source: { id: '' }, destination: { storageClass: '' } });
},
removeMapping(idx) {
this.value.spec.map.splice(idx, 1);
},
}
};
</script>
<template>
<CruResource
:done-route="doneRoute"
:resource="value"
:mode="mode"
:errors="errors"
:apply-hooks="applyHooks"
:validation-passed="isFormValid"
@finish="save"
@error="e=>errors=e"
>
<NameNsDescription
:value="value"
:mode="mode"
:rules="{ name: fvGetAndReportPathRules('metadata.name') }"
@update:value="$emit('update:value', $event)"
/>
<Tabbed
v-bind="$attrs"
class="mt-15"
:side-tabs="true"
>
<Tab
name="providers"
:label="t('harvester.addons.forklift.titles.providers')"
:weight="3"
>
<div class="row mb-20">
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.source.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.sourceProvider')"
:mode="mode"
required
@update:value="updateSourceProvider"
/>
</div>
<div class="col span-6">
<LabeledSelect
:value="value.spec.provider.destination.name"
:options="providerOptions"
:label="t('harvester.addons.forklift.fields.destProvider')"
:mode="mode"
required
@update:value="updateDestProvider"
/>
</div>
</div>
</Tab>
<Tab
name="mappings"
:label="t('harvester.addons.forklift.titles.storageMappings')"
:weight="2"
>
<div
v-for="(entry, idx) in value.spec.map"
:key="idx"
class="row mb-20"
>
<div class="col span-5">
<LabeledInput
v-model:value="entry.source.id"
:label="t('harvester.addons.forklift.fields.sourceDatastoreId')"
:placeholder="t('harvester.addons.forklift.placeholders.sourceDatastoreId')"
:mode="mode"
/>
</div>
<div class="col span-5">
<LabeledSelect
v-model:value="entry.destination.storageClass"
:options="storageClassOptions"
:label="t('harvester.addons.forklift.fields.destStorageClass')"
:mode="mode"
/>
</div>
<div class="col span-2">
<button
type="button"
class="btn role-link"
:disabled="value.spec.map.length <= 1"
@click="removeMapping(idx)"
>
{{ t('harvester.addons.forklift.actions.remove') }}
</button>
</div>
</div>
<button
type="button"
class="btn role-tertiary"
@click="addMapping"
>
{{ t('harvester.addons.forklift.actions.addMapping') }}
</button>
</Tab>
</Tabbed>
</CruResource>
</template>

View File

@ -1809,86 +1809,94 @@ harvester:
forklift:
labels:
dashboard: Create
provider: Providers
networkMap: Network Maps
storageMap: Storage Maps
plan: Migration Plans
migration: Migrations
titles:
basic: Basic
auth: Authentication
providers: Providers
networkMappings: Network Mappings
storageMappings: Storage Mappings
mappings: Mappings
vms: Virtual Machines
fields:
providerType: Provider Type
providerTypeVsphere: vSphere
providerTypeOpenstack: OpenStack
providerTypeOpenshift: OpenShift / Harvester (Local)
url: URL
username: Username
password: Password
caCert: CA Certificate (PEM)
insecureSkipVerify: Skip TLS Certificate Verification
selectSecret: Select Secret
createSecret: Create New Credentials
useSecret: Use Existing Secret
sourceProvider: Source Provider
destProvider: Destination Provider
sourceNetworkId: Source Network ID
destNetworkType: Destination Network Type
destNetworkPod: Pod Network
destNetworkMultus: Multus
sourceDatastoreId: Source Datastore ID
destStorageClass: Destination Storage Class
networkMap: Network Map
storageMap: Storage Map
configureProvider:
title: Configure Provider
description: Connect to your VMware vCenter or ESXi host to discover virtual machines for migration
connectionDetails: Connection Details
requirementsTitle: Requirements
requirementsText: "To migrate virtual machines from VMWare, you will need a vCenter Server running version 6.5 or higher with a user account that has at least read permissions, and the source environment must be network-accessible from Rancher."
name: Name
urlLabel: vCenter/ESXi URL
urlPlaceholder: "https://vcenter.example.com/sdk"
urlHint: Enter the full URL including https://
skipSsl: Skip SSL certificate verification
skipSslHint: Not recommended for production environments
testConnection: Test Connection
testSuccess: Connection test passed
testFailed: Connection test failed
testMissingFields: Please fill in all required fields before testing
testTimeout: Connection test timed out — the provider did not report a status in time
save: Save Provider and Continue
selectVms:
title: Select Virtual Machines
discovered: "{count} VMs discovered from"
lastSynced: Last synced 5 minutes ago
availableVms: Available Virtual Machines
selected: selected
saveSelection: Save Selection and Continue
manualEntry:
title: Add Virtual Machines
description: No VMs were discovered from the provider inventory. Add VMs manually by entering their IDs and names.
vmId: VM ID
vmIdPlaceholder: "e.g. vm-60527"
add: Add
added: VMs added
columns:
vmName: VM Name
vmId: VM ID
os: Operating System
resources: Resources
powerState: Power State
network: Network
datastore: Datastore
dashboard:
title: VMware to Harvester Migration
description: Migrate virtual machines from VMware vSphere to Harvester using cold migration for data consistency. Create a migration plan to select VMs, map networks and storage, and prepare a migration to Harvester.
createPlan: Create Migration Plan
tableTitle: Migration Plans
columns:
status: Status
plan: Migration Plan
progress: Progress
mappings: Mappings
configureMappings:
title: Set Mappings
description: Map VMware networks and datastores to Harvester and Longhorn target resources
save: Save Mappings and Continue
networkMapping:
title: Network Mapping
description: Map VMware port groups to Harvester networks
placeholder: Choose a Harvester network...
storageMapping:
title: Storage Mapping
description: Map VMware datastores to Harvester storage classes
placeholder: Choose a Longhorn Storage Volume
reviewMigration:
title: Review Migration Plan
description: Confirm your migration settings before starting the transfer of VMs to the target cluster.
migrationDetails: Migration Details
totalVms: Total VMs
vcpu: vCPU
memory: Memory
storage: Storage
source: Source
targetNamespace: Target Namespace
migrateSharedDisks: Migrate Shared Disks
vmId: VM ID
vmName: VM Name
plan: Migration Plan
placeholders:
url: "e.g. https://vcenter/sdk"
caCert: "-----BEGIN CERTIFICATE----- ..."
sourceNetworkId: "e.g. dvportgroup-13025"
sourceDatastoreId: "e.g. datastore-2040"
vmId: "e.g. vm-13007"
vmName: "e.g. my-vm"
actions:
addMapping: Add Mapping
addVm: Add VM
remove: Remove
detail:
plan: Plan
planNamespace: Plan Namespace
started: Started
completed: Completed
vmId: VM ID
vmPhase: Phase
vmStarted: Started
vmCompleted: Completed
vmCount: VMs in Plan
overallProgress: Overall Progress
pipelineSteps: Pipeline Steps
migrationName: Migration
migrationRun: "Migration #{idx}"
noCurrentMigration: No migration is currently running for this plan.
noHistory: No migration history available.
status:
label: Status
succeeded: Succeeded
failed: Failed
running: Running
executing: Executing
pending: Pending
ready: Ready
unknown: Unknown
tabs:
currentMigration: Current Migration
history: Migration History
migrationMode: Migration Mode
coldMigration: Cold Migration
vmsWillShutDown: VMs will shut down
virtualMachines: Virtual Machines
warningTitle: "Cold Migration Warning"
warningMessage: "All source VMs will be powered off, and downtime will occur during migration. Plan your maintenance window accordingly and notify relevant stakeholders"
startMigration: Start Migration Plan
vmImport:
titles:

View File

@ -1,60 +0,0 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_MIGRATION,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_MIGRATION,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_MIGRATION },
};
export default {
name: 'HarvesterForkliftMigration',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_MIGRATION);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_MIGRATION, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -1,60 +0,0 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_NETWORK_MAP,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_NETWORK_MAP,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_NETWORK_MAP },
};
export default {
name: 'HarvesterForkliftNetworkMap',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_NETWORK_MAP);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_NETWORK_MAP, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -1,60 +0,0 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_PLAN,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PLAN,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PLAN },
};
export default {
name: 'HarvesterForkliftPlan',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PLAN });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_PLAN);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_PLAN, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -1,60 +0,0 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_PROVIDER,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PROVIDER,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PROVIDER },
};
export default {
name: 'HarvesterForkliftProvider',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_PROVIDER);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_PROVIDER, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -1,60 +0,0 @@
<script>
import ResourceTable from '@shell/components/ResourceTable';
import Loading from '@shell/components/Loading';
import { SCHEMA } from '@shell/config/types';
import { HCI } from '../types';
const schema = {
id: HCI.FORKLIFT_STORAGE_MAP,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_STORAGE_MAP,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_STORAGE_MAP },
};
export default {
name: 'HarvesterForkliftStorageMap',
components: { ResourceTable, Loading },
inheritAttrs: false,
async fetch() {
const inStore = this.$store.getters['currentProduct'].inStore;
this.rows = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP });
const configSchema = this.$store.getters[`${ inStore }/schemaFor`](HCI.FORKLIFT_STORAGE_MAP);
if (!configSchema?.collectionMethods.find((x) => x.toLowerCase() === 'post')) {
this.$store.dispatch('type-map/configureType', { match: HCI.FORKLIFT_STORAGE_MAP, isCreatable: false });
}
},
data() {
return { rows: [] };
},
computed: {
schema() {
return schema;
}
},
typeDisplay() {
return this.$store.getters['type-map/labelFor'](schema, 99);
}
};
</script>
<template>
<Loading v-if="$fetchState.pending" />
<ResourceTable
v-else
v-bind="$attrs"
:groupable="true"
:schema="schema"
:rows="rows"
key-field="_key"
/>
</template>

View File

@ -1,47 +0,0 @@
import { FORKLIFT_API_VERSION } from '../config/harvester-map';
/**
* Shared mixin for Forklift edit pages that need to select
* source and destination providers from available Provider CRs.
*
* Components using this mixin must load providers in their own fetch():
* this.allProviders = await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_PROVIDER });
*/
export default {
data() {
return { allProviders: [] };
},
computed: {
providerOptions() {
return this.allProviders.map((p) => ({
label: `${ p.metadata.name } (${ p.spec?.type || 'unknown' })`,
value: p.metadata.name,
}));
},
},
methods: {
/**
* Build a Forklift object reference for a Provider.
*/
buildProviderRef(name) {
const provider = this.allProviders.find((p) => p.metadata.name === name);
return {
apiVersion: FORKLIFT_API_VERSION,
kind: 'Provider',
name,
namespace: provider?.metadata?.namespace || this.value.metadata.namespace || 'default',
};
},
updateSourceProvider(val) {
this.value.spec.provider.source = this.buildProviderRef(val);
},
updateDestProvider(val) {
this.value.spec.provider.destination = this.buildProviderRef(val);
},
},
};

View File

@ -0,0 +1,556 @@
<script>
import { defineComponent, 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';
import vmData from '../../../../utils/vms.json';
const schema = {
id: HCI.FORKLIFT_NETWORK_MAP,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_NETWORK_MAP,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_NETWORK_MAP },
};
export default defineComponent({
name: 'ForkliftConfigureMappings',
components: {
Loading,
Masthead,
AsyncButton,
LabeledSelect,
RcItemCard,
},
setup() {
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 providerName = computed(() => route.query.provider || 'vsphere');
const namespace = computed(() => route.query.namespace || 'default');
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 entries = [];
vms.value.forEach((vm) => {
if (vm.networks && vm.networks.length > 0) {
vm.networks.forEach((net) => {
entries.push({
vmId: vm.id,
vmName: vm.name || vm.id,
name: net.name || 'Unknown',
id: net.id || '',
vlanId: net.vlanId || '',
target: 'pod',
_key: `net-${ vm.id }-${ net.id || net.name }`,
});
});
} else {
entries.push({
vmId: vm.id,
vmName: vm.name || vm.id,
name: 'Default Network',
id: '',
vlanId: '',
target: 'pod',
_key: `net-${ vm.id }-default`,
});
}
});
networkEntries.value = entries;
};
const buildStorageEntries = () => {
const entries = [];
vms.value.forEach((vm) => {
if (vm.disks && vm.disks.length > 0) {
const seen = new Set();
vm.disks.forEach((disk) => {
const ds = disk.datastore;
if (ds && ds.id && !seen.has(ds.id)) {
seen.add(ds.id);
entries.push({
vmId: vm.id,
vmName: vm.name || vm.id,
name: ds.name || 'Unknown',
id: ds.id || '',
type: ds.type || '',
capacity: disk.capacity || 0,
target: 'harvester-longhorn',
_key: `stor-${ vm.id }-${ ds.id }`,
});
}
});
} else {
entries.push({
vmId: vm.id,
vmName: vm.name || vm.id,
name: 'Default Datastore',
id: '',
type: '',
capacity: 0,
target: 'harvester-longhorn',
_key: `stor-${ vm.id }-default`,
});
}
});
storageEntries.value = entries;
};
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 = () => {
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.value,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: 'forklift',
},
};
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' },
};
}
return {
source: { name: entry.name, id: entry.id },
destination: {
type: 'multus',
name: entry.target,
namespace: namespace.value,
},
};
}),
provider: providerRef,
};
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.value,
},
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.value,
},
spec: storageMapSpec,
});
await storageMap.save();
// Navigate to review page with all mapping data
const networkMappingsData = networkEntries.value.map((entry) => ({
vmId: entry.vmId,
source: entry.name,
target: entry.target === 'pod' ? 'Pod Networking' : entry.target === 'ignored' ? 'Ignored' : entry.target,
}));
const storageMappingsData = storageEntries.value.map((entry) => ({
vmId: entry.vmId,
source: entry.name,
target: entry.target,
}));
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
params: {
product: route.params.product,
cluster: route.params.cluster,
},
query: {
provider: providerName.value,
namespace: namespace.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 = [];
}
const vmsParam = route.query.vms;
if (vmsParam) {
try {
const vmIds = JSON.parse(vmsParam);
vms.value = vmIds.map((id) => {
const found = vmData.find((vm) => vm.id === id);
return found || {
id, name: id, networks: [], disks: []
};
});
} catch (e) {
vms.value = [];
}
}
buildNetworkEntries();
buildStorageEntries();
loading.value = false;
};
init();
return {
schema,
loading,
networkEntries,
storageEntries,
errors,
harvesterNetworkOptions,
storageClassOptions,
canSave,
t,
formatStorageDetail,
cancel,
saveMappings,
};
},
});
</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="{ title: { text: entry.vmId }, statuses: entry.target ? [{ icon: 'icon-notify-tick', color: 'text-success' }] : [] }"
>
<template #item-card-content>
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<span class="source-id text-muted">{{ entry.id }}</span>
<span class="source-detail text-muted">VLAN {{ entry.vlanId || '0' }}</span>
</div>
<div class="mapping-arrow">
</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>
</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="{ title: { text: entry.vmId }, statuses: entry.target ? [{ icon: 'icon-notify-tick', color: 'text-success' }] : [] }"
>
<template #item-card-content>
<div class="card-content-row">
<div class="mapping-source">
<span class="source-name">{{ entry.name }}</span>
<span class="source-id text-muted">{{ entry.id }}</span>
<span
v-if="entry.type || entry.capacity"
class="source-detail text-muted"
>{{ formatStorageDetail(entry) }}</span>
</div>
<div class="mapping-arrow">
</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>
</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;
}
.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%;
}
.mapping-source {
display: flex;
flex-direction: column;
min-width: 160px;
font-size: 14px;
line-height: 20px;
.source-name {
font-weight: 600;
}
}
.mapping-arrow {
color: var(--muted);
font-size: 16px;
flex-shrink: 0;
}
.mapping-target {
flex: 1;
}
.actions-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
.line-height-20 {
line-height: 20px;
}
</style>

View File

@ -0,0 +1,477 @@
<script>
import { defineComponent, ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRoute, useRouter } from 'vue-router';
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 { 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';
const schema = {
id: HCI.FORKLIFT_PROVIDER,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PROVIDER,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PROVIDER },
};
export default defineComponent({
name: 'ForkliftConfigureProvider',
components: {
LabeledInput,
Checkbox,
Banner,
Masthead,
AsyncButton,
},
setup() {
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
const allSecrets = ref([]);
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 isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
const cancel = () => {
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: route.params.product,
cluster: route.params.cluster,
}
});
};
const testConnection = async(buttonCb) => {
testResult.value = null;
testError.value = null;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.forklift.configureProvider.testMissingFields');
buttonCb(false);
return;
}
const inStore = store.getters['currentProduct'].inStore;
try {
if (createdProvider.value) {
await createdProvider.value.remove();
createdProvider.value = null;
}
if (createdSecret.value) {
await createdSecret.value.remove();
createdSecret.value = null;
}
const namespace = 'default';
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: {
createdForProviderType: 'vsphere',
createdForResourceType: 'providers',
}
}
});
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 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;
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) {
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess');
buttonCb(true);
} else {
if (createdProvider.value) {
await createdProvider.value.remove();
createdProvider.value = null;
}
if (createdSecret.value) {
await createdSecret.value.remove();
createdSecret.value = null;
}
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout');
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');
buttonCb(false);
}
};
const saveProvider = async(buttonCb) => {
const inStore = store.getters['currentProduct'].inStore;
errors.value = [];
try {
if (createdProvider.value) {
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
params: {
product: route.params.product,
cluster: route.params.cluster,
},
query: {
provider: providerName.value,
namespace: createdProvider.value.metadata.namespace,
}
});
return;
}
const namespace = 'default';
const secretName = `${ providerName.value }-creds-${ randomStr(4).toLowerCase() }`;
const newSecret = await store.dispatch(`${ inStore }/create`, {
type: SECRET,
metadata: {
name: secretName,
namespace,
labels: {
createdForProviderType: 'vsphere',
createdForResourceType: 'providers',
}
}
});
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();
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();
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
params: {
product: route.params.product,
cluster: route.params.cluster,
},
query: {
provider: providerName.value,
namespace,
}
});
} catch (err) {
errors.value = [err.message || err];
buttonCb(false);
}
};
const init = async() => {
const inStore = store.getters['currentProduct'].inStore;
allSecrets.value = await store.dispatch(`${ inStore }/findAll`, { type: SECRET });
loading.value = false;
};
init();
return {
schema,
allSecrets,
providerName,
url,
username,
password,
skipTlsVerify,
testResult,
testError,
errors,
loading,
isFormValid,
t,
cancel,
testConnection,
saveProvider,
};
},
});
</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">
<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')"
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')"
required
/>
</div>
<div class="col span-6">
<LabeledInput
v-model:value="password"
type="password"
:label="t('harvester.addons.forklift.fields.password')"
required
/>
</div>
</div>
<div class="mb-20">
<Checkbox
v-model:value="skipTlsVerify"
:label="t('harvester.addons.forklift.configureProvider.skipSsl')"
/>
<p class="text-muted ml-20">
{{ t('harvester.addons.forklift.configureProvider.skipSslHint') }}
</p>
</div>
<div class="mb-20">
<AsyncButton
mode="test"
: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"
:action-label="t('harvester.addons.forklift.configureProvider.save')"
:waiting-label="t('harvester.addons.forklift.configureProvider.save')"
:success-label="t('harvester.addons.forklift.configureProvider.save')"
:error-label="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>

View File

@ -0,0 +1,342 @@
<script>
import { defineComponent, ref, computed } from 'vue';
import { useStore } from 'vuex';
import { useRoute } from 'vue-router';
import Loading from '@shell/components/Loading';
import Masthead from '@shell/components/ResourceList/Masthead';
import ResourceTable from '@shell/components/ResourceTable';
import PercentageBar from '@shell/components/PercentageBar';
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 { FORKLIFT_PLAN_VM_COUNT } from '../../../../config/table-headers';
import { STATE, NAME as NAME_COL, AGE } from '@shell/config/table-headers';
const schema = {
id: HCI.FORKLIFT_PLAN,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PLAN,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PLAN },
};
export default defineComponent({
name: 'ForkliftMigrationDashboard',
components: {
Loading,
Masthead,
MappingsCell,
PercentageBar,
ResourceTable,
},
setup() {
const store = useStore();
const route = useRoute();
const { t } = useI18n(store);
const loading = ref(true);
const inStore = computed(() => store.getters['currentProduct'].inStore);
const allPlans = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_PLAN));
const allNetworkMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_NETWORK_MAP));
const allStorageMaps = computed(() => store.getters[`${ inStore.value }/all`](HCI.FORKLIFT_STORAGE_MAP));
const rows = computed(() => {
return allPlans.value.map((plan) => {
const netMapName = plan.spec?.map?.network?.name;
const netMapNs = plan.spec?.map?.network?.namespace || plan.metadata.namespace;
const storMapName = plan.spec?.map?.storage?.name;
const storMapNs = plan.spec?.map?.storage?.namespace || plan.metadata.namespace;
const netMap = allNetworkMaps.value.find((m) => m.metadata.name === netMapName && m.metadata.namespace === netMapNs);
const storMap = allStorageMaps.value.find((m) => m.metadata.name === storMapName && m.metadata.namespace === storMapNs);
plan.networkEntries = (netMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' }${ e.destination?.type === 'pod' ? 'Pod Network' : (e.destination?.name || '-') }`);
plan.storageEntries = (storMap?.spec?.map || []).map((e) => `${ e.source?.id || '-' }${ e.destination?.storageClass || '-' }`);
plan.networkDisplay = plan.networkEntries.join(', ') || '-';
plan.storageDisplay = plan.storageEntries.join(', ') || '-';
plan.vmIdsDisplay = (plan.spec?.vms || []).map((vm) => vm.id || vm.name || '').filter(Boolean).join(', ') || '-';
const vms = plan.status?.migration?.vms || [];
plan.vmProgress = vms.map((vm) => {
const pipeline = vm.pipeline || [];
const totalSteps = pipeline.length;
const completedSteps = pipeline.filter((s) => s.phase === 'Completed').length;
return {
name: vm.id || vm.name || 'Unknown',
steps: pipeline.map((step) => ({
name: step.name || 'Unknown',
progress: step.phase === 'Completed' ? 100 : (step.progress?.completed && step.progress?.total ? Math.round((step.progress.completed / step.progress.total) * 100) : 0),
hasError: !!step.error,
reason: (step.error?.reasons || []).join('; ') || '',
})),
progress: totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0,
};
});
let totalSteps = 0;
let completedSteps = 0;
vms.forEach((vm) => {
const pipeline = vm.pipeline || [];
totalSteps += pipeline.length;
completedSteps += pipeline.filter((s) => s.phase === 'Completed').length;
});
plan.progress = totalSteps > 0 ? Math.round((completedSteps / totalSteps) * 100) : 0;
return plan;
});
});
const createLocation = computed(() => ({
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
params: {
product: route.params.product,
cluster: route.params.cluster,
}
}));
const headers = [
{ ...STATE, labelKey: 'harvester.addons.forklift.dashboard.columns.status' },
{
...NAME_COL,
labelKey: 'harvester.addons.forklift.dashboard.columns.plan',
subLabel: 'VM IDs',
},
{ ...FORKLIFT_PLAN_VM_COUNT, width: 105 },
{
name: 'progress',
labelKey: 'harvester.addons.forklift.dashboard.columns.progress',
value: 'progress',
width: 299,
},
{
name: 'mappings',
labelKey: 'harvester.addons.forklift.dashboard.columns.mappings',
value: 'mappingsDisplay',
width: 387,
},
{ ...AGE },
];
const init = async() => {
await Promise.all([
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_PLAN }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
store.dispatch(`${ inStore.value }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
loading.value = false;
};
init();
return {
schema,
loading,
rows,
createLocation,
headers,
t,
};
},
});
</script>
<template>
<Loading v-if="loading" />
<div v-else>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.forklift.dashboard.title')"
>
<template #subHeader>
<div class="mmt-5">
<p class="text-muted">
{{ t('harvester.addons.forklift.dashboard.description') }}
</p>
</div>
</template>
<template #createButton>
<router-link
:to="createLocation"
class="btn role-primary"
>
{{ t('harvester.addons.forklift.dashboard.createPlan') }}
</router-link>
</template>
</Masthead>
<ResourceTable
:schema="schema"
:rows="rows"
:headers="headers"
:groupable="false"
:table-actions="false"
:search="false"
key-field="_key"
>
<template #header-left>
<h3 class="table-title m-0">
{{ t('harvester.addons.forklift.dashboard.tableTitle') }}
</h3>
</template>
<template #cell:name="{ row }">
<div class="plan-name-cell">
<div class="plan-name">
{{ row.metadata.name }}
</div>
<div class="plan-vms text-muted">
{{ row.vmIdsDisplay }}
</div>
</div>
</template>
<template #cell:vmCount="{ row }">
{{ (row.spec.vms || []).length }} VMs
</template>
<template #cell:progress="{ row }">
<div
v-if="row.vmProgress && row.vmProgress.length"
class="progress-steps"
>
<div
v-for="vm in row.vmProgress"
:key="vm.name"
class="vm-progress"
>
<div class="vm-name">
{{ vm.name }}
</div>
<div
v-for="step in vm.steps"
:key="step.name"
class="progress-step"
>
<span
class="step-name"
:class="{ 'text-error': step.hasError }"
>
{{ step.name }}
</span>
<PercentageBar
:model-value="step.progress"
:color-stops="step.hasError ? { 0: '--error' } : { 99: '--primary', 100: '--success' }"
preferred-direction="MORE"
class="step-bar"
/>
<span class="step-pct">{{ step.progress }}%</span>
</div>
<div
v-for="step in vm.steps.filter(s => s.hasError)"
:key="`error-${step.name}`"
class="step-error-msg"
>
<span class="text-error">{{ step.reason }}</span>
</div>
</div>
</div>
<span v-else>-</span>
</template>
<template #cell:mappings="{ row }">
<MappingsCell
:network-entries="row.networkEntries"
:storage-entries="row.storageEntries"
/>
</template>
</ResourceTable>
</div>
</template>
<style lang="scss" scoped>
.plan-name-cell {
.plan-name {
font-weight: 600;
line-height: 20px;
}
.plan-vms {
font-size: 12px;
line-height: 20px;
color: var(--muted);
}
}
.progress-steps {
display: flex;
flex-direction: column;
gap: 8px;
}
.vm-progress {
&:not(:last-child) {
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
}
.vm-name {
font-weight: 600;
font-size: 12px;
margin-bottom: 4px;
}
.progress-step {
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 4px;
padding-right: 13px;
}
.step-error-msg {
margin-top: 4px;
font-size: 12px;
.text-error {
color: var(--error);
}
}
.step-name {
font-size: 12px;
width: 110px;
min-width: 110px;
max-width: 110px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
&.text-error {
color: var(--error);
font-weight: 600;
}
}
.step-bar {
flex: 1;
min-width: 80px;
}
.step-pct {
font-size: 12px;
min-width: 35px;
text-align: left;
}
.table-title {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,544 @@
<script>
import { defineComponent, 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 { Banner } from '@components/Banner';
import { RcItemCard } from '@components/RcItemCard';
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 vmData from '../../../../utils/vms.json';
const schema = {
id: HCI.FORKLIFT_PLAN,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PLAN,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PLAN },
};
export default defineComponent({
name: 'ForkliftReviewMigration',
components: {
Loading,
Masthead,
AsyncButton,
Banner,
MappingsCell,
RcItemCard,
},
setup() {
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
const vms = ref([]);
const networkMappings = ref([]);
const storageMappings = ref([]);
const errors = ref([]);
const loading = ref(true);
const providerName = computed(() => route.query.provider || 'vsphere');
const namespace = computed(() => route.query.namespace || 'default');
const networkMapName = computed(() => route.query.networkMap || '');
const storageMapName = computed(() => route.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.vmId === vm.id);
const vmStorageMappings = storageMappings.value.filter((m) => m.vmId === vm.id);
return {
id: vm.id,
name: vm.name || vm.id,
os,
cpus,
memGB,
diskDisplay,
networkMappings: vmNetworkMappings,
storageMappings: vmStorageMappings,
};
});
});
const cancel = () => {
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: route.params.product,
cluster: route.params.cluster,
}
});
};
const startMigration = async(buttonCb) => {
const inStore = store.getters['currentProduct'].inStore;
try {
const planName = `${ providerName.value }-plan-${ Math.random().toString(36).substring(2, 7) }`;
const planSpec = {
provider: {
source: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: providerName.value,
namespace: namespace.value,
},
destination: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Provider',
name: 'host',
namespace: 'forklift',
},
},
map: {
network: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'NetworkMap',
name: networkMapName.value,
namespace: namespace.value,
},
storage: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'StorageMap',
name: storageMapName.value,
namespace: namespace.value,
},
},
targetNamespace: namespace.value,
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,
namespace: namespace.value,
},
spec: planSpec,
});
await plan.save();
const migrationName = `${ planName }-migration-${ Math.random().toString(36).substring(2, 7) }`;
const migration = await store.dispatch(`${ inStore }/create`, {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: migrationName,
namespace: namespace.value,
},
spec: {
plan: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: planName,
namespace: namespace.value,
},
},
});
await migration.save();
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: route.params.product,
cluster: route.params.cluster,
}
});
buttonCb(true);
} catch (err) {
errors.value = [err.message || err];
buttonCb(false);
}
};
const init = async() => {
const vmsParam = route.query.vms;
if (vmsParam) {
try {
const vmIds = JSON.parse(vmsParam);
vms.value = vmIds.map((id) => {
const found = vmData.find((vm) => vm.id === id);
return found || {
id, name: id, networks: [], disks: [], cpuCount: 0, memoryMB: 0, guestName: ''
};
});
} catch (e) {
vms.value = [];
}
}
const networkMappingsParam = route.query.networkMappings;
if (networkMappingsParam) {
try {
networkMappings.value = JSON.parse(networkMappingsParam);
} catch (e) {
networkMappings.value = [];
}
}
const storageMappingsParam = route.query.storageMappings;
if (storageMappingsParam) {
try {
storageMappings.value = JSON.parse(storageMappingsParam);
} catch (e) {
storageMappings.value = [];
}
}
loading.value = false;
};
init();
return {
schema,
loading,
vms,
errors,
providerName,
namespace,
totalVCpu,
totalMemoryGB,
totalStorageGB,
vmCards,
t,
cancel,
startMigration,
};
},
});
</script>
<template>
<Loading v-if="loading" />
<div
v-else
class="review-migration"
>
<Masthead
:schema="schema"
:resource="schema.id"
:type-display="t('harvester.addons.forklift.reviewMigration.title')"
:is-creatable="false"
>
<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">
{{ t('harvester.addons.forklift.reviewMigration.migrationDetails') }}
</h3>
<div class="details-grid">
<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">{{ 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
class="ml-10 small-text"
>{{ t('harvester.addons.forklift.reviewMigration.vmsWillShutDown') }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- Virtual Machines -->
<div class="vm-section">
<h3 class="section-title">
{{ 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-dock" />
{{ vm.cpus }} vCPU &bull; {{ vm.memGB }} &bull; {{ vm.diskDisplay }}
</span>
</div>
<MappingsCell
:network-entries="vm.networkMappings.map(m => `${m.source} → ${m.target}`)"
:storage-entries="vm.storageMappings.map(m => `${m.source} → ${m.target}`)"
/>
</div>
</template>
</RcItemCard>
</div>
</div>
<!-- Cold Migration Warning -->
<Banner
color="warning"
class="mt-20"
>
<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"
class="mt-10"
/>
<!-- Actions -->
<div class="actions-footer">
<button
class="btn role-secondary"
@click="cancel"
>
{{ t('generic.cancel') }}
</button>
<AsyncButton
: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;
}
.section-title {
font-weight: 600;
margin: 0 0 15px 0;
}
.migration-details {
margin-bottom: 30px;
}
.details-grid {
display: grid;
grid-template-columns: minmax(230px, 1fr) 1fr 1fr;
grid-template-rows: 1fr 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-section {
margin-top: 30px;
}
.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;
margin-top: 30px;
}
.banner-title {
font-weight: 600;
}
</style>

View File

@ -0,0 +1,518 @@
<script>
import { defineComponent, 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 SortableTable from '@shell/components/SortableTable';
import { LabeledInput } from '@components/Form/LabeledInput';
import { SCHEMA } from '@shell/config/types';
import { useI18n } from '@shell/composables/useI18n';
import { HCI } from '../../../../types';
import { PRODUCT_NAME } from '../../../../config/harvester';
import vmData from '../../../../utils/vms.json';
const schema = {
id: HCI.FORKLIFT_PROVIDER,
type: SCHEMA,
attributes: {
kind: HCI.FORKLIFT_PROVIDER,
namespaced: true
},
metadata: { name: HCI.FORKLIFT_PROVIDER },
};
export default defineComponent({
name: 'ForkliftSelectVms',
components: {
Loading,
Masthead,
SortableTable,
LabeledInput,
},
setup() {
const store = useStore();
const route = useRoute();
const router = useRouter();
const { t } = useI18n(store);
const allProviders = ref([]);
const provider = ref(null);
const discoveredVMs = ref([]);
const selectedVMs = ref([]);
const manualVMs = ref([]);
const newVmId = ref('');
const tableRows = ref([]);
const loading = ref(true);
const providerName = computed(() => provider.value?.metadata?.name || route.query.provider || '');
const vmCount = computed(() => discoveredVMs.value.length);
const showManualEntry = computed(() => discoveredVMs.value.length === 0);
const selectedCount = computed(() => showManualEntry.value ? manualVMs.value.length : selectedVMs.value.length);
const manualHeaders = [
{
name: 'id',
labelKey: 'harvester.addons.forklift.selectVms.columns.vmId',
value: 'id',
},
];
const headers = [
{
name: 'vmName',
labelKey: 'harvester.addons.forklift.selectVms.columns.vmName',
value: 'vmName',
subLabel: 'Identifier',
},
{
name: 'os',
labelKey: 'harvester.addons.forklift.selectVms.columns.os',
value: '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',
},
{
name: 'network',
labelKey: 'harvester.addons.forklift.selectVms.columns.network',
value: 'network',
subLabel: 'Identifier',
},
{
name: 'datastore',
labelKey: 'harvester.addons.forklift.selectVms.columns.datastore',
value: '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 powerState = vm.powerState || vm.status?.phase || '-';
let networks = [];
if (vm.networks && vm.networks.length > 0) {
networks = vm.networks.map((n) => ({
name: n.name || 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 || 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 addManualVm = () => {
if (!newVmId.value) {
return;
}
manualVMs.value.push({
id: newVmId.value.trim(),
name: newVmId.value.trim(),
_key: `manual-${ newVmId.value.trim() }`,
});
newVmId.value = '';
};
const removeManualVm = (index) => {
manualVMs.value.splice(index, 1);
};
const onSelect = (rows) => {
selectedVMs.value = rows.map((r) => r._original);
};
const cancel = () => {
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
params: {
product: route.params.product,
cluster: route.params.cluster,
}
});
};
const saveSelection = () => {
let vmIds;
if (showManualEntry.value) {
vmIds = manualVMs.value.map((vm) => vm.id);
} else {
vmIds = selectedVMs.value.map((vm) => vm.id || vm.vmId || vm.metadata?.name);
}
router.push({
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
params: {
product: route.params.product,
cluster: route.params.cluster,
},
query: {
provider: providerName.value,
namespace: provider.value?.metadata?.namespace || 'default',
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 = route.query.provider;
const namespace = route.query.namespace || 'default';
if (queryProvider) {
provider.value = allProviders.value.find(
(p) => p.metadata.name === queryProvider && p.metadata.namespace === namespace
);
}
if (provider.value) {
try {
const inventoryType = 'forklift-inventory.konveyor.io.vspherevm';
discoveredVMs.value = await store.dispatch(`${ inStore }/findAll`, { type: inventoryType });
} catch (e) {
discoveredVMs.value = [];
}
}
if (discoveredVMs.value.length === 0) {
discoveredVMs.value = vmData;
}
tableRows.value = buildTableRows();
loading.value = false;
};
init();
return {
schema,
loading,
provider,
providerName,
discoveredVMs,
selectedVMs,
manualVMs,
newVmId,
tableRows,
vmCount,
showManualEntry,
selectedCount,
manualHeaders,
headers,
t,
addManualVm,
removeManualVm,
onSelect,
cancel,
saveSelection,
};
},
});
</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>
<!-- Manual VM entry when no discovered VMs -->
<div v-if="showManualEntry">
<h3 class="m-0 mb-10">
{{ t('harvester.addons.forklift.selectVms.manualEntry.title') }}
</h3>
<p class="text-muted mb-20">
{{ t('harvester.addons.forklift.selectVms.manualEntry.description') }}
</p>
<div class="manual-vm-form">
<div class="row">
<div class="col span-6">
<LabeledInput
v-model:value="newVmId"
:label="t('harvester.addons.forklift.selectVms.manualEntry.vmId')"
:placeholder="t('harvester.addons.forklift.selectVms.manualEntry.vmIdPlaceholder')"
/>
</div>
<div class="col span-2 add-btn-col">
<button
class="btn role-primary"
:disabled="!newVmId"
@click="addManualVm"
>
{{ t('harvester.addons.forklift.selectVms.manualEntry.add') }}
</button>
</div>
</div>
</div>
<SortableTable
:rows="manualVMs"
:headers="manualHeaders"
:search="false"
:table-actions="false"
:row-actions="false"
:groupable="false"
key-field="_key"
class="mt-20"
>
<template #header-left>
<span class="text-muted">{{ manualVMs.length }} {{ t('harvester.addons.forklift.selectVms.manualEntry.added') }}</span>
</template>
<template #cell:id="{ row }">
<div class="manual-vm-row">
<span>{{ row.id }}</span>
<button
class="btn btn-sm role-link"
@click="removeManualVm(manualVMs.indexOf(row))"
>
<i class="icon icon-close" />
</button>
</div>
</template>
</SortableTable>
</div>
<!-- Discovered VMs table -->
<SortableTable
v-else
:rows="tableRows"
:headers="headers"
:search="true"
:table-actions="true"
:row-actions="false"
:groupable="false"
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 }">
<span
class="power-badge"
:class="row.powerStateClass"
>{{ row.powerState }}</span>
</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>
<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;
.vm-name {
font-weight: 600;
line-height: 20px;
}
.vm-id {
font-size: 12px;
line-height: 20px;
}
}
.power-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 600;
&.power-on {
background-color: #d4edda;
color: #155724;
}
&.power-off {
background-color: #fff3cd;
color: #856404;
}
}
.manual-vm-form {
.add-btn-col {
display: flex;
align-items: flex-end;
}
}
.manual-vm-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.multi-line-cell {
display: flex;
flex-direction: column;
line-height: 20px;
}
.mt-4 {
margin-top: 4px;
}
.actions-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
margin-top: 30px;
}
</style>

View File

@ -16,6 +16,11 @@ import HarvesterMembers from '../pages/c/_cluster/members/index.vue';
import ProjectNamespaces from '../pages/c/_cluster/projectsnamespaces.vue';
import HarvesterAlertmanagerReceiver from '../pages/c/_cluster/alertmanagerconfig/_alertmanagerconfigid/receiver.vue';
import HarvesterUnsupported from '../pages/c/_cluster/unsupported/index.vue';
import ForkliftDashboard from '../pages/c/_cluster/forklift/index.vue';
import ForkliftConfigureProvider from '../pages/c/_cluster/forklift/configure-provider.vue';
import ForkliftSelectVms from '../pages/c/_cluster/forklift/select-vms.vue';
import ForkliftConfigureMappings from '../pages/c/_cluster/forklift/configure-mappings.vue';
import ForkliftReviewMigration from '../pages/c/_cluster/forklift/review-migration.vue';
const routes = [
{
@ -83,6 +88,26 @@ const routes = [
name: `${ PRODUCT_NAME }-c-cluster-projectsnamespaces`,
path: `/:product/c/:cluster/projectsnamespaces`,
component: ProjectNamespaces,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift`,
path: `/:product/c/:cluster/forklift`,
component: ForkliftDashboard,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-provider`,
path: `/:product/c/:cluster/forklift/configure-provider`,
component: ForkliftConfigureProvider,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-select-vms`,
path: `/:product/c/:cluster/forklift/select-vms`,
component: ForkliftSelectVms,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-configure-mappings`,
path: `/:product/c/:cluster/forklift/configure-mappings`,
component: ForkliftConfigureMappings,
}, {
name: `${ PRODUCT_NAME }-c-cluster-forklift-review-migration`,
path: `/:product/c/:cluster/forklift/review-migration`,
component: ForkliftReviewMigration,
}, {
name: `${ PRODUCT_NAME }-c-cluster-resource`,
path: `/:product/c/:cluster/:resource`,

View File

@ -0,0 +1,272 @@
[
{
"id": "datastore-16879",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Content Library Datastore",
"revision": 646,
"name": "Content Library Datastore",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-16879",
"type": "NFS41",
"capacity": 65542688866304,
"free": 65166035648512,
"maintenance": "normal"
},
{
"id": "datastore-2036",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Mako14-Spinner1",
"revision": 460,
"name": "Mako14-Spinner1",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2036",
"type": "VMFS",
"capacity": 0,
"free": 0,
"maintenance": "normal"
},
{
"id": "datastore-16880",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/VCSA Services",
"revision": 631,
"name": "VCSA Services",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-16880",
"type": "NFS41",
"capacity": 1545487384576,
"free": 1545007792128,
"maintenance": "normal"
},
{
"id": "datastore-12038",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Mako13-Spinner1",
"revision": 1,
"name": "Mako13-Spinner1",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-12038",
"type": "VMFS",
"capacity": 8001524072448,
"free": 7993813893120,
"maintenance": "normal"
},
{
"id": "datastore-2045",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Mako15-Spinner1",
"revision": 10,
"name": "Mako15-Spinner1",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2045",
"type": "VMFS",
"capacity": 8001524072448,
"free": 7883079024640,
"maintenance": "normal"
},
{
"id": "datastore-2043",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Mako13-Local",
"revision": 107,
"name": "Mako13-Local",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2043",
"type": "VMFS",
"capacity": 8001524072448,
"free": 7444761673728,
"maintenance": "normal"
},
{
"id": "datastore-2044",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Mako14-Local",
"revision": 411,
"name": "Mako14-Local",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2044",
"type": "VMFS",
"capacity": 8001524072448,
"free": 7953250779136,
"maintenance": "normal"
},
{
"id": "datastore-27001",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Heartbeat-01",
"revision": 644,
"name": "Heartbeat-01",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-27001",
"type": "NFS41",
"capacity": 1545227599872,
"free": 1545007792128,
"maintenance": "normal"
},
{
"id": "datastore-27002",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Heartbeat-02",
"revision": 642,
"name": "Heartbeat-02",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-27002",
"type": "NFS41",
"capacity": 1545007972352,
"free": 1545007792128,
"maintenance": "normal"
},
{
"id": "datastore-60001",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/NFS VM Datastore",
"revision": 460,
"name": "NFS VM Datastore",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-60001",
"type": "NFS41",
"capacity": 65169610178560,
"free": 65166043054080,
"maintenance": "normal"
},
{
"id": "datastore-14753",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Content Library Datastore-DEAD",
"revision": 1,
"name": "Content Library Datastore-DEAD",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-14753",
"type": "NFS41",
"capacity": 76523271618560,
"free": 76438520856576,
"maintenance": "normal"
},
{
"id": "datastore-2039",
"parent": {
"kind": "Folder",
"id": "group-s1004"
},
"path": "/PG Devlab/datastore/Mako15-Local",
"revision": 263,
"name": "Mako15-Local",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2039",
"type": "VMFS",
"capacity": 8001524072448,
"free": 7828817313792,
"maintenance": "normal"
},
{
"id": "datastore-2035",
"parent": {
"kind": "StoragePod",
"id": "group-p2042"
},
"path": "/Mako13-NVMe2",
"revision": 855,
"name": "Mako13-NVMe2",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2035",
"type": "VMFS",
"capacity": 1600143753216,
"free": 443096760320,
"maintenance": "normal"
},
{
"id": "datastore-2034",
"parent": {
"kind": "StoragePod",
"id": "group-p2042"
},
"path": "/Mako13-NVMe1",
"revision": 44,
"name": "Mako13-NVMe1",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2034",
"type": "VMFS",
"capacity": 1600143753216,
"free": 350873452544,
"maintenance": "normal"
},
{
"id": "datastore-2037",
"parent": {
"kind": "StoragePod",
"id": "group-p2042"
},
"path": "/Mako14-NVMe1",
"revision": 450,
"name": "Mako14-NVMe1",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2037",
"type": "VMFS",
"capacity": 1600143753216,
"free": 466680283136,
"maintenance": "normal"
},
{
"id": "datastore-2040",
"parent": {
"kind": "StoragePod",
"id": "group-p2042"
},
"path": "/Mako15-NVMe1",
"revision": 648,
"name": "Mako15-NVMe1",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2040",
"type": "VMFS",
"capacity": 1600143753216,
"free": 380083634176,
"maintenance": "normal"
},
{
"id": "datastore-2038",
"parent": {
"kind": "StoragePod",
"id": "group-p2042"
},
"path": "/Mako14-NVMe2",
"revision": 623,
"name": "Mako14-NVMe2",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2038",
"type": "VMFS",
"capacity": 1600143753216,
"free": 685895581696,
"maintenance": "normal"
},
{
"id": "datastore-2041",
"parent": {
"kind": "StoragePod",
"id": "group-p2042"
},
"path": "/Mako15-NVMe2",
"revision": 642,
"name": "Mako15-NVMe2",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/datastores/datastore-2041",
"type": "VMFS",
"capacity": 1600143753216,
"free": 383375114240,
"maintenance": "normal"
}
]

View File

@ -0,0 +1,332 @@
[
{
"id": "dvs-5001",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/Production-VDS",
"revision": 1,
"name": "Production-VDS",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvs-5001",
"variant": "DvSwitch",
"vlanId": "",
"host": [
{
"Host": {
"kind": "Host",
"id": "host-1010"
},
"PNIC": [
"vmnic2"
]
},
{
"Host": {
"kind": "Host",
"id": "host-1012"
},
"PNIC": [
"vmnic2"
]
},
{
"Host": {
"kind": "Host",
"id": "host-2003"
},
"PNIC": [
"vmnic2"
]
}
]
},
{
"id": "dvs-13021",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/DSwitch",
"revision": 1,
"name": "DSwitch",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvs-13021",
"variant": "DvSwitch",
"vlanId": "",
"host": [
{
"Host": {
"kind": "Host",
"id": "host-1010"
},
"PNIC": [
"vmnic0"
]
},
{
"Host": {
"kind": "Host",
"id": "host-1012"
},
"PNIC": [
"vmnic0"
]
},
{
"Host": {
"kind": "Host",
"id": "host-2003"
},
"PNIC": [
"vmnic0"
]
}
]
},
{
"id": "dvportgroup-13026",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/DSwitch-vMotion",
"revision": 1,
"name": "DSwitch-vMotion",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-13026",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-13021"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-13026"
},
{
"id": "dvportgroup-13027",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/DSwitch-vSAN",
"revision": 1,
"name": "DSwitch-vSAN",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-13027",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-13021"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-13027"
},
{
"id": "dvportgroup-13023",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/DSwitch-vCenter-HA-Management",
"revision": 1,
"name": "DSwitch-vCenter-HA-Management",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-13023",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-13021"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-13023"
},
{
"id": "dvportgroup-13025",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/DSwitch-vCenter-HA-VM-Network",
"revision": 1,
"name": "DSwitch-vCenter-HA-VM-Network",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-13025",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-13021"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-13025"
},
{
"id": "dvportgroup-13024",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/DSwitch-vCenter-HA-Witness",
"revision": 1,
"name": "DSwitch-vCenter-HA-Witness",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-13024",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-13021"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-13024"
},
{
"id": "dvportgroup-5002",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/Production-VDS-DVUplinks-5001",
"revision": 1,
"name": "Production-VDS-DVUplinks-5001",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-5002",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-5001"
},
"vlanId": "0-4094",
"host": null,
"key": "dvportgroup-5002"
},
{
"id": "dvportgroup-5003",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/VM-Network",
"revision": 1,
"name": "VM-Network",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-5003",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-5001"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-5003"
},
{
"id": "dvportgroup-5005",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/mgmt",
"revision": 1,
"name": "mgmt",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-5005",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-5001"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-5005"
},
{
"id": "dvportgroup-6001",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/vCenter-HA",
"revision": 1,
"name": "vCenter-HA",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-6001",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-5001"
},
"vlanId": "",
"host": null,
"key": "dvportgroup-6001"
},
{
"id": "dvportgroup-13022",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/DSwitch-DVUplinks-13021",
"revision": 1,
"name": "DSwitch-DVUplinks-13021",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/dvportgroup-13022",
"variant": "DvPortGroup",
"dvSwitch": {
"kind": "Network",
"id": "dvs-13021"
},
"vlanId": "0-4094",
"host": null,
"key": "dvportgroup-13022"
},
{
"id": "network-12023",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/vCenter-HA-Witness",
"revision": 1,
"name": "vCenter-HA-Witness",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/network-12023",
"variant": "Standard",
"vlanId": "",
"host": null
},
{
"id": "network-12022",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/vCenter-HA-Management",
"revision": 1,
"name": "vCenter-HA-Management",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/network-12022",
"variant": "Standard",
"vlanId": "",
"host": null
},
{
"id": "network-12021",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/vCenter-HA-VM-Network",
"revision": 1,
"name": "vCenter-HA-VM-Network",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/network-12021",
"variant": "Standard",
"vlanId": "",
"host": null
},
{
"id": "network-1017",
"parent": {
"kind": "Folder",
"id": "group-n1005"
},
"path": "/PG Devlab/network/VM Network",
"revision": 1,
"name": "VM Network",
"selfLink": "providers/vsphere/eaac340a-8953-479d-9463-85a1a100ab9d/networks/network-1017",
"variant": "Standard",
"vlanId": "",
"host": null
}
]

14275
pkg/harvester/utils/vms.json Normal file

File diff suppressed because it is too large Load Diff