refactor: add customized migration plan delete model (#996)

* refactor: add customized migration plan delete model

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: copilot review

Signed-off-by: Andy Lee <andy.lee@suse.com>

* refactor: copilot review

Signed-off-by: Andy Lee <andy.lee@suse.com>

---------

Signed-off-by: Andy Lee <andy.lee@suse.com>
This commit is contained in:
Andy Lee 2026-07-14 17:36:25 +08:00 committed by GitHub
parent e0436bff28
commit fb6ffa3c0f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 355 additions and 13 deletions

View File

@ -2002,6 +2002,8 @@ harvester:
start: Start start: Start
restart: Restart restart: Restart
stop: Stop stop: Stop
deleteDialog:
warning: Deleting this migration plan will also delete the following related resources
vmImport: vmImport:
titles: titles:

View File

@ -46,6 +46,10 @@ export default class ForkliftPlan extends HarvesterResource {
} }
get stateDescription() { get stateDescription() {
if (this.metadata?.deletionTimestamp) {
return null;
}
const messages = []; const messages = [];
const conditions = this.status?.conditions || []; const conditions = this.status?.conditions || [];
@ -67,7 +71,7 @@ export default class ForkliftPlan extends HarvesterResource {
get stateObj() { get stateObj() {
return { return {
error: this.planFailed || this.planCritical, error: this.planFailed || this.planCritical,
transitioning: this.isMigrating, transitioning: this.isMigrating || this.isPendingMigrationStart || !!this.metadata?.deletionTimestamp,
message: this.stateDescription, message: this.stateDescription,
}; };
} }
@ -78,6 +82,14 @@ export default class ForkliftPlan extends HarvesterResource {
return !!migration?.started && !migration?.completed; return !!migration?.started && !migration?.completed;
} }
// Right after creating a plan, controller status can lag briefly.
// Treat this window as in-progress so we don't flash a succeeded badge.
get isPendingMigrationStart() {
const vmCount = this.spec?.vms?.length || 0;
return vmCount > 0 && !this.planFailed && !this.planCritical && !this.planCanceled && !this.planSucceeded && !this.isMigrating;
}
get planCanceled() { get planCanceled() {
const history = this.status?.migration?.history || []; const history = this.status?.migration?.history || [];
@ -98,6 +110,10 @@ export default class ForkliftPlan extends HarvesterResource {
} }
get stateDisplay() { get stateDisplay() {
if (this.metadata?.deletionTimestamp) {
return 'Terminating';
}
if (this.planFailed) { if (this.planFailed) {
return this.t('harvester.addons.vmMigration.plan.states.error'); return this.t('harvester.addons.vmMigration.plan.states.error');
} }
@ -114,10 +130,18 @@ export default class ForkliftPlan extends HarvesterResource {
return this.t('harvester.addons.vmMigration.plan.states.inProgress'); return this.t('harvester.addons.vmMigration.plan.states.inProgress');
} }
if (this.isPendingMigrationStart) {
return this.t('harvester.addons.vmMigration.plan.states.inProgress');
}
return this.t('harvester.addons.vmMigration.plan.states.active'); return this.t('harvester.addons.vmMigration.plan.states.active');
} }
get stateBackground() { get stateBackground() {
if (this.metadata?.deletionTimestamp) {
return 'bg-info';
}
if (this.planFailed) { if (this.planFailed) {
return 'bg-error'; return 'bg-error';
} }
@ -134,6 +158,10 @@ export default class ForkliftPlan extends HarvesterResource {
return 'bg-info'; return 'bg-info';
} }
if (this.isPendingMigrationStart) {
return 'bg-info';
}
return 'bg-success'; return 'bg-success';
} }
@ -255,15 +283,4 @@ export default class ForkliftPlan extends HarvesterResource {
async deletePlan() { async deletePlan() {
await this.remove(); await this.remove();
} }
/**
* Deleting a Plan cascades via ownerReferences set at creation time.
* Kubernetes GC will automatically delete: Migration, NetworkMap, StorageMap.
* Use foreground propagation to ensure children are deleted before the parent.
*/
remove(opt = {}) {
opt.params = { ...(opt.params || {}), propagationPolicy: 'Foreground' };
return this._remove(opt);
}
} }

View File

