feat(forklift): New changes added based on new figma

Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
Marcelo Fukumoto 2026-05-07 02:20:45 +02:00
parent 63d726aa91
commit 341f7734a9
No known key found for this signature in database
GPG Key ID: 1CA12189625C2543
4 changed files with 215 additions and 32 deletions

View File

@ -1892,7 +1892,6 @@ harvester:
targetNamespace: Target Namespace
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"

View File

@ -1,6 +1,161 @@
import HarvesterResource from './harvester';
import { HCI } from '../types';
export default class ForkliftPlan extends HarvesterResource {
get planFailed() {
const conditions = this.status?.conditions || [];
return conditions.some((c) => c.type === 'Failed' && c.status === 'True');
}
get isMigrating() {
const migration = this.status?.migration;
return !!migration?.started && !migration?.completed;
}
get planCanceled() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return false;
}
const latest = history[history.length - 1];
const conditions = latest.conditions || [];
return conditions.some((c) => c.type === 'Canceled' && c.status === 'True');
}
get stateDisplay() {
if (this.planFailed) {
return 'Error';
}
if (this.planCanceled) {
return 'Canceled';
}
if (this.isMigrating) {
return 'In Progress';
}
return 'Active';
}
get stateBackground() {
if (this.planFailed) {
return 'bg-error';
}
if (this.planCanceled) {
return 'bg-warning';
}
if (this.isMigrating) {
return 'bg-info';
}
return 'bg-success';
}
get _availableActions() {
const canStop = this.isMigrating && !this.planCanceled;
const canStart = !this.isMigrating || this.planFailed || this.planCanceled;
const out = super._availableActions;
if (canStop) {
out.unshift({
action: 'stopMigration',
enabled: true,
icon: 'icon icon-pause',
label: 'Stop',
});
}
if (canStart) {
out.unshift({
action: 'startMigration',
enabled: true,
icon: 'icon icon-play',
label: 'Start',
});
}
return out;
}
async stopMigration() {
const history = this.status?.migration?.history || [];
if (history.length === 0) {
return;
}
const latest = history[history.length - 1];
const migrationName = latest.migration?.name;
if (migrationName) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ migrationName }`);
await this.$dispatch('request', { url, method: 'DELETE' });
}
}
async startMigration() {
const namespace = this.metadata.namespace;
const history = this.status?.migration?.history || [];
// Delete previous migrations before starting a new one
for (const entry of history) {
const name = entry.migration?.name;
if (name) {
const selfUrl = this.linkFor('self');
const url = selfUrl.replace('forklift.konveyor.io.plans', 'forklift.konveyor.io.migrations').replace(`/${ this.metadata.name }`, `/${ name }`);
try {
await this.$dispatch('request', { url, method: 'DELETE' });
} catch (e) {
// Migration may already be gone — ignore 404s
}
}
}
const migration = await this.$dispatch('create', {
type: HCI.FORKLIFT_MIGRATION,
metadata: {
name: `${ this.metadata.name }-migration-${ Math.random().toString(36).substring(2, 7) }`,
namespace,
ownerReferences: [
{
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
uid: this.metadata.uid,
blockOwnerDeletion: true,
},
],
},
spec: {
plan: {
apiVersion: 'forklift.konveyor.io/v1beta1',
kind: 'Plan',
name: this.metadata.name,
namespace,
},
},
});
await migration.save();
}
async deletePlan() {
await this.remove();
}
/**
* Deleting a Plan cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap, Provider ( Secret).
@ -9,7 +164,7 @@ export default class ForkliftPlan extends HarvesterResource {
remove() {
const opt = { ...arguments };
opt.params = { propagationPolicy: 'Foreground' };
opt.params = { propagationPolicy: 'Background' };
return this._remove(opt);
}

View File

@ -41,6 +41,7 @@ const createdProvider = ref(null);
const createdSecret = ref(null);
const loading = ref(true);
const testPassed = ref(false);
const testing = ref(false);
const isFormValid = computed(() => !!providerName.value && !!url.value && !!username.value && !!password.value);
@ -71,6 +72,7 @@ const cancel = async() => {
const testConnection = async(buttonCb) => {
testResult.value = null;
testError.value = null;
testing.value = true;
if (!providerName.value || !url.value || !username.value || !password.value) {
testError.value = t('harvester.addons.forklift.configureProvider.testMissingFields');
@ -191,6 +193,7 @@ const testConnection = async(buttonCb) => {
if (connected) {
testPassed.value = true;
testResult.value = t('harvester.addons.forklift.configureProvider.testSuccess');
testing.value = false;
buttonCb(true);
} else {
if (createdProvider.value) {
@ -203,6 +206,7 @@ const testConnection = async(buttonCb) => {
}
testError.value = errorMsg || t('harvester.addons.forklift.configureProvider.testTimeout');
testing.value = false;
buttonCb(false);
}
} catch (err) {
@ -220,6 +224,7 @@ const testConnection = async(buttonCb) => {
}
testError.value = err.message || t('harvester.addons.forklift.configureProvider.testFailed');
testing.value = false;
buttonCb(false);
}
};
@ -399,7 +404,7 @@ init();
{{ t('generic.cancel') }}
</button>
<AsyncButton
:disabled="!isFormValid"
:disabled="!isFormValid || testing"
:action-label="t('harvester.addons.forklift.configureProvider.save')"
:waiting-label="t('harvester.addons.forklift.configureProvider.save')"
:success-label="t('harvester.addons.forklift.configureProvider.save')"

View File

@ -48,8 +48,6 @@ const rows = computed(() => {
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(', ') || '-';
@ -76,13 +74,31 @@ const rows = computed(() => {
currentStep = step.name || `Step ${ idx + 1 }`;
}
if (step.error) {
errorMsg = (step.error.reasons || []).join('; ') || 'Error';
if (step.error && !errorMsg) {
const reasons = (step.error.reasons || []).join('; ') || 'Error';
errorMsg = `${ step.name || `Step ${ idx + 1 }` }: ${ reasons }`;
}
if (step.phase === 'Failed' && !errorMsg) {
errorMsg = `${ step.name || `Step ${ idx + 1 }` }: Failed`;
}
}
});
overallProgress = Math.round(overallProgress);
// Fallback to VM-level error if no step-level error found
if (!errorMsg && vm.error) {
const reasons = (vm.error.reasons || []).join('; ') || 'Failed';
errorMsg = `${ currentStep || vm.error.phase || 'Migration' }: ${ reasons }`;
}
overallProgress = Math.round(overallProgress * 10) / 10;
// If no step-level error but the plan itself is failed, surface it
if (!errorMsg && plan.planFailed) {
errorMsg = `${ currentStep || 'Migration' }: Failed`;
}
return {
vmName: vm.name || vm.id || 'Unknown',
@ -90,10 +106,11 @@ const rows = computed(() => {
progress: overallProgress,
currentStep,
errorMsg,
canceled: plan.planCanceled,
};
});
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length) : 0;
plan.progress = plan.vmProgress.length > 0 ? Math.round(plan.vmProgress.reduce((sum, vm) => sum + vm.progress, 0) / plan.vmProgress.length * 10) / 10 : 0;
return plan;
});
@ -209,16 +226,18 @@ init();
<span class="vm-name">{{ vm.vmName }}</span>
<span class="text-muted vm-id">id: {{ vm.vmId }}</span>
</div>
<span class="vm-pct">{{ vm.progress }}%</span>
</div>
<PercentageBar
:model-value="vm.progress"
:color-stops="vm.errorMsg ? { 0: '--error' } : { 99: '--primary', 100: '--success' }"
preferred-direction="MORE"
class="vm-bar"
/>
<div class="vm-pct-block">
<PercentageBar
:model-value="vm.progress"
:color-stops="vm.errorMsg ? { 100: '--error' } : vm.canceled ? { 100: '--darker' } : vm.progress >= 100 ? { 100: '--success' } : { 100: '--primary' }"
preferred-direction="MORE"
class="vm-bar"
/>
<span class="vm-pct text-muted mr-10">{{ vm.progress }}%</span>
</div>
<div
v-if="vm.progress === 100"
v-if="vm.progress >= 100"
class="step-label text-muted"
>
Finished Successfully
@ -229,6 +248,12 @@ init();
>
{{ vm.errorMsg }}
</div>
<div
v-else-if="vm.canceled"
class="step-label text-muted"
>
Canceled
</div>
<div
v-else-if="vm.currentStep"
class="step-label text-muted"
@ -252,7 +277,7 @@ init();
<style lang="scss" scoped>
.plan-name-cell {
.plan-name {
font-weight: 600;
font-weight: 500;
line-height: 20px;
}
@ -266,14 +291,7 @@ init();
.progress-cells {
display: flex;
flex-direction: column;
gap: 10px;
}
.vm-progress {
&:not(:last-child) {
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}
gap: 16px;
}
.vm-progress-header {
@ -284,8 +302,7 @@ init();
}
.vm-name {
font-weight: 600;
font-size: 12px;
font-size: 14px;
}
.vm-name-block {
@ -299,8 +316,16 @@ init();
}
.vm-pct {
font-size: 12px;
font-weight: 600;
font-size: 14px;
min-width: 40px;
text-align: left;
}
.vm-pct-block {
display: flex;
align-items: center;
gap: 14px;
justify-content: space-between;
}
.vm-bar {
@ -308,13 +333,12 @@ init();
}
.step-label {
font-size: 12px;
font-size: 13px;
margin-top: 4px;
line-height: 20px;
&.text-error {
color: var(--error);
font-weight: 600;
}
}