From 0e7898c6019ba0f7ebd617200697796784a90638 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Wed, 16 Sep 2026 16:41:46 +0300 Subject: [PATCH 1/2] Reload instance details from the source `Reload All Options` re-derived them from the VM info stored on the transfer, which only an execution refreshes, so it never queried the origin endpoint. Also fixes the instance details fallback, unreachable because an empty `dbInstancesDetails` array is truthy. Signed-off-by: Mihaela Balutoiu --- .../TransferDetailsPage.tsx | 34 ++++++++++++------- 1 file changed, 21 insertions(+), 13 deletions(-) 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(); }} From 9099e4b6eeae34fa328c9ab4e1c7d3fddd976d60 Mon Sep 17 00:00:00 2001 From: Mihaela Balutoiu Date: Wed, 16 Sep 2026 16:43:38 +0300 Subject: [PATCH 2/2] Replace the mapping of a changed source NIC `getSelectedNetworks()` rebuilt a mapping for every key of the transfer's `network_map`, so mapping a NIC's new source network added to the map instead of replacing the obsolete entry. Only the mappings of the NICs currently attached to the instances are sent now, unless there are no instance details. Signed-off-by: Mihaela Balutoiu --- .../TransferItemModal.spec.tsx | 222 ++++++++++++++++++ .../TransferItemModal/TransferItemModal.tsx | 23 ++ 2 files changed, 245 insertions(+) create mode 100644 src/components/modules/TransferModule/TransferItemModal/TransferItemModal.spec.tsx 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,