@ -9,6 +9,7 @@ import ConfigureMappingsStep from '@pkg/harvester/components/vm-migration/Config
import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue'; import ReviewMigrationStep from '@pkg/harvester/components/vm-migration/ReviewMigrationStep.vue';
import { PRODUCT_NAME } from '@pkg/harvester/config/harvester'; import { PRODUCT_NAME } from '@pkg/harvester/config/harvester';
import { currentRouter } from '@pkg/harvester/utils/router'; import { currentRouter } from '@pkg/harvester/utils/router';
import { exceptionToErrorsArray, stringify } from '@shell/utils/error';
const store = useStore(); const store = useStore();
const { t } = useI18n(store); const { t } = useI18n(store);
@ -220,7 +221,13 @@ const onFinish = async(buttonCb) => {
buttonCb(true); buttonCb(true);
currentRouter().push(migrationListLocation); currentRouter().push(migrationListLocation);
} catch (err) { } catch (err) {
errors.value = [err instanceof Error ? err.message : String(err)]; errors.value = exceptionToErrorsArray(err).map((e) => {
if (typeof e === 'string') {
return e;
}
return stringify(e);
});
buttonCb(false); buttonCb(false);
} }
}; };

View File

@ -0,0 +1,316 @@
<script>
import { defineComponent } from 'vue';
import { mapState, mapGetters } from 'vuex';
import { resourceNames } from '@shell/utils/string';
import { exceptionToErrorsArray } from '@shell/utils/error';
import { HCI } from '../types';
export default defineComponent({
name: 'PromptRemoveForkliftPlanDialog',
emits: ['errors'],
props: {
value: {
type: Array,
default: () => []
},
names: {
type: Array,
default: () => []
},
type: {
type: String,
required: true
},
close: {
type: Function,
required: true
},
doneLocation: {
type: Object,
default: () => {}
}
},
data() {
return {
errors: [],
removing: false,
};
},
computed: {
...mapState('action-menu', ['toRemove']),
...mapGetters({ t: 'i18n/t' }),
existingRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
return {
[HCI.FORKLIFT_MIGRATION]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_MIGRATION) || [],
[HCI.FORKLIFT_NETWORK_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_NETWORK_MAP) || [],
[HCI.FORKLIFT_STORAGE_MAP]: this.$store.getters[`${ inStore }/all`](HCI.FORKLIFT_STORAGE_MAP) || [],
};
},
resourceType() {
return this.t('typeLabel."forklift.konveyor.io.plan"', { count: this.names?.length || 1 });
},
relatedSummary() {
const existing = this.existingRelatedResources;
return (this.value || []).map((plan) => {
const planName = plan?.metadata?.name || '-';
const namespace = plan?.metadata?.namespace || '';
const networkMap = plan?.spec?.map?.network;
const storageMap = plan?.spec?.map?.storage;
const history = plan?.status?.migration?.history || [];
const migrationsMap = new Map();
history
.map((entry) => ({
name: entry?.migration?.name,
namespace: entry?.migration?.namespace || namespace,
}))
.filter((entry) => !!entry.name)
.forEach((entry) => {
const matched = existing[HCI.FORKLIFT_MIGRATION].find((resource) => resource?.metadata?.name === entry.name && resource?.metadata?.namespace === entry.namespace);
if (matched) {
migrationsMap.set(`${ entry.namespace }/${ entry.name }`, entry);
}
});
const matchedNetworkMap = networkMap?.name && existing[HCI.FORKLIFT_NETWORK_MAP].find((resource) => resource?.metadata?.name === networkMap.name && resource?.metadata?.namespace === (networkMap.namespace || namespace));
const matchedStorageMap = storageMap?.name && existing[HCI.FORKLIFT_STORAGE_MAP].find((resource) => resource?.metadata?.name === storageMap.name && resource?.metadata?.namespace === (storageMap.namespace || namespace));
return {
planName,
namespace,
migrations: [...migrationsMap.values()],
networkMap: matchedNetworkMap ? {
namespace: networkMap.namespace || namespace,
name: networkMap.name,
} : null,
storageMap: matchedStorageMap ? {
namespace: storageMap.namespace || namespace,
name: storageMap.name,
} : null,
};
});
},
},
async mounted() {
const inStore = this.$store.getters['currentProduct'].inStore;
try {
await Promise.all([
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
]);
} catch {
// Best-effort: the dialog can still function without this prefetch.
}
},
methods: {
resourceNames,
buildDeleteTargets() {
const targets = new Map();
for (const plan of this.value || []) {
const namespace = plan?.metadata?.namespace || '';
const networkMap = plan?.spec?.map?.network;
const storageMap = plan?.spec?.map?.storage;
const history = plan?.status?.migration?.history || [];
if (networkMap?.name) {
const ns = networkMap.namespace || namespace;
const key = `${ HCI.FORKLIFT_NETWORK_MAP }|${ ns }|${ networkMap.name }`;
targets.set(key, {
type: HCI.FORKLIFT_NETWORK_MAP,
name: networkMap.name,
namespace: ns,
});
}
if (storageMap?.name) {
const ns = storageMap.namespace || namespace;
const key = `${ HCI.FORKLIFT_STORAGE_MAP }|${ ns }|${ storageMap.name }`;
targets.set(key, {
type: HCI.FORKLIFT_STORAGE_MAP,
name: storageMap.name,
namespace: ns,
});
}
for (const entry of history) {
const migrationName = entry?.migration?.name;
if (!migrationName) {
continue;
}
const migrationNs = entry?.migration?.namespace || namespace;
const key = `${ HCI.FORKLIFT_MIGRATION }|${ migrationNs }|${ migrationName }`;
targets.set(key, {
type: HCI.FORKLIFT_MIGRATION,
name: migrationName,
namespace: migrationNs,
});
}
}
return [...targets.values()];
},
async deleteRelatedResources() {
const inStore = this.$store.getters['currentProduct'].inStore;
const targets = this.buildDeleteTargets();
if (!targets.length) {
return;
}
let cache = {};
try {
cache = {
[HCI.FORKLIFT_MIGRATION]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_MIGRATION }),
[HCI.FORKLIFT_NETWORK_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_NETWORK_MAP }),
[HCI.FORKLIFT_STORAGE_MAP]: await this.$store.dispatch(`${ inStore }/findAll`, { type: HCI.FORKLIFT_STORAGE_MAP }),
};
} catch (err) {
// Best-effort cleanup; don't block closing the dialog if listing related resources fails.
return;
}
for (const target of targets) {
const matched = (cache[target.type] || []).find((resource) => resource?.metadata?.name === target.name && resource?.metadata?.namespace === target.namespace
);
if (!matched) {
continue;
}
try {
await matched.remove({ params: { propagationPolicy: 'Background' } });
} catch (err) {
// Continue deleting remaining resources and let final plan deletion fail if needed.
}
}
},
async remove(buttonDone) {
this.errors = [];
this.removing = true;
try {
const results = await Promise.allSettled((this.value || []).map((resource) => resource.remove({ params: { propagationPolicy: 'Background' } })));
await this.deleteRelatedResources();
const rejected = results.filter((r) => r.status === 'rejected');
if (rejected.length) {
throw rejected[0].reason;
}
this.close(buttonDone);
} catch (err) {
this.errors = exceptionToErrorsArray(err);
this.$emit('errors', err);
if (buttonDone) {
buttonDone(false);
}
} finally {
this.removing = false;
}
},
}
});
</script>
<template>
<div class="mt-10">
<div class="mb-10">
{{ t('promptRemove.attemptingToRemove', { type: resourceType }) }}
<span v-clean-html="resourceNames(names, null, t)" />
</div>
<hr class="mb-10">
<div class="mb-10 text-warning">
<i class="icon icon-warning icon-lg mr-5" />
{{ t('harvester.addons.vmMigration.deleteDialog.warning') }}
</div>
<div
v-for="summary in relatedSummary"
:key="`${ summary.namespace }/${ summary.planName }`"
class="mb-10"
>
<div> {{ t('harvester.addons.vmMigration.labels.migration') }}:</div>
<template v-if="summary.migrations.length">
<div
v-for="migration in summary.migrations"
:key="`${ migration.namespace }/${ migration.name }`"
>
<div class="ml-20">
- {{ migration.name }}
</div>
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div> {{ t('harvester.addons.vmMigration.labels.networkMap') }}:</div>
<template v-if="summary.networkMap">
<div class="ml-20">
- {{ summary.networkMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
<div> {{ t('harvester.addons.vmMigration.labels.storageMap') }}:</div>
<template v-if="summary.storageMap">
<div class="ml-20">
- {{ summary.storageMap.name }}
</div>
</template>
<div
v-else
class="ml-20"
>
- -
</div>
</div>
<Banner
v-for="(error, i) in errors"
:key="i"
color="error"
:label="error"
/>
</div>
</template>