mirror of
https://github.com/harvester/harvester-ui-extension.git
synced 2026-08-16 12:49:14 +00:00
feat(forklift): Fixed some UI changes
Signed-off-by: Marcelo Fukumoto <marcelo.fukumoto@suse.com>
This commit is contained in:
parent
de7598b7af
commit
1d26c48117
@ -2,11 +2,10 @@
|
||||
import { ref, computed, watch } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import Loading from '@shell/components/Loading';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { STORAGE_CLASS, NETWORK_ATTACHMENT } from '@shell/config/types';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
import { HCI } from '../../types';
|
||||
import MappingColumn from './MappingColumn.vue';
|
||||
|
||||
const props = defineProps({
|
||||
providerName: { type: String, default: '' },
|
||||
@ -268,7 +267,7 @@ const formatStorageDetail = (entry) => {
|
||||
|
||||
const buildNetworkMapSpec = (providerRef) => {
|
||||
return {
|
||||
map: networkEntries.value.map((entry) => {
|
||||
map: networkEntries.value.filter((entry) => !!entry.target).map((entry) => {
|
||||
if (entry.target === 'pod') {
|
||||
return {
|
||||
source: { name: entry.name, id: entry.id },
|
||||
@ -302,7 +301,7 @@ const buildNetworkMapSpec = (providerRef) => {
|
||||
|
||||
const buildStorageMapSpec = (providerRef) => {
|
||||
return {
|
||||
map: storageEntries.value.map((entry) => ({
|
||||
map: storageEntries.value.filter((entry) => !!entry.target).map((entry) => ({
|
||||
source: { name: entry.name, id: entry.id },
|
||||
destination: { storageClass: entry.target },
|
||||
})),
|
||||
@ -478,17 +477,24 @@ const init = async() => {
|
||||
}
|
||||
}
|
||||
|
||||
const hasExistingNetworkTargets = networkEntries.value.some((e) => !!e.target);
|
||||
const hasExistingStorageTargets = storageEntries.value.some((e) => !!e.target);
|
||||
|
||||
if (!hasExistingNetworkTargets) {
|
||||
if (props.existingNetworkMap?.spec?.map) {
|
||||
applyNetworkMapTargets(props.existingNetworkMap.spec.map);
|
||||
} else {
|
||||
applyDefaultNetworkMap();
|
||||
}
|
||||
}
|
||||
|
||||
if (!hasExistingStorageTargets) {
|
||||
if (props.existingStorageMap?.spec?.map) {
|
||||
applyStorageMapTargets(props.existingStorageMap.spec.map);
|
||||
} else {
|
||||
applyDefaultStorageMap();
|
||||
}
|
||||
}
|
||||
|
||||
loading.value = false;
|
||||
};
|
||||
@ -502,114 +508,45 @@ init();
|
||||
v-else
|
||||
class="configure-mappings"
|
||||
>
|
||||
<p class="text-muted line-height-20">
|
||||
<p class="text-deemphasized line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.description') }}
|
||||
</p>
|
||||
<div class="mappings-columns">
|
||||
<!-- Network Mapping -->
|
||||
<div class="mapping-column">
|
||||
<div>
|
||||
<h3 class="mapping-section-title">
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.title') }}
|
||||
</h3>
|
||||
<p class="text-muted line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.networkMapping.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RcItemCard
|
||||
v-for="entry in networkEntries"
|
||||
:id="entry._key"
|
||||
:key="entry._key"
|
||||
:variant="'small'"
|
||||
:header="{}"
|
||||
class="bg-light-gray"
|
||||
>
|
||||
<template #item-card-content>
|
||||
<div class="card-content-column">
|
||||
<div class="card-content-row">
|
||||
<div class="mapping-source">
|
||||
<span class="source-name">{{ entry.name }}</span>
|
||||
<span class="source-detail text-muted">VLAN {{ entry.vlanId || '0' }}</span>
|
||||
</div>
|
||||
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-muted']">
|
||||
<i class="icon icon-right-arrow-alt" />
|
||||
</div>
|
||||
<div class="mapping-target">
|
||||
<LabeledSelect
|
||||
v-model:value="entry.target"
|
||||
<MappingColumn
|
||||
:title="t('harvester.addons.vmMigration.configureMappings.networkMapping.title')"
|
||||
:description="t('harvester.addons.vmMigration.configureMappings.networkMapping.description')"
|
||||
:entries="networkEntries"
|
||||
:options="harvesterNetworkOptions"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureMappings.networkMapping.placeholder')"
|
||||
:searchable="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!useAllProviderData && entry.usedBy.length">
|
||||
<span class="used-by">
|
||||
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RcItemCard>
|
||||
</div>
|
||||
|
||||
<!-- Storage Mapping -->
|
||||
<div class="mapping-column">
|
||||
<div>
|
||||
<h3 class="mapping-section-title">
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.title') }}
|
||||
</h3>
|
||||
<p class="text-muted">
|
||||
{{ t('harvester.addons.vmMigration.configureMappings.storageMapping.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RcItemCard
|
||||
v-for="entry in storageEntries"
|
||||
:id="entry._key"
|
||||
:key="entry._key"
|
||||
:variant="'small'"
|
||||
:header="{}"
|
||||
class="bg-light-gray"
|
||||
:show-used-by="!useAllProviderData"
|
||||
:clearable="useAllProviderData"
|
||||
>
|
||||
<template #item-card-content>
|
||||
<div class="card-content-column">
|
||||
<div class="card-content-row">
|
||||
<div class="mapping-source">
|
||||
<span class="source-name">{{ entry.name }}</span>
|
||||
<span
|
||||
v-if="entry.type || entry.capacity"
|
||||
class="source-detail text-muted"
|
||||
>{{ formatStorageDetail(entry) }}</span>
|
||||
</div>
|
||||
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-muted']">
|
||||
<i class="icon icon-right-arrow-alt" />
|
||||
</div>
|
||||
<div class="mapping-target">
|
||||
<LabeledSelect
|
||||
v-model:value="entry.target"
|
||||
<template #source-detail="{ entry }">
|
||||
<span class="source-detail text-deemphasized">VLAN {{ entry.vlanId || '0' }}</span>
|
||||
</template>
|
||||
</MappingColumn>
|
||||
|
||||
<MappingColumn
|
||||
:title="t('harvester.addons.vmMigration.configureMappings.storageMapping.title')"
|
||||
:description="t('harvester.addons.vmMigration.configureMappings.storageMapping.description')"
|
||||
:entries="storageEntries"
|
||||
:options="storageClassOptions"
|
||||
:placeholder="t('harvester.addons.vmMigration.configureMappings.storageMapping.placeholder')"
|
||||
:searchable="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!useAllProviderData && entry.usedBy.length">
|
||||
<span class="used-by">
|
||||
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
:show-used-by="!useAllProviderData"
|
||||
:clearable="useAllProviderData"
|
||||
>
|
||||
<template #source-detail="{ entry }">
|
||||
<span
|
||||
v-if="entry.type || entry.capacity"
|
||||
class="source-detail text-deemphasized"
|
||||
>{{ formatStorageDetail(entry) }}</span>
|
||||
</template>
|
||||
</RcItemCard>
|
||||
</div>
|
||||
</MappingColumn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
.mappings-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -629,85 +566,6 @@ init();
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-column {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.item-card-header) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-section-title {
|
||||
font-weight: 600;
|
||||
margin: 0 0 5px 0;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.card-content-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-source {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
min-width: 100px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
.source-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.used-by {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapping-target {
|
||||
flex: 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.bg-light-gray {
|
||||
background-color: var(--category-active) !important;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.configure-mappings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -718,4 +576,9 @@ init();
|
||||
}
|
||||
}
|
||||
|
||||
.source-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -518,7 +518,7 @@ defineExpose({ testConnection, clickTestButton });
|
||||
|
||||
<template>
|
||||
<div class="configure-provider-step">
|
||||
<p class="text-muted line-height-20">
|
||||
<p class="text-deemphasized line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.description') }}
|
||||
</p>
|
||||
|
||||
@ -567,7 +567,7 @@ defineExpose({ testConnection, clickTestButton });
|
||||
:disabled="isExistingProvider && !editMode"
|
||||
required
|
||||
/>
|
||||
<p class="text-muted mt-5">
|
||||
<p class="text-deemphasized mt-5">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.urlHint') }}
|
||||
</p>
|
||||
</div>
|
||||
@ -598,7 +598,7 @@ defineExpose({ testConnection, clickTestButton });
|
||||
:label="t('harvester.addons.vmMigration.configureProvider.skipSsl')"
|
||||
:disabled="isExistingProvider && !editMode"
|
||||
/>
|
||||
<p class="text-muted ml-20">
|
||||
<p class="text-deemphasized ml-20">
|
||||
{{ t('harvester.addons.vmMigration.configureProvider.skipSslHint') }}
|
||||
</p>
|
||||
</div>
|
||||
@ -670,5 +670,9 @@ defineExpose({ testConnection, clickTestButton });
|
||||
gap: 20px;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.checkbox-label) {
|
||||
color: var(--body-text);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
181
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
181
pkg/harvester/components/vm-migration/MappingColumn.vue
Normal file
@ -0,0 +1,181 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import LabeledSelect from '@shell/components/form/LabeledSelect';
|
||||
import { RcItemCard } from '@components/RcItemCard';
|
||||
import { useI18n } from '@shell/composables/useI18n';
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n(store);
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
entries: { type: Array, default: () => [] },
|
||||
options: { type: Array, default: () => [] },
|
||||
placeholder: { type: String, default: '' },
|
||||
showUsedBy: { type: Boolean, default: false },
|
||||
clearable: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const selectOptions = computed(() => {
|
||||
if (!props.clearable) {
|
||||
return props.options;
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
label: t('harvester.addons.vmMigration.configureMappings.removeMap'),
|
||||
value: null,
|
||||
kind: 'highlighted',
|
||||
},
|
||||
{
|
||||
label: 'divider',
|
||||
disabled: true,
|
||||
kind: 'divider',
|
||||
},
|
||||
{
|
||||
label: `${ props.placeholder }:`,
|
||||
disabled: true,
|
||||
kind: 'title',
|
||||
},
|
||||
...props.options,
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mapping-column">
|
||||
<div>
|
||||
<h3 class="mapping-section-title">
|
||||
{{ title }}
|
||||
</h3>
|
||||
<p
|
||||
v-if="description"
|
||||
class="text-deemphasized line-height-20"
|
||||
>
|
||||
{{ description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<RcItemCard
|
||||
v-for="entry in entries"
|
||||
:id="entry._key"
|
||||
:key="entry._key"
|
||||
:variant="'small'"
|
||||
:header="{}"
|
||||
class="bg-light-gray"
|
||||
>
|
||||
<template #item-card-content>
|
||||
<div class="card-content-column">
|
||||
<div class="card-content-row">
|
||||
<div class="mapping-source">
|
||||
<span class="source-name">{{ entry.name }}</span>
|
||||
<slot
|
||||
name="source-detail"
|
||||
:entry="entry"
|
||||
/>
|
||||
</div>
|
||||
<div :class="['mapping-arrow', entry.target ? 'text-success' : 'text-deemphasized']">
|
||||
<i class="icon icon-right-arrow-alt" />
|
||||
</div>
|
||||
<div class="mapping-target">
|
||||
<LabeledSelect
|
||||
v-model:value="entry.target"
|
||||
:options="selectOptions"
|
||||
:placeholder="placeholder+'...'"
|
||||
:searchable="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="showUsedBy && entry.usedBy && entry.usedBy.length">
|
||||
<span class="used-by">
|
||||
Used by: <b>{{ entry.usedBy.join(', ') }}</b>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</RcItemCard>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mapping-column {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
flex-direction: column;
|
||||
|
||||
:deep(.item-card-header) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-section-title {
|
||||
font-weight: 600;
|
||||
margin: 0 0 5px 0;
|
||||
line-height: 28px;
|
||||
}
|
||||
|
||||
.card-content-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.card-content-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mapping-source {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 2;
|
||||
min-width: 100px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
|
||||
.source-name {
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-detail {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.used-by {
|
||||
margin-top: 4px;
|
||||
font-size: 14px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
||||
|
||||
.mapping-arrow {
|
||||
font-size: 22px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mapping-target {
|
||||
flex: 3;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.line-height-20 {
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.bg-light-gray {
|
||||
background-color: var(--category-active) !important;
|
||||
border: 0;
|
||||
}
|
||||
</style>
|
||||
@ -338,7 +338,7 @@ defineExpose({ startMigration: startMigrationAction });
|
||||
<template #item-card-content>
|
||||
<div class="vm-card-content">
|
||||
<div class="vm-card-specs">
|
||||
<span class="vm-os text-muted">{{ vm.os }}</span>
|
||||
<span class="vm-os text-deemphasized">{{ vm.os }}</span>
|
||||
<span class="vm-resources">
|
||||
<i class="icon icon-disk" />
|
||||
{{ vm.cpus }} vCPU • {{ vm.memGB }} • {{ vm.diskDisplay }}
|
||||
|
||||
@ -357,6 +357,8 @@ const refreshVMs = async() => {
|
||||
emit('complete', { selectedVMs: selectedVMs.value });
|
||||
refreshing.value = false;
|
||||
|
||||
await nextTick();
|
||||
|
||||
const table = sortableTableRef.value;
|
||||
|
||||
if (table) {
|
||||
@ -366,6 +368,8 @@ const refreshVMs = async() => {
|
||||
table.update(rowsToReselect, []);
|
||||
}
|
||||
}
|
||||
|
||||
skipNextSelectionEvent = false;
|
||||
};
|
||||
|
||||
const init = async() => {
|
||||
@ -403,7 +407,7 @@ init();
|
||||
v-else
|
||||
class="select-vms-step"
|
||||
>
|
||||
<p class="text-muted line-height-20">
|
||||
<p class="text-deemphasized line-height-20">
|
||||
{{ t('harvester.addons.vmMigration.selectVms.discovered', { count: vmCount }) }}
|
||||
<router-link
|
||||
v-if="provider"
|
||||
@ -446,18 +450,18 @@ init();
|
||||
<h3 class="m-0">
|
||||
{{ t('harvester.addons.vmMigration.selectVms.availableVms') }}
|
||||
</h3>
|
||||
<span class="text-muted">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
|
||||
<span class="text-deemphasized">{{ selectedCount }} {{ t('harvester.addons.vmMigration.selectVms.selected') }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:vmName="{ row }">
|
||||
<div class="vm-name-cell">
|
||||
<span class="vm-name">{{ row.vmName }}</span>
|
||||
<span class="vm-id text-muted">{{ row.vmId }}</span>
|
||||
<span class="vm-id text-deemphasized">{{ row.vmId }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<template #cell:resources="{ row }">
|
||||
<span>{{ row.resourcesDisplay }}</span><br>
|
||||
<span class="text-muted">{{ row.resourcesSub }}</span>
|
||||
<span class="text-deemphasized">{{ row.resourcesSub }}</span>
|
||||
</template>
|
||||
<template #cell:powerState="{ row }">
|
||||
<BadgeState
|
||||
@ -473,7 +477,7 @@ init();
|
||||
:class="{ 'mt-4': i > 0 }"
|
||||
>
|
||||
<span>{{ net.name }}</span><br>
|
||||
<span class="text-muted">{{ net.id }}</span>
|
||||
<span class="text-deemphasized">{{ net.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -485,7 +489,7 @@ init();
|
||||
:class="{ 'mt-4': i > 0 }"
|
||||
>
|
||||
<span>{{ ds.name }}</span><br>
|
||||
<span class="text-muted">{{ ds.id }}</span>
|
||||
<span class="text-deemphasized">{{ ds.id }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -531,7 +535,7 @@ init();
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-muted {
|
||||
.text-deemphasized {
|
||||
font-size: 13px;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1910,10 +1910,11 @@ harvester:
|
||||
description: Map VMware networks and datastores to Harvester and Longhorn target resources
|
||||
save: Save Mappings and Continue
|
||||
noTemplate: Start from scratch
|
||||
removeMap: Remove Map
|
||||
networkMapping:
|
||||
title: Network Mapping
|
||||
description: Map VMware port groups to Harvester networks
|
||||
placeholder: Choose a Harvester network...
|
||||
placeholder: Choose a Harvester network
|
||||
template: Use existing network mapping as template
|
||||
storageMapping:
|
||||
title: Storage Mapping
|
||||
|
||||
@ -132,7 +132,6 @@ export default class ForkliftPlan extends HarvesterResource {
|
||||
}
|
||||
|
||||
get _availableActions() {
|
||||
if (this.isForkliftDashboard) {
|
||||
const canStop = this.isMigrating && !this.planCanceled;
|
||||
const canStart = !this.planSucceeded && (!this.isMigrating || this.planFailed || this.planCanceled || this.planCritical);
|
||||
const out = [];
|
||||
@ -142,7 +141,7 @@ export default class ForkliftPlan extends HarvesterResource {
|
||||
action: 'startMigration',
|
||||
enabled: true,
|
||||
icon: 'icon icon-play',
|
||||
label: 'Start',
|
||||
label: this.isForkliftDashboard ? 'Restart' : 'Start',
|
||||
});
|
||||
}
|
||||
|
||||
@ -155,6 +154,7 @@ export default class ForkliftPlan extends HarvesterResource {
|
||||
});
|
||||
}
|
||||
|
||||
if (this.isForkliftDashboard) {
|
||||
out.push({
|
||||
action: 'promptRemove',
|
||||
altAction: 'remove',
|
||||
@ -167,9 +167,11 @@ export default class ForkliftPlan extends HarvesterResource {
|
||||
});
|
||||
|
||||
return out;
|
||||
}
|
||||
} else {
|
||||
out.push(...super._availableActions);
|
||||
|
||||
return super._availableActions;
|
||||
return out;
|
||||
}
|
||||
}
|
||||
|
||||
async stopMigration() {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user