diff --git a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.spec.tsx b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.spec.tsx
new file mode 100644
index 00000000..f2be716a
--- /dev/null
+++ b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.spec.tsx
@@ -0,0 +1,222 @@
+/*
+Copyright (C) 2026 Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program. If not, see .
+*/
+
+import React, { act } from "react";
+
+import { render } from "@testing-library/react";
+import {
+ OPENSTACK_ENDPOINT_MOCK,
+ VMWARE_ENDPOINT_MOCK,
+} from "@tests/mocks/EndpointsMock";
+import { INSTANCE_MOCK } from "@tests/mocks/InstancesMock";
+import { NETWORK_MOCK } from "@tests/mocks/NetworksMock";
+import { TRANSFER_ITEM_DETAILS_MOCK } from "@tests/mocks/TransferMock";
+import TestUtils from "@tests/TestUtils";
+
+import { Instance } from "@src/@types/Instance";
+import { UpdateData } from "@src/@types/MainItem";
+import transferStore from "@src/stores/TransferStore";
+
+import TransferItemModal from "./TransferItemModal";
+
+jest.mock("@src/stores/ProviderStore", () => ({
+ __esModule: true,
+ default: {
+ providers: {},
+ sourceSchema: [],
+ destinationSchema: [],
+ sourceSchemaLoading: false,
+ destinationSchemaLoading: false,
+ sourceOptionsPrimaryLoading: false,
+ sourceOptionsSecondaryLoading: false,
+ destinationOptionsPrimaryLoading: false,
+ destinationOptionsSecondaryLoading: false,
+ loadProviders: jest.fn(),
+ loadOptionsSchema: jest.fn(),
+ getOptionsValues: jest.fn(),
+ hasExecuteNowOptions: jest.fn(() => true),
+ },
+ getFieldChangeOptions: jest.fn(() => null),
+}));
+jest.mock("@src/stores/TransferStore", () => ({
+ __esModule: true,
+ default: { update: jest.fn() },
+}));
+jest.mock("@src/stores/EndpointStore", () => ({
+ __esModule: true,
+ default: {
+ storageBackends: [],
+ storageConfigDefault: "storage-config-default",
+ storageLoading: false,
+ loadStorage: jest.fn(),
+ },
+}));
+jest.mock("@src/stores/MinionPoolStore", () => ({
+ __esModule: true,
+ default: { minionPools: [], loadMinionPools: jest.fn() },
+}));
+jest.mock("@src/stores/NetworkStore", () => ({
+ __esModule: true,
+ default: { loadNetworks: jest.fn() },
+}));
+jest.mock("@src/utils/Config", () => ({
+ __esModule: true,
+ default: {
+ config: {
+ extraOptionsApiCalls: [],
+ passwordFields: [],
+ },
+ },
+}));
+
+// the network which `TRANSFER_ITEM_DETAILS_MOCK.network_map` is mapping
+const MAPPED_NETWORK_NAME = "network-name";
+const NEW_NETWORK_NAME = "new-network-name";
+
+const buildInstance = (networkName: string): Instance => ({
+ ...INSTANCE_MOCK,
+ devices: {
+ ...INSTANCE_MOCK.devices,
+ nics: [{ ...INSTANCE_MOCK.devices.nics[0], network_name: networkName }],
+ },
+});
+
+const openNetworkMapping = async () => {
+ const navigationItem = Array.from(
+ TestUtils.selectAll("Panel__NavigationItem"),
+ ).find(item => item.textContent === "Network Mapping");
+ await act(async () => {
+ navigationItem!.click();
+ });
+};
+
+const selectFirstNetwork = async () => {
+ await act(async () => {
+ TestUtils.selectAll("DropdownButton__Wrapper")[0].click();
+ });
+ await act(async () => {
+ TestUtils.selectAll("Dropdown__ListItem-")[0].click();
+ });
+};
+
+const getSourceNicNames = () =>
+ Array.from(TestUtils.selectAll("WizardNetworks__NetworkName")).map(
+ nic => nic.textContent,
+ );
+
+const getSelectedNetworkLabel = () =>
+ TestUtils.select("DropdownButton__Label")!.textContent;
+
+const getSentUpdateData = (): UpdateData =>
+ jest.mocked(transferStore.update).mock.calls[0][0].updateData;
+
+describe("TransferItemModal", () => {
+ let defaultProps: TransferItemModal["props"];
+
+ beforeEach(() => {
+ jest.clearAllMocks();
+
+ defaultProps = {
+ type: "transfer",
+ isOpen: true,
+ transfer: TRANSFER_ITEM_DETAILS_MOCK,
+ sourceEndpoint: VMWARE_ENDPOINT_MOCK,
+ destinationEndpoint: OPENSTACK_ENDPOINT_MOCK,
+ instancesDetails: [buildInstance(MAPPED_NETWORK_NAME)],
+ instancesDetailsLoading: false,
+ networks: [NETWORK_MOCK],
+ networksLoading: false,
+ onRequestClose: jest.fn(),
+ onUpdateComplete: jest.fn(),
+ onReloadClick: jest.fn(),
+ };
+ });
+
+ it("renders without crashing", () => {
+ const { getByText } = render();
+ expect(getByText("Edit Transfer")).toBeTruthy();
+ });
+
+ it("renders the mapping of the source NIC", async () => {
+ render();
+ await openNetworkMapping();
+
+ expect(getSourceNicNames()).toEqual([MAPPED_NETWORK_NAME]);
+ expect(getSelectedNetworkLabel()).toBe(NETWORK_MOCK.name);
+ });
+
+ it("renders the NIC which was attached to another network on the source", async () => {
+ render(
+ ,
+ );
+ await openNetworkMapping();
+
+ expect(getSourceNicNames()).toEqual([NEW_NETWORK_NAME]);
+ expect(getSelectedNetworkLabel()).toBe("Select Network");
+ });
+
+ it("keeps the existing mappings when there are no instances details", async () => {
+ render();
+ await openNetworkMapping();
+
+ expect(getSourceNicNames()).toEqual([MAPPED_NETWORK_NAME]);
+ });
+
+ it("sends the mapping of the source NIC on update", async () => {
+ const { getByText } = render();
+ await openNetworkMapping();
+ await selectFirstNetwork();
+ await act(async () => {
+ getByText("Update").click();
+ });
+
+ expect(
+ getSentUpdateData().network.map(
+ mapping => mapping.sourceNic.network_name,
+ ),
+ ).toEqual([MAPPED_NETWORK_NAME]);
+ });
+
+ it("doesn't send the mappings of the networks removed from the source NICs", async () => {
+ const { getByText } = render(
+ ,
+ );
+ await openNetworkMapping();
+ await selectFirstNetwork();
+ await act(async () => {
+ getByText("Update").click();
+ });
+
+ expect(
+ getSentUpdateData().network.map(
+ mapping => mapping.sourceNic.network_name,
+ ),
+ ).toEqual([NEW_NETWORK_NAME]);
+ });
+
+ it("fires onReloadClick on reload", async () => {
+ render();
+ await act(async () => {
+ TestUtils.select("Panel__ReloadButton")!.click();
+ });
+
+ expect(defaultProps.onReloadClick).toHaveBeenCalled();
+ });
+});
diff --git a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx
index 00671acb..4b6071a8 100644
--- a/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx
+++ b/src/components/modules/TransferModule/TransferItemModal/TransferItemModal.tsx
@@ -247,12 +247,35 @@ class TransferItemModal extends React.Component {
return storageMap;
}
+ getSourceNicsNetworkNames(): string[] {
+ const networkNames: string[] = [];
+ this.props.instancesDetails.forEach(instance => {
+ instance.devices?.nics?.forEach(nic => {
+ if (!networkNames.includes(nic.network_name)) {
+ networkNames.push(nic.network_name);
+ }
+ });
+ });
+ return networkNames;
+ }
+
getSelectedNetworks(): NetworkMap[] {
const selectedNetworks: NetworkMap[] = [];
const networkMap: any = this.props.transfer.network_map;
+ const sourceNicsNetworkNames = this.getSourceNicsNetworkNames();
if (networkMap) {
Object.keys(networkMap).forEach(sourceNetworkName => {
+ // the NICs can be changed on the source platform, so the mappings of
+ // the networks which are no longer attached to the instances are
+ // dropped, otherwise they would be sent again on update
+ if (
+ sourceNicsNetworkNames.length &&
+ !sourceNicsNetworkNames.includes(sourceNetworkName)
+ ) {
+ return;
+ }
+
// if the network mapping was updated, just use the new mapping instead of the old one
const updatedMapping = this.state.selectedNetworks.find(
m => m.sourceNic.network_name === sourceNetworkName,
diff --git a/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx b/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx
index b528dc60..8d18cb6b 100644
--- a/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx
+++ b/src/components/smart/TransferDetailsPage/TransferDetailsPage.tsx
@@ -48,7 +48,7 @@ import ObjectUtils from "@src/utils/ObjectUtils";
import replicaImage from "./images/replica.svg";
import liveMigrationImage from "./images/live_migration.svg";
-import type { InstanceScript } from "@src/@types/Instance";
+import type { Instance, InstanceScript } from "@src/@types/Instance";
import type { Execution } from "@src/@types/Execution";
import type { Schedule } from "@src/@types/Schedule";
import type { Field } from "@src/@types/Field";
@@ -220,6 +220,12 @@ class TransferDetailsPage extends React.Component {
return transferStore.transferDetails;
}
+ get instancesDetails(): Instance[] {
+ return this.state.dbInstancesDetails.length
+ ? this.state.dbInstancesDetails
+ : instanceStore.instancesDetails;
+ }
+
getLastExecution() {
const executions = transferStore.executionsList;
if (executions.length) {
@@ -301,6 +307,7 @@ class TransferDetailsPage extends React.Component {
cache: boolean;
transferId?: string;
showLoading?: boolean;
+ reloadInstances?: boolean;
onDetailsLoaded?: () => void;
}) {
await transferStore.getTransferDetails({
@@ -348,9 +355,13 @@ class TransferDetailsPage extends React.Component {
);
}
- if (this.hasStoredVmInfo(transfer.info)) {
+ // The VM info stored on the transfer is only refreshed by a new execution,
+ // so on an explicit reload the instances details are loaded from the source
+ // platform, in order to pick up source side changes, such as a changed NIC.
+ if (this.hasStoredVmInfo(transfer.info) && !options.reloadInstances) {
this.populateInstanceStoreFromTransferInfo(transfer.info);
} else {
+ this.setState({ dbInstancesDetails: [] });
instanceStore.loadInstancesDetails({
endpointId: transfer.origin_endpoint_id,
instances: transfer.instances.map(n => ({ id: n })),
@@ -389,7 +400,10 @@ class TransferDetailsPage extends React.Component {
const transfer = this.transfer;
if (
transfer &&
- !this.state.dbInstancesDetails.length &&
+ // the stored VM info is only used when there are no instances details
+ // loaded, or being loaded, from the source platform
+ !this.instancesDetails.length &&
+ !instanceStore.loadingInstancesDetails &&
this.hasStoredVmInfo(transfer.info)
) {
this.populateInstanceStoreFromTransferInfo(transfer.info);
@@ -692,7 +706,7 @@ class TransferDetailsPage extends React.Component {
}
handleEditTransferReload() {
- this.loadTransferWithInstances({ cache: false });
+ this.loadTransferWithInstances({ cache: false, reloadInstances: true });
}
handleUpdateComplete(redirectTo: string) {
@@ -740,9 +754,7 @@ class TransferDetailsPage extends React.Component {
}}
transfer={transfer}
destinationEndpoint={destinationEndpoint}
- instancesDetails={
- this.state.dbInstancesDetails || instanceStore.instancesDetails
- }
+ instancesDetails={this.instancesDetails}
instancesDetailsLoading={instanceStore.loadingInstancesDetails}
networks={networkStore.networks}
networksLoading={networkStore.loading}
@@ -843,9 +855,7 @@ class TransferDetailsPage extends React.Component {
{
m.platform === "destination",
)}
loadingInstances={instanceStore.loadingInstancesDetails}
- instances={
- this.state.dbInstancesDetails || instanceStore.instancesDetails
- }
+ instances={this.instancesDetails}
onCancelClick={() => {
this.handleCloseDeploymentModal();
}}