This is an automated email from the ASF dual-hosted git repository. Pearl1594 pushed a commit to branch support-multi-vlan-guestnet in repository https://gitbox.apache.org/repos/asf/cloudstack.git
commit fb2e91f41d5c4c57c65765c2de2034be3df761f7 Author: Pearl1594 <[email protected]> AuthorDate: Wed Oct 7 12:55:30 2026 -0400 Finish VM deploy/VNF UI, migration host-readiness, and trunk-association rule/listing support - VM deploy/VNF UI: nicnetworkslist grouping in DeployVM/DeployVnfAppliance, zone-flag gating in NicsTab/NicsTable/host list, MigrateWizard unsuitable- reason tooltip - Migration: expose getMultiNetworkNicUnsuitableReason via findHostsForMigration/HostForMigrationResponse; destination-capability- driven bridge/VLAN-XML rewrite in LibvirtMigrateCommandWrapper now also covers a destination-only vlan_filtering boundary, not just the source's - DHCP: VR full-rebuild (createDhcpEntryCommandsForVMs) now includes a nic's trunk associations, not just its primary network - Billing: live associate/disassociate on a Running Instance now bills immediately instead of waiting for the next VM start - Association API hardening: admin-only authorization on associate/change- primary/disassociate, access check on requested networks, CIDR-overlap error includes network names - listNics/listVirtualMachines now also match a nic via its trunk associations, not just its primary network, so Static NAT/Port Forwarding/ Load Balancing pickers can target an associated network's IP, labeled distinctly from primary/secondary IPs --- .../java/com/cloud/server/ManagementService.java | 7 + .../admin/host/FindHostsForMigrationCmd.java | 7 + .../admin/network/AssociateNetworkToNicCmd.java | 4 +- .../admin/network/ChangeNicPrimaryNetworkCmd.java | 4 +- .../network/DisassociateNetworkFromNicCmd.java | 4 +- .../api/command/user/vm/BaseDeployVMCmd.java | 2 +- .../api/response/HostForMigrationResponse.java | 8 + .../main/java/com/cloud/event/UsageEventUtils.java | 5 +- .../engine/orchestration/NetworkOrchestrator.java | 15 ++ .../wrapper/LibvirtMigrateCommandWrapper.java | 7 +- .../wrapper/LibvirtMigrateCommandWrapperTest.java | 18 ++ .../java/com/cloud/api/query/QueryManagerImpl.java | 41 ++++ .../java/com/cloud/network/NetworkServiceImpl.java | 45 +++- .../cloud/network/router/CommandSetupHelper.java | 30 +++ .../com/cloud/server/ManagementServerImpl.java | 14 ++ .../com/cloud/network/NetworkServiceImplTest.java | 38 ++++ ui/public/locales/en.json | 22 ++ ui/src/components/view/ListView.vue | 13 +- ui/src/config/section/infra/hosts.js | 2 +- ui/src/utils/multiNetworkNic.js | 89 ++++++++ ui/src/views/compute/DeployVM.vue | 76 +++++-- ui/src/views/compute/DeployVnfAppliance.vue | 37 +++- ui/src/views/compute/MigrateWizard.vue | 5 + .../views/compute/wizard/NetworkConfiguration.vue | 228 ++++++++++++++++++--- ui/src/views/compute/wizard/VnfNicsSelection.vue | 173 +++++++++++++++- ui/src/views/infra/Resources.vue | 47 ++++- ui/src/views/network/EnableStaticNat.vue | 51 ++--- ui/src/views/network/InternalLBAssignVmForm.vue | 34 +-- ui/src/views/network/LoadBalancing.vue | 38 ++-- ui/src/views/network/NicsTab.vue | 159 +++++++++++++- ui/src/views/network/NicsTable.vue | 47 ++++- ui/src/views/network/PortForwarding.vue | 43 ++-- 32 files changed, 1171 insertions(+), 142 deletions(-) diff --git a/api/src/main/java/com/cloud/server/ManagementService.java b/api/src/main/java/com/cloud/server/ManagementService.java index bcca229c06b..029882f95dd 100644 --- a/api/src/main/java/com/cloud/server/ManagementService.java +++ b/api/src/main/java/com/cloud/server/ManagementService.java @@ -472,6 +472,13 @@ public interface ManagementService { Ternary<Pair<List<? extends Host>, Integer>, List<? extends Host>, Map<Host, Boolean>> listHostsForMigrationOfVM(VirtualMachine vm, Long startIndex, Long pageSize, String keyword, List<VirtualMachine> vmList); + /** + * If vm has a multi-VLAN trunk NIC and host is not ready for it (VLAN filtering not enabled), returns a + * human-readable reason; null otherwise (including when vm has no such NIC, in which case host readiness + * for this concern is irrelevant). + */ + String getMultiNetworkNicUnsuitableReason(VirtualMachine vm, Host host); + /** * Apply affinity group constraints and other exclusion rules for VM migration. * This is a helper method that can be used independently for per-iteration affinity checks in DRS. diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java index 4d6ef741961..a56880de66c 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/host/FindHostsForMigrationCmd.java @@ -32,6 +32,7 @@ import org.apache.cloudstack.api.response.UserVmResponse; import com.cloud.host.Host; import com.cloud.utils.Pair; import com.cloud.utils.Ternary; +import com.cloud.vm.VirtualMachine; @APICommand(name = "findHostsForMigration", description = "Find hosts suitable for migrating an Instance.", responseObject = HostForMigrationResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false) @@ -73,6 +74,8 @@ public class FindHostsForMigrationCmd extends BaseListCmd { List<? extends Host> hostsWithCapacity = hostsForMigration.second(); hostsRequiringStorageMotion = hostsForMigration.third(); + VirtualMachine vm = _entityMgr.findById(VirtualMachine.class, getVirtualMachineId()); + response = new ListResponse<HostForMigrationResponse>(); List<HostForMigrationResponse> hostResponses = new ArrayList<HostForMigrationResponse>(); for (Host host : result.first()) { @@ -90,6 +93,10 @@ public class FindHostsForMigrationCmd extends BaseListCmd { hostResponse.setRequiresStorageMotion(false); } + if (!suitableForMigration && vm != null) { + hostResponse.setUnsuitableReason(_mgr.getMultiNetworkNicUnsuitableReason(vm, host)); + } + hostResponse.setObjectName("host"); hostResponses.add(hostResponse); } diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java index 4b47a5e9462..2fa45197ea5 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/AssociateNetworkToNicCmd.java @@ -21,6 +21,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; @@ -45,7 +46,8 @@ import com.cloud.vm.Nic; import com.cloud.vm.VirtualMachine; @APICommand(name = "associateNetworkToNic", description = "Associates one or more additional networks with an existing NIC, delivering them as a multi-VLAN trunk", - responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0") + responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0", + authorized = {RoleType.Admin}) public class AssociateNetworkToNicCmd extends BaseAsyncCmd { private static final String s_name = "associatenetworktonicresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java index ac2e2ce2d55..93d48aa1dd2 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/ChangeNicPrimaryNetworkCmd.java @@ -16,6 +16,7 @@ // under the License. package org.apache.cloudstack.api.command.admin.network; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; @@ -36,7 +37,8 @@ import com.cloud.vm.VirtualMachine; @APICommand(name = "changeNicPrimaryNetwork", description = "Changes a multi-VLAN trunk NIC's primary (native/untagged) network to one of its already-associated networks. " + "Only allowed while the owning Instance is stopped. The operator is responsible for confirming the guest will " + "reacquire its address on the new primary network (e.g. via DHCP) after the next start - CloudStack cannot verify this from the host side.", - responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0") + responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0", + authorized = {RoleType.Admin}) public class ChangeNicPrimaryNetworkCmd extends BaseAsyncCmd { private static final String s_name = "changenicprimarynetworkresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java index ad860a4efe2..58b1450c032 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/admin/network/DisassociateNetworkFromNicCmd.java @@ -16,6 +16,7 @@ // under the License. package org.apache.cloudstack.api.command.admin.network; +import org.apache.cloudstack.acl.RoleType; import org.apache.cloudstack.api.APICommand; import org.apache.cloudstack.api.ApiCommandResourceType; import org.apache.cloudstack.api.ApiConstants; @@ -35,7 +36,8 @@ import com.cloud.vm.Nic; import com.cloud.vm.VirtualMachine; @APICommand(name = "disassociateNetworkFromNic", description = "Disassociates a network from an existing NIC, shrinking its multi-VLAN trunk", - responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0") + responseObject = NicResponse.class, requestHasSensitiveInfo = false, responseHasSensitiveInfo = false, since = "24.0.0", + authorized = {RoleType.Admin}) public class DisassociateNetworkFromNicCmd extends BaseAsyncCmd { private static final String s_name = "disassociatenetworkfromnicresponse"; diff --git a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java index 81b1debd1d8..7975c9279ea 100644 --- a/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java +++ b/api/src/main/java/org/apache/cloudstack/api/command/user/vm/BaseDeployVMCmd.java @@ -194,7 +194,7 @@ public abstract class BaseDeployVMCmd extends BaseAsyncCreateCustomIdCmd impleme private Map ipToNetworkList; @Parameter(name = ApiConstants.NIC_NETWORKS_LIST, type = CommandType.MAP, since = "24.0.0", - authorized = {RoleType.Admin, RoleType.ResourceAdmin, RoleType.DomainAdmin}, + authorized = {RoleType.Admin}, description = "one entry per nic, each naming the set of networks that nic is associated with as a multi-VLAN trunk. The first network id in each entry is the nic's primary network." + " Can't be specified with networkIds or ipToNetworkList parameters." + " Optional ip4addresses/ip6addresses request specific IPs for the entry's associated (non-primary) networks - comma-separated," diff --git a/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java b/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java index b3853d9cc24..6143ce8de72 100644 --- a/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java +++ b/api/src/main/java/org/apache/cloudstack/api/response/HostForMigrationResponse.java @@ -29,7 +29,15 @@ public class HostForMigrationResponse extends HostResponse { @Param(description = "True if migrating an Instance to this host requires storage motion, false otherwise") private Boolean requiresStorageMotion; + @SerializedName("unsuitablereason") + @Param(description = "If this host is not suitable for migration, the reason why; null when suitable") + private String unsuitableReason; + public void setRequiresStorageMotion(Boolean requiresStorageMotion) { this.requiresStorageMotion = requiresStorageMotion; } + + public void setUnsuitableReason(String unsuitableReason) { + this.unsuitableReason = unsuitableReason; + } } diff --git a/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java b/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java index 753774f9360..6209cc04b4a 100644 --- a/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java +++ b/engine/components-api/src/main/java/com/cloud/event/UsageEventUtils.java @@ -144,7 +144,10 @@ public class UsageEventUtils { } } - private static void publishNicNetworkOfferingUsageEvent(String usageType, long accountId, long zoneId, long vmId, String entityType, String entityUUID, + // Public so a single association can be billed immediately outside the nic-wide loop above - e.g. a live + // associateNetworkToNic/disassociateNetworkFromNic on a Running VM, where only the one network just + // (dis)associated should be billed, not every network the nic already has. + public static void publishNicNetworkOfferingUsageEvent(String usageType, long accountId, long zoneId, long vmId, String entityType, String entityUUID, long nicId, NetworkVO network, long isDefault, boolean displayResource) { Map<String, String> details = Collections.singletonMap(UsageEventVO.DynamicParameters.networkId.name(), String.valueOf(network.getId())); publishUsageEventWithDetails(usageType, accountId, zoneId, vmId, Long.toString(nicId), network.getNetworkOfferingId(), null, isDefault, entityType, entityUUID, diff --git a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java index f676882211a..7901532b0b0 100644 --- a/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java +++ b/engine/orchestration/src/main/java/org/apache/cloudstack/engine/orchestration/NetworkOrchestrator.java @@ -4130,6 +4130,9 @@ public class NetworkOrchestrator extends ManagerBase implements NetworkOrchestra result = _nicDao.listByVmId(vmId); } else { result = _nicDao.listByVmIdAndNicIdAndNtwkId(vmId, nicId, networkId); + if (networkId != null) { + result = addNicsWithNetworkAssociation(result, vmId, nicId, networkId); + } } } else { result = _nicDao.listByVmIdAndKeyword(vmId, keyword); @@ -4158,6 +4161,18 @@ public class NetworkOrchestrator extends ManagerBase implements NetworkOrchestra return result; } + // A nic whose primary network is something else but is also associated with networkId (multi-VLAN trunk nic) + // is invisible to the primary-only search above, so it needs a separate associations-based lookup here. + private List<NicVO> addNicsWithNetworkAssociation(final List<NicVO> primaryMatches, final long vmId, final Long nicId, final long networkId) { + final List<NicVO> result = new ArrayList<>(primaryMatches); + for (final NicVO candidate : _nicDao.listByVmIdAndNicIdAndNtwkId(vmId, nicId, null)) { + if (!result.contains(candidate) && _nicNetworkMapDao.findByNicIdAndNetworkId(candidate.getId(), networkId) != null) { + result.add(candidate); + } + } + return result; + } + @DB @Override public boolean reallocate(final VirtualMachineProfile vm, final DataCenterDeployment dest) throws InsufficientCapacityException, ConcurrentOperationException { diff --git a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java index 48f58e25d5d..2396227bc49 100644 --- a/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java +++ b/plugins/hypervisors/kvm/src/main/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapper.java @@ -699,9 +699,12 @@ public final class LibvirtMigrateCommandWrapper extends CommandWrapper<MigrateCo throws ParserConfigurationException, IOException, SAXException, TransformerException { NicTO[] nics = command.getVirtualMachine().getNics(); boolean sourceVlanFilteringEnabled = libvirtComputingResource.hostSupportsVlanFiltering(); + // destination-shared-bridge is also a rewrite trigger: a legacy-source nic's bridge name (breth1-<vid>) + // doesn't exist on a shared-bridge destination, needs rewriting to cloudbr1 just as much as the reverse + boolean destVlanFilteringEnabled = Boolean.TRUE.equals(command.getDestVlanFilteringEnabled()); boolean anyNicNeedsHandling = false; for (NicTO nic : nics) { - if (nic.getBroadcastType() == Networks.BroadcastDomainType.Vlan && (sourceVlanFilteringEnabled || nic.isTrunkVlan())) { + if (nic.getBroadcastType() == Networks.BroadcastDomainType.Vlan && (sourceVlanFilteringEnabled || destVlanFilteringEnabled || nic.isTrunkVlan())) { anyNicNeedsHandling = true; break; } @@ -732,7 +735,7 @@ public final class LibvirtMigrateCommandWrapper extends CommandWrapper<MigrateCo String mac = findInterfaceMacAddress(interfaceElement); NicTO nic = mac == null ? null : findNicByMac(nics, mac); if (nic == null || nic.getBroadcastType() != Networks.BroadcastDomainType.Vlan - || !(sourceVlanFilteringEnabled || nic.isTrunkVlan())) { + || !(sourceVlanFilteringEnabled || destVlanFilteringEnabled || nic.isTrunkVlan())) { continue; } replaceVlanTrunkInterface(doc, interfaceElement, nic, command); diff --git a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java index 9e9a5ce61c3..410f5137cbd 100644 --- a/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java +++ b/plugins/hypervisors/kvm/src/test/java/com/cloud/hypervisor/kvm/resource/wrapper/LibvirtMigrateCommandWrapperTest.java @@ -1005,6 +1005,24 @@ public class LibvirtMigrateCommandWrapperTest { assertEquals(vlanNicDomainXml, result); } + @Test + public void replaceVlanTrunkInterfacesRewritesOrdinaryNicBridgeWhenOnlyDestIsFilteringEnabled() + throws ParserConfigurationException, IOException, SAXException, TransformerException { + // legacy (non-filtering) source -> shared-bridge destination: the source's own per-VLAN bridge name + // doesn't exist on the destination, so the rewrite must fire even though the source isn't shared-bridge + NicTO nic = buildVlanNicTO(false, 100, null); + Mockito.doReturn(new NicTO[]{nic}).when(virtualMachineTOMock).getNics(); + Mockito.doReturn(virtualMachineTOMock).when(migrateCommandMock).getVirtualMachine(); + Mockito.doReturn(false).when(libvirtComputingResourceMock).hostSupportsVlanFiltering(); + Mockito.doReturn(true).when(migrateCommandMock).getDestVlanFilteringEnabled(); + Mockito.doReturn(false).when(migrateCommandMock).getDestVlanTrunkXmlSupported(); + Mockito.doReturn(Map.of(vlanNicMac, "destcloudbr0")).when(migrateCommandMock).getNicBridgeMapping(); + + String result = libvirtMigrateCmdWrapper.replaceVlanTrunkInterfaces(vlanNicDomainXml, migrateCommandMock, libvirtComputingResourceMock); + + assertTrue(result.contains("bridge=\"destcloudbr0\"")); + } + @Test public void replaceVlanTrunkInterfacesInsertsTrunkVlanBlockWhenDestSupportsTrunkXml() throws ParserConfigurationException, IOException, SAXException, TransformerException { diff --git a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java index d700fba5a78..d76c7fc6742 100644 --- a/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java +++ b/server/src/main/java/com/cloud/api/query/QueryManagerImpl.java @@ -376,6 +376,8 @@ import com.cloud.vm.VmDetailConstants; import com.cloud.vm.dao.DomainRouterDao; import com.cloud.vm.dao.InstanceGroupVMMapDao; import com.cloud.vm.dao.NicDao; +import com.cloud.vm.dao.NicNetworkMapDao; +import com.cloud.vm.dao.NicNetworkMapVO; import com.cloud.vm.dao.UserVmDao; import com.cloud.vm.dao.VMInstanceDao; import com.cloud.vm.dao.VMInstanceDetailsDao; @@ -592,6 +594,9 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q @Inject NicDao nicDao; + @Inject + NicNetworkMapDao nicNetworkMapDao; + @Inject HostDao hostDao; @@ -1776,9 +1781,45 @@ public class QueryManagerImpl extends MutualExclusiveIdsManagerBase implements Q Integer count = uniqueVmPair.second(); List<Long> vmIds = uniqueVmPair.first().stream().map(VMInstanceVO::getId).collect(Collectors.toList()); + + if (networkId != null) { + return addVmsWithNetworkAssociation(vmIds, count, networkId, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria); + } return new Pair<>(vmIds, count); } + // A nic whose primary network is something else but is also associated with networkId (multi-VLAN trunk nic) + // is invisible to the primary-nic-only search above, so it needs a separate, additive lookup here. The ACL + // check is re-applied via the same builder the primary search above already used, rather than re-implemented, + // so visibility rules can't drift between the two. + private Pair<List<Long>, Integer> addVmsWithNetworkAssociation(List<Long> vmIds, Integer count, Long networkId, Long domainId, Boolean isRecursive, + List<Long> permittedAccounts, ListProjectResourcesCriteria listProjectResourcesCriteria) { + Set<Long> candidateVmIds = new HashSet<>(); + for (Long nicId : nicNetworkMapDao.listNicIdsByNetworkId(networkId)) { + NicVO nic = nicDao.findById(nicId); + if (nic != null && !vmIds.contains(nic.getInstanceId())) { + candidateVmIds.add(nic.getInstanceId()); + } + } + if (candidateVmIds.isEmpty()) { + return new Pair<>(vmIds, count); + } + + SearchBuilder<UserVmVO> assocSearchBuilder = userVmDao.createSearchBuilder(); + accountMgr.buildACLSearchBuilder(assocSearchBuilder, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria); + assocSearchBuilder.and("idIN", assocSearchBuilder.entity().getId(), Op.IN); + SearchCriteria<UserVmVO> assocCriteria = assocSearchBuilder.create(); + accountMgr.buildACLSearchCriteria(assocCriteria, domainId, isRecursive, permittedAccounts, listProjectResourcesCriteria); + assocCriteria.setParameters("idIN", candidateVmIds.toArray()); + List<UserVmVO> visibleAssociatedVms = userVmDao.search(assocCriteria, null); + + List<Long> mergedIds = new ArrayList<>(vmIds); + for (UserVmVO vm : visibleAssociatedVms) { + mergedIds.add(vm.getId()); + } + return new Pair<>(mergedIds, count + visibleAssociatedVms.size()); + } + @Override public ListResponse<SecurityGroupResponse> searchForSecurityGroups(ListSecurityGroupsCmd cmd) { Pair<List<SecurityGroupJoinVO>, Integer> result = searchForSecurityGroupsInternal(cmd); diff --git a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java index 7c2e356fb28..4f94f00864d 100644 --- a/server/src/main/java/com/cloud/network/NetworkServiceImpl.java +++ b/server/src/main/java/com/cloud/network/NetworkServiceImpl.java @@ -939,6 +939,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C refreshNicVlanMappingMetadata(nic); for (NetworkVO network : newlyAssociatedNetworks) { pushDhcpEntryForAssociation(vm, nic, network); + publishAssociationUsageEvent(vm, nic, network, EventTypes.EVENT_NETWORK_OFFERING_ASSIGN); } } @@ -1006,14 +1007,14 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C vnfTemplateManager.validateVnfApplianceTrunkNic(template, nic.getDeviceId()); } - Pair<List<NetworkVO>, List<NetworkVO>> networkSets = buildResultingNetworkSet(vm, nic, primaryNetwork, requestedNetworkIds); + Pair<List<NetworkVO>, List<NetworkVO>> networkSets = buildResultingNetworkSet(vm, nic, primaryNetwork, requestedNetworkIds, caller); List<NetworkVO> resultingSet = networkSets.first(); List<NetworkVO> requestedNetworks = networkSets.second(); validateTrunkSet(primaryNetwork, resultingSet); validateNoOverlappingCidrs(resultingSet); implementRequestedNetworks(requestedNetworks, vm, caller); - validateNoDuplicateVlanAfterImplement(resultingSet); + validateNoDuplicateVlanAfterImplement(resultingSet, requestedNetworks); persistAssociations(nic, vm, requestedNetworks, ipAddressesMap); return requestedNetworks; } @@ -1059,6 +1060,10 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C if (vmRunning) { cleanupDhcpEntryForAssociation(vm, nic, targetNetworkId, association.getIp4Address(), association.getIp6Address()); + NetworkVO targetNetwork = _networksDao.findById(targetNetworkId); + if (targetNetwork != null) { + publishAssociationUsageEvent(vm, nic, targetNetwork, EventTypes.EVENT_NETWORK_OFFERING_REMOVE); + } updateLiveVlanTrunkMembership(vm, nic); refreshNicVlanMappingMetadata(nic); } @@ -1148,6 +1153,14 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C sendDhcpEntryCommandForAssociation(vm, nic, network, association.getIp4Address(), association.getIp6Address(), false); } + // A live associate/disassociate on a Running Instance has no nic-plug/VM-start of its own to piggyback + // billing on, so without this the association would go unbilled until the VM's next stop/start. Bills + // only the one network just (dis)associated, never isDefault (an association is never a nic's default). + private void publishAssociationUsageEvent(VirtualMachine vm, NicVO nic, NetworkVO network, String eventType) { + UsageEventUtils.publishNicNetworkOfferingUsageEvent(eventType, vm.getAccountId(), vm.getDataCenterId(), vm.getId(), + VirtualMachine.class.getName(), vm.getUuid(), nic.getId(), network, 0L, vm.isDisplay()); + } + // Counterpart to pushDhcpEntryForAssociation, for a Running Instance's disassociate. private void cleanupDhcpEntryForAssociation(VirtualMachine vm, NicVO nic, long networkId, String ip4Address, String ip6Address) throws ResourceUnavailableException { NetworkVO network = _networksDao.findById(networkId); @@ -1235,7 +1248,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C return vm; } - private Pair<List<NetworkVO>, List<NetworkVO>> buildResultingNetworkSet(VirtualMachine vm, NicVO nic, NetworkVO primaryNetwork, List<Long> requestedNetworkIds) { + private Pair<List<NetworkVO>, List<NetworkVO>> buildResultingNetworkSet(VirtualMachine vm, NicVO nic, NetworkVO primaryNetwork, List<Long> requestedNetworkIds, Account caller) { List<NicNetworkMapVO> existingAssociations = _nicNetworkMapDao.listByNicId(nic.getId()); Set<Long> associatedNetworkIds = new HashSet<>(); associatedNetworkIds.add(primaryNetwork.getId()); @@ -1268,6 +1281,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C if (requestedNetwork == null) { throw new InvalidParameterValueException(String.format("Unable to find network with id %d", requestedNetworkId)); } + _accountMgr.checkAccess(caller, null, true, requestedNetwork); requestedNetworks.add(requestedNetwork); resultingSet.add(requestedNetwork); } @@ -1329,9 +1343,9 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C String cidrB = networkCidrs.get(j).second(); if (NetUtils.isNetworksOverlap(cidrA, cidrB)) { throw new InvalidParameterValueException(String.format( - "Networks %s and %s have overlapping subnets (%s and %s) and cannot be associated with the same nic; " + + "Networks %s (%s) and %s (%s) have overlapping subnets (%s and %s) and cannot be associated with the same nic; " + "a guest cannot unambiguously route between associated networks that share address space", - networkA.getUuid(), networkB.getUuid(), cidrA, cidrB)); + networkA.getName(), networkA.getUuid(), networkB.getName(), networkB.getUuid(), cidrA, cidrB)); } } } @@ -1344,7 +1358,7 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C } } - private void validateNoDuplicateVlanAfterImplement(List<NetworkVO> resultingSet) { + private void validateNoDuplicateVlanAfterImplement(List<NetworkVO> resultingSet, List<NetworkVO> requestedNetworks) { // re-fetch: implementNetworkIfNeeded may have just assigned a broadcastUri to one of the requested // networks, and existing associations are re-read too rather than trusted stale List<NetworkVO> implementedNetworks = new ArrayList<>(); @@ -1355,7 +1369,11 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C } implementedNetworks.add(refreshed); } - validateNoDuplicateVlan(implementedNetworks); + Set<Long> justRequestedIds = new HashSet<>(); + for (NetworkVO network : requestedNetworks) { + justRequestedIds.add(network.getId()); + } + validateNoDuplicateVlan(implementedNetworks, justRequestedIds); } private void persistAssociations(NicVO nic, VirtualMachine vm, List<NetworkVO> requestedNetworks, Map<Long, Network.IpAddresses> requestedIps) @@ -1422,12 +1440,21 @@ public class NetworkServiceImpl extends ManagerBase implements NetworkService, C } } - private void validateNoDuplicateVlan(List<NetworkVO> resultingSet) { + private void validateNoDuplicateVlan(List<NetworkVO> resultingSet, Set<Long> justRequestedIds) { Map<URI, NetworkVO> seenBroadcastUris = new HashMap<>(); for (NetworkVO network : resultingSet) { URI broadcastUri = network.getBroadcastUri(); if (broadcastUri == null) { - throw new CloudRuntimeException(String.format("Network %s has no VLAN assigned after implementation; this should not happen", network.getUuid())); + if (!justRequestedIds.contains(network.getId())) { + // this call never attempted to implement this network (it's the nic's existing primary, + // untouched here and only implemented later at the VM's actual start; or an association + // that already existed before this call) - whatever reason it currently lacks a VLAN is + // unrelated to this request. It isn't this method's job to flag that; a genuinely missing + // VLAN at actual nic-plug time already fails closed with its own clear error there. + continue; + } + throw new CloudRuntimeException(String.format("Network %s (%s) has no VLAN assigned after implementation; this should not happen", + network.getName(), network.getUuid())); } NetworkVO conflicting = seenBroadcastUris.put(broadcastUri, network); if (conflicting != null) { diff --git a/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java b/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java index 2fb10b622b6..9875e33be36 100644 --- a/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java +++ b/server/src/main/java/com/cloud/network/router/CommandSetupHelper.java @@ -841,6 +841,36 @@ public class CommandSetupHelper { createDhcpEntryCommand(router, vm, nic, false, cmds); } } + createDhcpEntryCommandsForAssociatedNics(router, cmds, guestNetworkId, dc, dnsBasicZoneUpdates); + } + + // A nic whose primary network is something else but is also associated with guestNetworkId (multi-VLAN + // trunk nic) is invisible to the primary-only lookup above, so a full VR rebuild would otherwise drop its + // entry - it would keep its current lease but fail to renew once this VR is the only one it can ask. + private void createDhcpEntryCommandsForAssociatedNics(final DomainRouterVO router, final Commands cmds, final long guestNetworkId, + final DataCenterVO dc, final String dnsBasicZoneUpdates) { + final NetworkVO network = _networkDao.findById(guestNetworkId); + if (network == null) { + return; + } + for (final NicNetworkMapVO association : _nicNetworkMapDao.listByNetworkId(guestNetworkId)) { + final NicVO nic = _nicDao.findById(association.getNicId()); + if (nic == null) { + continue; + } + final UserVmVO vm = _userVmDao.findById(nic.getInstanceId()); + if (vm == null || !(vm.getState() == VirtualMachine.State.Running || vm.getState() == VirtualMachine.State.Migrating + || vm.getState() == VirtualMachine.State.Stopping)) { + continue; + } + if (dc.getNetworkType() == NetworkType.Basic && router.getPodIdToDeployIn().longValue() != vm.getPodIdToDeployIn().longValue() + && dnsBasicZoneUpdates.equalsIgnoreCase("pod")) { + continue; + } + logger.debug("Creating dhcp entry for vm " + vm + "'s association with network " + guestNetworkId + " on domR " + router + "."); + createDhcpEntryCommand(router, vm, nic.getMacAddress(), association.getIp4Address(), association.getIp6Address(), + network.getGateway(), network.getIp6Gateway(), guestNetworkId, false, false, cmds); + } } public void createDeleteIpAliasCommand(final DomainRouterVO router, final List<IpAliasTO> deleteIpAliasTOs, final List<IpAliasTO> createIpAliasTos, final long networkId, diff --git a/server/src/main/java/com/cloud/server/ManagementServerImpl.java b/server/src/main/java/com/cloud/server/ManagementServerImpl.java index 7412d2e5fa9..282cd082e97 100644 --- a/server/src/main/java/com/cloud/server/ManagementServerImpl.java +++ b/server/src/main/java/com/cloud/server/ManagementServerImpl.java @@ -1766,6 +1766,20 @@ public class ManagementServerImpl extends MutualExclusiveIdsManagerBase implemen return new Ternary<>(otherHosts, suitableHosts, requiresStorageMotion); } + @Override + public String getMultiNetworkNicUnsuitableReason(final VirtualMachine vm, final Host host) { + boolean vmHasMultiNetworkNic = nicDao.listByVmId(vm.getId()).stream().anyMatch(NicVO::getMultiNetwork); + if (!vmHasMultiNetworkNic) { + return null; + } + final DetailVO detail = _detailsDao.findDetail(host.getId(), Host.HOST_VLAN_FILTERING_ENABLED); + final boolean vlanFilteringEnabled = detail != null && Boolean.parseBoolean(detail.getValue()); + if (!vlanFilteringEnabled) { + return "Instance has a multi-VLAN trunk NIC, but this host does not have VLAN filtering enabled"; + } + return null; + } + private void validateVgpuProfileForVmMigration(final VirtualMachineProfile vmProfile) { // Validate if the VM is using a vGPU profile that supports migration. ServiceOffering serviceOffering = vmProfile.getServiceOffering(); diff --git a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java index 008ce3f4dc5..ba1f9043f15 100644 --- a/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java +++ b/server/src/test/java/com/cloud/network/NetworkServiceImplTest.java @@ -92,6 +92,7 @@ import com.cloud.domain.dao.DomainDao; import com.cloud.exception.InsufficientAddressCapacityException; import com.cloud.exception.InsufficientCapacityException; import com.cloud.exception.InvalidParameterValueException; +import com.cloud.exception.PermissionDeniedException; import com.cloud.exception.ResourceAllocationException; import com.cloud.host.DetailVO; import com.cloud.host.Host; @@ -1750,6 +1751,43 @@ public class NetworkServiceImplTest { Assert.assertNotNull(result); } + @Test + public void associateNetworkToNicChecksCallerAccessToRequestedNetwork() throws Exception { + NicVO nic = mockNic(11L, 5L, 205L, false); + Mockito.when(nicDao.findById(11L)).thenReturn(nic); + UserVmVO vm = mockVm(VirtualMachine.State.Stopped, null); + Mockito.when(userVmDao.findById(5L)).thenReturn(vm); + NetworkVO primaryNetwork = mockNetwork(205L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1174)); + Mockito.when(primaryNetwork.getCidr()).thenReturn("10.1.1.0/24"); + Mockito.when(networkDao.findById(205L)).thenReturn(primaryNetwork); + Mockito.when(nicNetworkMapDao.listByNicId(11L)).thenReturn(new ArrayList<>()); + NetworkVO requestedNetwork = mockNetwork(206L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1180)); + Mockito.when(requestedNetwork.getCidr()).thenReturn("10.1.2.0/24"); + Mockito.when(networkDao.findById(206L)).thenReturn(requestedNetwork); + Mockito.when(ipAddressManagerMock.allocateGuestIP(requestedNetwork, null)).thenReturn("10.1.2.50"); + + service.associateNetworkToNic(mockAssociateCmd(11L, Arrays.asList(206L))); + + Mockito.verify(accountManager).checkAccess(accountMock, null, true, requestedNetwork); + } + + @Test(expected = PermissionDeniedException.class) + public void associateNetworkToNicFailsWhenCallerLacksAccessToRequestedNetwork() throws Exception { + NicVO nic = mockNic(11L, 5L, 205L, false); + Mockito.when(nicDao.findById(11L)).thenReturn(nic); + UserVmVO vm = mockVm(VirtualMachine.State.Stopped, null); + Mockito.when(userVmDao.findById(5L)).thenReturn(vm); + NetworkVO primaryNetwork = mockNetwork(205L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1174)); + Mockito.when(networkDao.findById(205L)).thenReturn(primaryNetwork); + Mockito.when(nicNetworkMapDao.listByNicId(11L)).thenReturn(new ArrayList<>()); + NetworkVO requestedNetwork = mockNetwork(206L, Network.GuestType.Isolated, 1L, 1L, null, Networks.BroadcastDomainType.Vlan, vlanUri(1180)); + Mockito.when(networkDao.findById(206L)).thenReturn(requestedNetwork); + Mockito.doThrow(new PermissionDeniedException("no access to network 206")) + .when(accountManager).checkAccess(accountMock, null, true, requestedNetwork); + + service.associateNetworkToNic(mockAssociateCmd(11L, Arrays.asList(206L))); + } + @Test(expected = InvalidParameterValueException.class) public void associateNetworkToNicFailsWhenRequestedNetworkIsOnADifferentPhysicalNetwork() throws Exception { NicVO nic = mockNic(11L, 5L, 205L, false); diff --git a/ui/public/locales/en.json b/ui/public/locales/en.json index 99bf2cf7aef..b4470132c47 100644 --- a/ui/public/locales/en.json +++ b/ui/public/locales/en.json @@ -443,9 +443,11 @@ "label.assign.instance.another": "Assign Instance to another Account", "label.assign.vms": "Assign Instances", "label.assigning.vms": "Assigning Instances", +"label.associate.network": "Associate Network", "label.associatednetwork": "Associated Network", "label.associatednetworkid": "Associated Network ID", "label.associatednetworkname": "Network name", +"label.associatednetworks": "Associated Networks (trunk)", "label.asyncbackup": "Async backup", "label.attach.vol.to.instance": "Attach the created Volume to an existing Instance", "label.attaching": "Attaching", @@ -937,6 +939,7 @@ "label.disable.vpn": "Disable remote access VPN", "label.disable.webhook": "Disable Webhook", "label.disabled": "Disabled", +"label.disassociate.network": "Disassociate Network", "label.disconnected": "Last disconnected", "label.discover.gpu.devices": "Discover GPU devices", "label.disk": "Disk", @@ -1317,6 +1320,7 @@ "label.host.vddk.support": "VDDK Support", "label.host.vddk.version": "VDDK Version", "label.host.tag": "Host tag", +"label.host.vlanfilteringenabled": "VLAN filtering", "label.host.virtv2v.version": "Virt-v2v Version", "label.hostcontrolstate": "Compute Resource Status", "label.hostid": "Host", @@ -1767,6 +1771,8 @@ "label.move.to.bottom": "Move to bottom", "label.move.to.top": "Move to top", "label.move.up.row": "Move up one row", +"label.multinetworknic.hosts.ready": "Multi-VLAN trunk NIC ready hosts", +"label.multinetworknic.hosts.ready.count": "{ready} of {total} hosts ready", "label.my.isos": "My ISOs", "label.my.templates": "My Templates", "label.na": "N/A", @@ -1794,6 +1800,7 @@ "label.network": "Network", "label.network.acl": "Network ACL", "label.network.acls": "Network ACLs", +"label.network.add.associated": "Add Associated Networks", "label.network.addvm": "Add Network to Instance", "label.network.desc": "Network desc", "label.network.domain": "Network Domain", @@ -1812,6 +1819,8 @@ "label.networkdevicetype": "Type", "label.networkdomain": "Network domain", "label.networkid": "Network", +"label.nic.associatednetworks": "Associated Networks", +"label.nic.trunked": "Trunked", "label.networkkbsread": "Network read", "label.networkkbswrite": "Network write", "label.networklimit": "Network limits", @@ -2895,8 +2904,10 @@ "label.vlan": "VLAN/VNI", "label.vlan.range": "VLAN/VNI range", "label.vlan.vni.ranges": "VLAN/VNI range(s)", +"label.vlanfilteringenabled": "VLAN filtering enabled", "label.vlanid": "VLAN/VNI ID", "label.vlanrange": "VLAN/VNI range", +"label.vlantrunkxmlsupported": "VLAN trunk XML supported", "label.vm": "Instance", "label.vm.add": "Add Instance", "label.vm.password": "Password of the Instance is", @@ -3321,6 +3332,9 @@ "message.assign.instance.another": "Please specify the Account type, domain, Account name and Network (optional) of the new Account. <br> If the default NIC of the Instance is on a shared Network, CloudStack will check if the Network can be used by the new Account if you do not specify one Network. <br> If the default NIC of the Instance is on a isolated Network, and the new Account has more one isolated Networks, you should specify one.", "message.assign.vm.failed": "Failed to assign Instance", "message.assign.vm.processing": "Assigning Instance...", +"message.associate.network.desc": "Select one or more additional Networks to associate with this NIC, delivering them as a multi-VLAN trunk. Requires VLAN-filtering support on the host(s) the Instance may run on.", +"message.associate.network.loop.warning": "Instances attached to this NIC will receive multiple L2 segments. Misconfiguration inside guest VMs may cause L2 loops. Ensure proper VLAN handling.", +"message.associate.network.processing": "Associating Network(s)...", "message.attach.volume": "Please fill in the following data to attach a new volume. If you are attaching a disk volume to a Windows based Instance, you will need to reboot the Instance to see the attached disk.", "message.attach.volume.failed": "Failed to attach volume.", "message.attach.volume.progress": "Attaching volume", @@ -3560,6 +3574,8 @@ "message.disable.vpn.failed": "Failed to disable VPN.", "message.disable.vpn.processing": "Disabling VPN...", "message.disable.webhook.ssl.verification": "Disabling SSL verification is not recommended", +"message.disassociate.network.confirm": "Please confirm you want to remove this Network association from the NIC.", +"message.disassociate.network.processing": "Disassociating Network...", "message.discovering.feature": "Discovering features, please wait...", "message.disk.offering.created": "Disk offering created:", "message.success.clone.backup.offering": "Successfully cloned backup offering", @@ -3593,6 +3609,8 @@ "message.error.access.key": "Please enter access key.", "message.error.account.delete.name.mismatch": "Name entered doesn't match the account name.", "message.error.add.guest.network": "Either IPv4 fields or IPv6 fields need to be filled when adding a guest Network.", +"message.error.associate.network": "Failed to associate Network(s) with NIC", +"message.error.disassociate.network": "Failed to disassociate Network from NIC", "message.error.add.interface.static.route": "Adding interface Static Route failed", "message.error.add.logical.router": "Adding Logical Router failed", "message.error.add.network.static.route": "Adding Network Static Route failed", @@ -3772,6 +3790,7 @@ "message.host.dedicated": "Host Dedicated", "message.host.dedication.released": "Host dedication released.", "message.host.external.datadisk": "Usage of data disks for the selected template is not applicable", +"message.host.vlanfilteringenabled.tooltip": "VLAN filtering is enabled on this host's guest bridge, so it can accept multi-VLAN trunk NICs", "message.import.running.instance.warning": "The selected VM is powered-on on the VMware Datacenter. The recommended state to convert a VMware VM into KVM is powered-off after a graceful shutdown of the guest OS.", "message.import.vm.tasks": "Import from VMware to KVM tasks", "message.import.volume": "Please specify the domain, account or project name. <br>If not set, the volume will be imported for the caller.", @@ -3898,6 +3917,7 @@ "message.network.restart.required": "Restart is required for network(s). Click here to view network(s) which require restart.", "message.network.secondaryip": "Please confirm that you would like to acquire a new secondary IP for this NIC. \n NOTE: You need to manually configure the newly-acquired secondary IP inside the virtual machine.", "message.network.selection": "Choose one or more Networks to attach the Instance to.", +"message.nic.trunked.tooltip": "This NIC is a multi-VLAN trunk: in addition to its primary Network, it carries traffic for the associated Networks listed below.", "message.network.selection.new.network": "A new Network can also be created here.", "message.network.updateip": "Please confirm that you would like to change the IP address for this NIC on the Instance.", "message.network.update.nic": "Please confirm that you would like to update this NIC.", @@ -4065,6 +4085,8 @@ "message.success.add.kuberversion": "Successfully added Kubernetes version", "message.success.add.logical.router": "Successfully added Logical Router", "message.success.add.network": "Successfully added Network", +"message.success.associate.network": "Successfully associated Network(s) with NIC", +"message.success.disassociate.network": "Successfully disassociated Network from NIC", "message.success.add.network.acl": "Successfully added Network ACL", "message.success.add.network.static.route": "Successfully added Network Static Route", "message.success.add.network.permissions": "Successfully added Network permissions", diff --git a/ui/src/components/view/ListView.vue b/ui/src/components/view/ListView.vue index 9a7d874fef3..cc3a98de37a 100644 --- a/ui/src/components/view/ListView.vue +++ b/ui/src/components/view/ListView.vue @@ -178,6 +178,12 @@ <warning-outlined :style="{ color: $config.theme['@warning-color'] }" /> </a-tooltip> </span> + <span v-else-if="$route.path.startsWith('/host') && record.vlanfilteringenabled && multiNetworkNicEnabledZones[record.zoneid]"> + + <a-tooltip :title="$t('message.host.vlanfilteringenabled.tooltip')"> + <a-tag color="blue">{{ $t('label.host.vlanfilteringenabled') }}</a-tag> + </a-tooltip> + </span> </span> <span v-if="record.leaseduration !== undefined" @@ -1082,6 +1088,7 @@ import cronstrue from 'cronstrue/i18n' import moment from 'moment-timezone' import { timeZoneName } from '@/utils/timezone' import { FileTextOutlined } from '@ant-design/icons-vue' +import { multiNetworkNicEnabledZones, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic' export default { name: 'ListView', @@ -1182,7 +1189,8 @@ export default { }, usageTypeMap: {}, resourceIdToValidLinksMap: {}, - listDayOfWeek: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'] + listDayOfWeek: ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'], + multiNetworkNicEnabledZones: multiNetworkNicEnabledZones } }, watch: { @@ -1192,6 +1200,9 @@ export default { if (newData === oldData) return this.items.forEach(record => { this.resourceIdToValidLinksMap[record.id] = validateLinks(this.$router, false, record) + if (this.$route.path.startsWith('/host') && record.vlanfilteringenabled && record.zoneid) { + fetchMultiNetworkNicEnabledForZone(record.zoneid) + } }) } } diff --git a/ui/src/config/section/infra/hosts.js b/ui/src/config/section/infra/hosts.js index 046f120dd37..74727d7f8ae 100644 --- a/ui/src/config/section/infra/hosts.js +++ b/ui/src/config/section/infra/hosts.js @@ -45,7 +45,7 @@ export default { fields.push('managementservername') return fields }, - details: ['name', 'id', 'resourcestate', 'ipaddress', 'hypervisor', 'externalprovisioner', 'arch', 'type', 'clustername', 'podname', 'zonename', 'lastpinged', 'storageaccessgroups', 'clusterstorageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups', 'managementservername', 'disconnected', 'created', 'externaldetails'], + details: ['name', 'id', 'resourcestate', 'ipaddress', 'hypervisor', 'externalprovisioner', 'arch', 'type', 'clustername', 'podname', 'zonename', 'vlanfilteringenabled', 'vlantrunkxmlsupported', 'lastpinged', 'storageaccessgroups', 'clusterstorageaccessgroups', 'podstorageaccessgroups', 'zonestorageaccessgroups', 'managementservername', 'disconnected', 'created', 'externaldetails'], tabs: [{ name: 'details', component: shallowRef(defineAsyncComponent(() => import('@/components/view/DetailsTab.vue'))) diff --git a/ui/src/utils/multiNetworkNic.js b/ui/src/utils/multiNetworkNic.js new file mode 100644 index 00000000000..7997ac30711 --- /dev/null +++ b/ui/src/utils/multiNetworkNic.js @@ -0,0 +1,89 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +// Shared, module-level cache of the effective (zone-override-or-global) value of the +// multi.network.nic.enabled ConfigKey, keyed by zoneid. Reused by any view that needs to +// gate multi-VLAN trunk NIC UI on whether the feature is actually usable in a given zone +// (e.g. the host list's VLAN-filtering badge, the deploy wizard's associate-network control). +import { reactive } from 'vue' +import { getAPI } from '@/api' + +export const multiNetworkNicEnabledZones = reactive({}) + +// in-flight requests only, keyed by zoneid - dedupes simultaneous callers (e.g. many host rows +// in the same zone on one page) without skipping a fresh re-check on the next page visit +const pendingRequests = {} + +export function isMultiNetworkNicEnabledForZone (zoneid) { + return !!multiNetworkNicEnabledZones[zoneid] +} + +export function fetchMultiNetworkNicEnabledForZone (zoneid) { + if (!zoneid || pendingRequests[zoneid]) { + return + } + pendingRequests[zoneid] = getAPI('listConfigurations', { name: 'multi.network.nic.enabled', zoneid }).then(json => { + const config = json?.listconfigurationsresponse?.configuration?.[0] + multiNetworkNicEnabledZones[zoneid] = !!(config && config.value === 'true') + }).catch(() => {}).finally(() => { + delete pendingRequests[zoneid] + }) +} + +// Flattens a nic's selectable IP targets for rule-creation pickers (Static NAT / Port Forwarding / Load +// Balancing), distinguishing three kinds that are otherwise easy to conflate in the UI: +// - primary: the nic's own network (nic.networkid / nic.ipaddress) +// - secondary: another IP on that SAME network (nic.secondaryip[], pre-existing nic_secondary_ips feature) +// - associated: an IP on a DIFFERENT network this nic trunks to (nic.associatednetworks[], nic_network_map) +// Each option carries the networkid a rule-creation call (enableStaticNat/createPortForwardingRule/ +// assignToLoadBalancerRule) must submit alongside its ip - an associated option's networkid differs from +// the nic's own, which is the one real behavioral difference from a secondary IP. +export function buildNicIpOptions (nic) { + const options = [] + if (nic.ipaddress) { + options.push({ kind: 'primary', ip: nic.ipaddress, networkid: nic.networkid, networkname: nic.networkname, nicid: nic.id }) + } + for (const secondary of (nic.secondaryip || [])) { + options.push({ kind: 'secondary', ip: secondary.ipaddress, networkid: nic.networkid, networkname: nic.networkname, nicid: nic.id }) + } + for (const association of (nic.associatednetworks || [])) { + options.push({ kind: 'associated', ip: association.ipaddress, networkid: association.networkid, networkname: association.networkname, nicid: nic.id }) + } + return options +} + +// Groups buildNicIpOptions() output into labeled sections for a picker (e.g. <a-select-opt-group>), +// in a fixed, meaningful order - primary first, then secondary, then associated - omitting any section +// that has nothing to show (the common legacy case has only a primary section). +export function groupNicIpOptionsByKind (options, translate) { + return [ + { kind: 'primary', label: translate('label.primary') }, + { kind: 'secondary', label: translate('label.secondaryips') }, + { kind: 'associated', label: translate('label.associatednetworks') } + ] + .map(({ kind, label }) => ({ kind, label, items: options.filter(item => item.kind === kind) })) + .filter(group => group.items.length > 0) +} + +// Resolves the networkid a rule-creation call (enableStaticNat/createPortForwardingRule/ +// assignToLoadBalancerRule) must submit for a chosen ip, falling back to fallbackNetworkId when ip +// doesn't match any option (e.g. options haven't loaded yet) - keeps existing callers' behavior for +// every pre-existing case, only changing the outcome for an associated-network selection. +export function resolveNetworkIdForIp (options, ip, fallbackNetworkId) { + const match = (options || []).find(item => item.ip === ip) + return match ? match.networkid : fallbackNetworkId +} diff --git a/ui/src/views/compute/DeployVM.vue b/ui/src/views/compute/DeployVM.vue index 4fa5c14d86e..cd4c55cb891 100644 --- a/ui/src/views/compute/DeployVM.vue +++ b/ui/src/views/compute/DeployVM.vue @@ -448,9 +448,12 @@ v-if="networks.length > 0" :items="networks" :preFillContent="dataPreFill" + :canTrunkNics="canTrunkNics" + :zoneId="zoneId" @update-network-config="($event) => updateNetworkConfig($event)" @handler-error="($event) => hasError = $event" @select-default-network-item="($event) => updateDefaultNetworks($event)" + @update-trunk-groups="($event) => updateTrunkGroups($event)" ></network-configuration> </div> </div> @@ -1159,6 +1162,7 @@ export default { selectedZone: '', formModel: {}, nicToNetworkSelection: [], + trunkGroups: {}, selectedArchitecture: null, isLeaseFeatureEnabled: this.$store.getters.features.instanceleaseenabled, showLeaseOptions: false, @@ -1185,6 +1189,9 @@ export default { rootDiskSize () { return this.showRootDiskSizeChanger && this.rootDiskSizeFixed > 0 }, + canTrunkNics () { + return this.hypervisor === 'KVM' && ('associateNetworkToNic' in this.$store.getters.apis) + }, isNormalAndDomainUser () { return ['DomainAdmin', 'User'].includes(this.$store.getters.userInfo.roletype) }, @@ -2240,6 +2247,9 @@ export default { updateNetworkConfig (networks) { this.networkConfig = networks }, + updateTrunkGroups (trunkGroups) { + this.trunkGroups = trunkGroups + }, updateSshKeyPairs (names) { this.form.keypairs = names this.sshKeyPairs = names.map((sshKeyPair) => { return sshKeyPair.name }) @@ -2524,9 +2534,48 @@ export default { deployVmData['nicnetworklist[' + j + '].network'] = nicNetwork.network } } else { - const arrNetwork = [] networkIds = values.networkids - if (networkIds.length > 0) { + if (!networkIds || networkIds.length === 0) { + this.$notification.error({ + message: this.$t('message.request.failed'), + description: this.$t('message.step.4.continue') + }) + this.loading.deploy = false + return + } + const groupedAwayNetworkIds = Object.values(this.trunkGroups || {}).flat() + const hasTrunkGroups = groupedAwayNetworkIds.length > 0 + if (hasTrunkGroups) { + // one or more nics group several networks onto a single nic (multi-VLAN trunk) - mutually + // exclusive with iptonetworklist at the API level, so every nic (grouped or not) goes + // through nicnetworkslist here instead. + const ownNicNetworkIds = networkIds.filter(id => !groupedAwayNetworkIds.includes(id)) + const orderedNicNetworkIds = [] + for (const id of ownNicNetworkIds) { + if (id === this.defaultnetworkid) { + orderedNicNetworkIds.unshift(id) + } else { + orderedNicNetworkIds.push(id) + } + } + for (let j = 0; j < orderedNicNetworkIds.length; j++) { + const primaryNetworkId = orderedNicNetworkIds[j] + const groupNetworkIds = [primaryNetworkId, ...(this.trunkGroups[primaryNetworkId] || [])] + deployVmData['nicnetworkslist[' + j + '].networkids'] = groupNetworkIds.join(',') + if (this.networkConfig.length > 0) { + const networkConfig = this.networkConfig.filter((item) => item.key === primaryNetworkId) + if (networkConfig && networkConfig.length > 0) { + if (networkConfig[0].ipAddress) { + deployVmData['nicnetworkslist[' + j + '].ip4address'] = networkConfig[0].ipAddress + } + if (networkConfig[0].macAddress) { + deployVmData['nicnetworkslist[' + j + '].macaddress'] = networkConfig[0].macAddress + } + } + } + } + } else { + const arrNetwork = [] for (let i = 0; i < networkIds.length; i++) { if (networkIds[i] === this.defaultnetworkid) { const ipToNetwork = { @@ -2540,21 +2589,14 @@ export default { arrNetwork.push(ipToNetwork) } } - } else { - this.$notification.error({ - message: this.$t('message.request.failed'), - description: this.$t('message.step.4.continue') - }) - this.loading.deploy = false - return - } - for (let j = 0; j < arrNetwork.length; j++) { - deployVmData['iptonetworklist[' + j + '].networkid'] = arrNetwork[j].networkid - if (this.networkConfig.length > 0) { - const networkConfig = this.networkConfig.filter((item) => item.key === arrNetwork[j].networkid) - if (networkConfig && networkConfig.length > 0) { - deployVmData['iptonetworklist[' + j + '].ip'] = networkConfig[0].ipAddress ? networkConfig[0].ipAddress : undefined - deployVmData['iptonetworklist[' + j + '].mac'] = networkConfig[0].macAddress ? networkConfig[0].macAddress : undefined + for (let j = 0; j < arrNetwork.length; j++) { + deployVmData['iptonetworklist[' + j + '].networkid'] = arrNetwork[j].networkid + if (this.networkConfig.length > 0) { + const networkConfig = this.networkConfig.filter((item) => item.key === arrNetwork[j].networkid) + if (networkConfig && networkConfig.length > 0) { + deployVmData['iptonetworklist[' + j + '].ip'] = networkConfig[0].ipAddress ? networkConfig[0].ipAddress : undefined + deployVmData['iptonetworklist[' + j + '].mac'] = networkConfig[0].macAddress ? networkConfig[0].macAddress : undefined + } } } } diff --git a/ui/src/views/compute/DeployVnfAppliance.vue b/ui/src/views/compute/DeployVnfAppliance.vue index 05f804ef4ea..1cd92d46734 100644 --- a/ui/src/views/compute/DeployVnfAppliance.vue +++ b/ui/src/views/compute/DeployVnfAppliance.vue @@ -362,7 +362,10 @@ :items="templateVnfNics" :templateNics="templateNics" :networks="networks" - @update-vnf-nic-networks="($event) => updateVnfNicNetworks($event)" /> + :canTrunkNics="canTrunkNics" + :zoneId="zoneId" + @update-vnf-nic-networks="($event) => updateVnfNicNetworks($event)" + @update-vnf-trunk-groups="($event) => updateVnfTrunkGroups($event)" /> </div> <div style="margin-top: 15px" v-if="showVnfConfigureManagement"> <a-form-item name="vnfconfiguremanagement" ref="vnfconfiguremanagement"> @@ -985,6 +988,7 @@ export default { templateProperties: {}, templateVnfNics: [], vnfNicNetworks: {}, + vnfTrunkGroups: {}, selectedTemplateConfiguration: {}, iso: {}, hypervisor: '', @@ -1055,6 +1059,9 @@ export default { rootDiskSize () { return this.showRootDiskSizeChanger && this.rootDiskSizeFixed > 0 }, + canTrunkNics () { + return this.hypervisor === 'KVM' && ('associateNetworkToNic' in this.$store.getters.apis) + }, isNormalAndDomainUser () { return ['DomainAdmin', 'User'].includes(this.$store.getters.userInfo.roletype) }, @@ -1944,6 +1951,9 @@ export default { updateVnfNicNetworks (vnfNicNetworks) { this.vnfNicNetworks = vnfNicNetworks || {} }, + updateVnfTrunkGroups (vnfTrunkGroups) { + this.vnfTrunkGroups = vnfTrunkGroups || {} + }, updateSshKeyPairs (names) { this.form.keypairs = names this.sshKeyPairs = names.map((sshKeyPair) => { return sshKeyPair.name }) @@ -2264,9 +2274,32 @@ export default { networkIds = values.networkids if (networkIds.length > 0) { if (this.templateVnfNics && this.templateVnfNics.length > 0) { + const hasVnfTrunkGroups = this.vnfTrunkGroups && Object.values(this.vnfTrunkGroups).some(ids => ids && ids.length > 0) + let k = 0 for (const templateVnfNic of this.templateVnfNics) { const vnfNicNetworkId = this.vnfNicNetworks[String(templateVnfNic.deviceid)]?.id || null - if (vnfNicNetworkId) { + if (!vnfNicNetworkId) { + continue + } + if (hasVnfTrunkGroups) { + // one or more VNF nics group several networks onto a single nic (multi-VLAN trunk) - + // mutually exclusive with iptonetworklist at the API level, so every nic (grouped or + // not) goes through nicnetworkslist here instead. + const assocIds = this.vnfTrunkGroups[String(templateVnfNic.deviceid)] || [] + createVnfAppData['nicnetworkslist[' + k + '].networkids'] = [vnfNicNetworkId, ...assocIds].join(',') + if (this.networkConfig.length > 0) { + const networkConfig = this.networkConfig.filter((item) => item.key === vnfNicNetworkId) + if (networkConfig && networkConfig.length > 0) { + if (networkConfig[0].ipAddress) { + createVnfAppData['nicnetworkslist[' + k + '].ip4address'] = networkConfig[0].ipAddress + } + if (networkConfig[0].macAddress) { + createVnfAppData['nicnetworkslist[' + k + '].macaddress'] = networkConfig[0].macAddress + } + } + } + k++ + } else { const ipToNetwork = { networkid: vnfNicNetworkId } diff --git a/ui/src/views/compute/MigrateWizard.vue b/ui/src/views/compute/MigrateWizard.vue index 0e8c08d320f..b24c4461834 100644 --- a/ui/src/views/compute/MigrateWizard.vue +++ b/ui/src/views/compute/MigrateWizard.vue @@ -49,6 +49,11 @@ class="host-item__suitability-icon" twoToneColor="#52c41a" v-if="record.suitableformigration" /> + <a-tooltip v-else-if="record.unsuitablereason" :title="record.unsuitablereason" placement="top"> + <close-circle-two-tone + class="host-item__suitability-icon" + twoToneColor="#f5222d" /> + </a-tooltip> <close-circle-two-tone class="host-item__suitability-icon" twoToneColor="#f5222d" diff --git a/ui/src/views/compute/wizard/NetworkConfiguration.vue b/ui/src/views/compute/wizard/NetworkConfiguration.vue index f3192d278bd..c5649586a5c 100644 --- a/ui/src/views/compute/wizard/NetworkConfiguration.vue +++ b/ui/src/views/compute/wizard/NetworkConfiguration.vue @@ -28,7 +28,7 @@ :rules="rules"> <a-table :columns="columns" - :dataSource="dataItems" + :dataSource="visibleDataItems" :pagination="false" :rowSelection="rowSelection" :customRow="onClickRow" @@ -39,6 +39,22 @@ <template v-if="column.key === 'name'"> <div>{{ text }}</div> <small v-if="record.type!=='L2'">{{ $t('label.cidr') + ': ' + record.cidr }}</small> + <div v-if="trunkGroups[record.id] && trunkGroups[record.id].length > 0" style="margin-top: 4px"> + <a-tag v-for="assocId in trunkGroups[record.id]" :key="assocId" closable @close.prevent.stop="removeFromGroup(record.id, assocId)"> + {{ networkNameById(assocId) }} + </a-tag> + </div> + </template> + <template v-if="column.key === 'associate'"> + <span @click.stop> + <tooltip-button + tooltipPlacement="top" + :tooltip="$t('label.network.add.associated')" + icon="plus-outlined" + size="small" + type="primary" + @onClick="openAssociateModal(record)" /> + </span> </template> <template v-if="!this.autoscale"> <template v-if="column.key === 'ipAddress'"> @@ -86,12 +102,52 @@ </a-button> </div> </a-form> + + <a-modal + :visible="showAssociateModal" + :title="$t('label.associate.network')" + :maskClosable="false" + :closable="true" + :footer="null" + @cancel="closeAssociateModal"> + {{ $t('message.associate.network.desc') }} + <a-alert + type="warning" + show-icon + style="margin-top: 10px; margin-bottom: 10px" + :message="$t('message.associate.network.loop.warning')" /> + <a-select + mode="multiple" + style="width: 100%" + :loading="associateModalLoading" + :placeholder="$t('label.networks')" + v-model:value="associateSelection" + optionFilterProp="label"> + <a-select-option + v-for="network in associateModalNetworks" + :key="network.id" + :value="network.id" + :label="network.name"> + {{ network.name }} + </a-select-option> + </a-select> + <div style="margin-top: 20px; text-align: right;"> + <a-button @click="closeAssociateModal">{{ $t('label.cancel') }}</a-button> + <a-button type="primary" style="margin-left: 8px" @click="confirmAssociate">{{ $t('label.ok') }}</a-button> + </div> + </a-modal> </template> <script> import { ref, reactive } from 'vue' +import { getAPI } from '@/api' +import TooltipButton from '@/components/widgets/TooltipButton' +import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic' export default { name: 'NetworkConfiguration', + components: { + TooltipButton + }, props: { items: { type: Array, @@ -112,33 +168,28 @@ export default { preFillContent: { type: Object, default: () => {} + }, + canTrunkNics: { + type: Boolean, + default: false + }, + zoneId: { + type: String, + default: '' } }, data () { return { networks: [], - columns: [ - { - key: 'name', - dataIndex: 'name', - title: this.$t('label.network'), - width: '30%' - }, - { - key: 'ipAddress', - dataIndex: 'ip', - title: this.$t('label.ip'), - width: '30%' - }, - { - key: 'macAddress', - dataIndex: 'mac', - title: this.$t('label.macaddress'), - width: '30%' - } - ], selectedRowKeys: [], dataItems: [], + trunkGroups: {}, + showAssociateModal: false, + associateTarget: null, + associateSelection: [], + associateModalNetworks: [], + associateModalLoading: false, + associatedNetworkNames: {}, macRegex: /^([0-9A-F]{2}[:-]){5}([0-9A-F]{2})$/i, ipV4Regex: /^(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)\.(25[0-5]|2[0-4]\d|[01]?\d\d?)$/i } @@ -153,6 +204,7 @@ export default { this.selectedRowKeys = [this.dataItems[0].id] this.$emit('select-default-network-item', this.dataItems[0].id) } + fetchMultiNetworkNicEnabledForZone(this.zoneId) }, computed: { rowSelection () { @@ -164,6 +216,46 @@ export default { selectedRowKeys: this.selectedRowKeys, onChange: this.onSelectRow } + }, + showTrunkNicControls () { + return this.canTrunkNics && isMultiNetworkNicEnabledForZone(this.zoneId) + }, + columns () { + const nameAndFieldWidth = this.showTrunkNicControls ? '25%' : '30%' + const cols = [ + { + key: 'name', + dataIndex: 'name', + title: this.$t('label.network'), + width: nameAndFieldWidth + }, + { + key: 'ipAddress', + dataIndex: 'ip', + title: this.$t('label.ip'), + width: nameAndFieldWidth + }, + { + key: 'macAddress', + dataIndex: 'mac', + title: this.$t('label.macaddress'), + width: nameAndFieldWidth + } + ] + if (this.showTrunkNicControls) { + cols.push({ + key: 'associate', + title: '', + width: '25%' + }) + } + return cols + }, + groupedAwayNetworkIds () { + return Object.values(this.trunkGroups).flat() + }, + visibleDataItems () { + return this.dataItems.filter(item => !this.groupedAwayNetworkIds.includes(item.id)) } }, watch: { @@ -172,23 +264,109 @@ export default { this.selectedRowKeys = [newValue] } }, + zoneId (newValue) { + fetchMultiNetworkNicEnabledForZone(newValue) + }, items: { deep: true, handler (newData) { if (newData && newData.length > 0) { this.dataItems = newData this.initForm() - const keyEx = this.dataItems.filter((item) => this.selectedRowKeys.includes(item.id)) + const validPrimaryIds = this.dataItems.map(item => item.id) + const prunedGroups = {} + let groupsChanged = false + for (const [primaryId, assocIds] of Object.entries(this.trunkGroups)) { + if (!validPrimaryIds.includes(primaryId)) { + // the primary network this group was folded under is no longer selected - drop the whole group + groupsChanged = true + continue + } + // an associated network is independent of the selection list, except it can't also become its own + // selected row - if the user just checked it separately, it needs its own nic, not an association + const filtered = assocIds.filter(id => !validPrimaryIds.includes(id)) + if (filtered.length !== assocIds.length) { + groupsChanged = true + } + if (filtered.length > 0) { + prunedGroups[primaryId] = filtered + } + } + if (groupsChanged) { + this.trunkGroups = prunedGroups + this.$emit('update-trunk-groups', this.trunkGroups) + } + const keyEx = this.visibleDataItems.filter((item) => this.selectedRowKeys.includes(item.id)) if (!keyEx || keyEx.length === 0) { - this.selectedRowKeys = [this.dataItems[0].id] - this.$emit('select-default-network-item', this.dataItems[0].id) + this.selectedRowKeys = this.visibleDataItems.length > 0 ? [this.visibleDataItems[0].id] : [] + this.$emit('select-default-network-item', this.selectedRowKeys[0]) } } } } }, - emits: ['update-network-config', 'select-default-network-item', 'handler-error'], + emits: ['update-network-config', 'select-default-network-item', 'handler-error', 'update-trunk-groups'], methods: { + networkNameById (id) { + const network = this.items.find(item => item.id === id) + if (network) { + return network.name + } + return this.associatedNetworkNames[id] || id + }, + openAssociateModal (record) { + this.associateTarget = record + this.associateSelection = [] + this.associateModalNetworks = [] + this.showAssociateModal = true + this.associateModalLoading = true + const alreadyUsedNetworkIds = [record.id, ...this.dataItems.map(item => item.id), ...this.groupedAwayNetworkIds] + getAPI('listNetworks', { + listAll: true, + zoneid: this.zoneId + }).then(response => { + const allNetworks = response.listnetworksresponse.network || [] + this.associateModalNetworks = allNetworks.filter(network => + network.type !== 'L2' && !alreadyUsedNetworkIds.includes(network.id)) + const names = {} + allNetworks.forEach(network => { names[network.id] = network.name }) + this.associatedNetworkNames = { ...this.associatedNetworkNames, ...names } + }).finally(() => { + this.associateModalLoading = false + }) + }, + closeAssociateModal () { + this.showAssociateModal = false + this.associateTarget = null + this.associateSelection = [] + this.associateModalNetworks = [] + }, + confirmAssociate () { + if (!this.associateTarget || this.associateSelection.length === 0) { + this.closeAssociateModal() + return + } + const existing = this.trunkGroups[this.associateTarget.id] || [] + this.trunkGroups = { + ...this.trunkGroups, + [this.associateTarget.id]: [...existing, ...this.associateSelection] + } + if (!this.visibleDataItems.some(item => this.selectedRowKeys.includes(item.id))) { + // the current default network just got folded into this group as an association; keep the group's primary as default + this.selectedRowKeys = [this.associateTarget.id] + this.$emit('select-default-network-item', this.associateTarget.id) + } + this.$emit('update-trunk-groups', this.trunkGroups) + this.closeAssociateModal() + }, + removeFromGroup (primaryId, assocId) { + const existing = this.trunkGroups[primaryId] || [] + this.trunkGroups = { + ...this.trunkGroups, + [primaryId]: existing.filter(id => id !== assocId) + } + this.$emit('update-trunk-groups', this.trunkGroups) + }, initForm () { this.formRef = ref() this.form = reactive({}) diff --git a/ui/src/views/compute/wizard/VnfNicsSelection.vue b/ui/src/views/compute/wizard/VnfNicsSelection.vue index 538c9553eb4..12b527f1d91 100644 --- a/ui/src/views/compute/wizard/VnfNicsSelection.vue +++ b/ui/src/views/compute/wizard/VnfNicsSelection.vue @@ -57,20 +57,75 @@ return option.children[0].children.toLowerCase().indexOf(input.toLowerCase()) >= 0 }" > <a-select-option key="" >{{ }}</a-select-option> - <a-select-option v-for="network in networks" :key="network.id"> + <a-select-option v-for="network in availableNetworksForSelect" :key="network.id"> {{ network.name }} </a-select-option> </a-select> + <div v-if="trunkGroups[record.deviceid] && trunkGroups[record.deviceid].length > 0" style="margin-top: 4px"> + <a-tag v-for="assocId in trunkGroups[record.deviceid]" :key="assocId" closable @close.prevent.stop="removeFromGroup(record.deviceid, assocId)"> + {{ networkNameById(assocId) }} + </a-tag> + </div> </a-form-item> </template> + <template #associate="{ record }"> + <tooltip-button + v-if="!record.management && values[record.deviceid]" + tooltipPlacement="top" + :tooltip="$t('label.network.add.associated')" + icon="plus-outlined" + size="small" + type="primary" + @onClick="openAssociateModal(record)" /> + </template> </a-table> </a-form> + + <a-modal + :visible="showAssociateModal" + :title="$t('label.associate.network')" + :maskClosable="false" + :closable="true" + :footer="null" + @cancel="closeAssociateModal"> + {{ $t('message.associate.network.desc') }} + <a-alert + type="warning" + show-icon + style="margin-top: 10px; margin-bottom: 10px" + :message="$t('message.associate.network.loop.warning')" /> + <a-select + mode="multiple" + style="width: 100%" + :loading="associateModalLoading" + :placeholder="$t('label.networks')" + v-model:value="associateSelection" + optionFilterProp="label"> + <a-select-option + v-for="network in associateModalNetworks" + :key="network.id" + :value="network.id" + :label="network.name"> + {{ network.name }} + </a-select-option> + </a-select> + <div style="margin-top: 20px; text-align: right;"> + <a-button @click="closeAssociateModal">{{ $t('label.cancel') }}</a-button> + <a-button type="primary" style="margin-left: 8px" @click="confirmAssociate">{{ $t('label.ok') }}</a-button> + </div> + </a-modal> </template> <script> import { ref, reactive } from 'vue' +import { getAPI } from '@/api' +import TooltipButton from '@/components/widgets/TooltipButton' +import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic' export default { name: 'VnfNicsSelection', + components: { + TooltipButton + }, props: { items: { type: Array, @@ -87,12 +142,44 @@ export default { preFillContent: { type: Object, default: () => {} + }, + canTrunkNics: { + type: Boolean, + default: false + }, + zoneId: { + type: String, + default: '' } }, data () { return { values: {}, - columns: [ + trunkGroups: {}, + showAssociateModal: false, + associateTarget: null, + associateSelection: [], + associateModalNetworks: [], + associateModalLoading: false, + associatedNetworkNames: {} + } + }, + created () { + this.initForm() + fetchMultiNetworkNicEnabledForZone(this.zoneId) + }, + computed: { + showTrunkNicControls () { + return this.canTrunkNics && isMultiNetworkNicEnabledForZone(this.zoneId) + }, + groupedAwayNetworkIds () { + return Object.values(this.trunkGroups).flat() + }, + availableNetworksForSelect () { + return this.networks.filter(network => !this.groupedAwayNetworkIds.includes(network.id)) + }, + columns () { + const cols = [ { dataIndex: 'deviceid', title: this.$t('label.deviceid'), @@ -120,20 +207,31 @@ export default { { dataIndex: 'description', title: this.$t('label.description'), - width: '35%', + width: this.showTrunkNicControls ? '25%' : '35%', slots: { customRender: 'description' } }, { dataIndex: 'network', title: this.$t('label.network'), - width: '25%', + width: this.showTrunkNicControls ? '15%' : '25%', slots: { customRender: 'network' } } ] + if (this.showTrunkNicControls) { + cols.push({ + dataIndex: 'associate', + title: '', + width: '10%', + slots: { customRender: 'associate' } + }) + } + return cols } }, - created () { - this.initForm() + watch: { + zoneId (newValue) { + fetchMultiNetworkNicEnabledForZone(newValue) + } }, methods: { initForm () { @@ -147,9 +245,72 @@ export default { this.form = reactive(form) this.rules = reactive(rules) }, + networkNameById (id) { + const network = this.networks.find(item => item.id === id) + if (network) { + return network.name + } + return this.associatedNetworkNames[id] || id + }, updateNicNetworkValue (value, deviceid) { this.values[deviceid] = this.networks.filter(network => network.id === value)?.[0] || null + if (!value && this.trunkGroups[deviceid]) { + // no primary network left for this nic, so any associated networks it had no longer make sense either + const prunedGroups = { ...this.trunkGroups } + delete prunedGroups[deviceid] + this.trunkGroups = prunedGroups + this.$emit('update-vnf-trunk-groups', this.trunkGroups) + } this.$emit('update-vnf-nic-networks', this.values) + }, + openAssociateModal (record) { + this.associateTarget = record + this.associateSelection = [] + this.associateModalNetworks = [] + this.showAssociateModal = true + this.associateModalLoading = true + const alreadyUsedDeviceNetworkIds = Object.values(this.values).filter(Boolean).map(network => network.id) + const alreadyUsedNetworkIds = [...alreadyUsedDeviceNetworkIds, ...this.groupedAwayNetworkIds] + getAPI('listNetworks', { + listAll: true, + zoneid: this.zoneId + }).then(response => { + const allNetworks = response.listnetworksresponse.network || [] + this.associateModalNetworks = allNetworks.filter(network => !alreadyUsedNetworkIds.includes(network.id)) + const names = {} + allNetworks.forEach(network => { names[network.id] = network.name }) + this.associatedNetworkNames = { ...this.associatedNetworkNames, ...names } + }).finally(() => { + this.associateModalLoading = false + }) + }, + closeAssociateModal () { + this.showAssociateModal = false + this.associateTarget = null + this.associateSelection = [] + this.associateModalNetworks = [] + }, + confirmAssociate () { + if (!this.associateTarget || this.associateSelection.length === 0) { + this.closeAssociateModal() + return + } + const deviceid = this.associateTarget.deviceid + const existing = this.trunkGroups[deviceid] || [] + this.trunkGroups = { + ...this.trunkGroups, + [deviceid]: [...existing, ...this.associateSelection] + } + this.$emit('update-vnf-trunk-groups', this.trunkGroups) + this.closeAssociateModal() + }, + removeFromGroup (deviceid, assocId) { + const existing = this.trunkGroups[deviceid] || [] + this.trunkGroups = { + ...this.trunkGroups, + [deviceid]: existing.filter(id => id !== assocId) + } + this.$emit('update-vnf-trunk-groups', this.trunkGroups) } } } diff --git a/ui/src/views/infra/Resources.vue b/ui/src/views/infra/Resources.vue index 0055343b30d..15dd7b26a18 100644 --- a/ui/src/views/infra/Resources.vue +++ b/ui/src/views/infra/Resources.vue @@ -61,12 +61,27 @@ </a-collapse> </div> </a-list-item> + <a-list-item v-if="showHostReadiness" class="list-item"> + <div class="list-item__container"> + <div class="list-item__data list-item__title">{{ $t('label.multinetworknic.hosts.ready') }}</div> + <div class="list-item__vals"> + <div class="list-item__data"> + {{ $t('label.multinetworknic.hosts.ready.count', { ready: hostReadiness.ready, total: hostReadiness.total }) }} + </div> + <a-progress + status="normal" + :percent="hostReadinessPercent" + :format="p => p.toFixed(2) + '%'" /> + </div> + </div> + </a-list-item> </a-list> </a-spin> </template> <script> import { getAPI } from '@/api' +import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic' export default { name: 'Resources', @@ -90,7 +105,22 @@ export default { return { fetchLoading: false, resourcesList: [], - collapseActive: {} + collapseActive: {}, + hostReadiness: { ready: 0, total: 0 } + } + }, + computed: { + zoneId () { + return this.$route.meta.name === 'zone' ? this.resource.id : this.resource.zoneid + }, + showHostReadiness () { + return isMultiNetworkNicEnabledForZone(this.zoneId) && this.hostReadiness.total > 0 + }, + hostReadinessPercent () { + if (this.hostReadiness.total === 0) { + return 0 + } + return (this.hostReadiness.ready / this.hostReadiness.total) * 100 } }, created () { @@ -111,6 +141,21 @@ export default { }).finally(() => { this.fetchLoading = false }) + fetchMultiNetworkNicEnabledForZone(this.zoneId) + this.fetchHostReadiness(entity, params[entity]) + }, + fetchHostReadiness (entity, entityId) { + const params = { type: 'Routing' } + params[entity] = entityId + getAPI('listHosts', params).then(response => { + const hosts = response.listhostsresponse.host || [] + this.hostReadiness = { + ready: hosts.filter(host => host.vlanfilteringenabled).length, + total: hosts.length + } + }).catch(error => { + this.$notifyError(error) + }) }, updateTaggedCapacities () { var resourcesListCopy = [...this.resourcesList] diff --git a/ui/src/views/network/EnableStaticNat.vue b/ui/src/views/network/EnableStaticNat.vue index f6f6748630c..bc955cf6083 100644 --- a/ui/src/views/network/EnableStaticNat.vue +++ b/ui/src/views/network/EnableStaticNat.vue @@ -58,21 +58,23 @@ {{ record.name }} </div> <a-select - v-if="nicsList.length && selectedVm && selectedVm === record.id" + v-if="nicIpOptions.length && selectedVm && selectedVm === record.id" class="nic-select" - :defaultValue="selectedNic.ipaddress" + :defaultValue="selectedNic.ip" showSearch optionFilterProp="label" :filterOption="(input, option) => { return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 }"> - <a-select-option - @click="selectedNic = item" - v-for="item in nicsList" - :key="item.id" - :label="item.ipaddress"> - {{ item.ipaddress }} - </a-select-option> + <a-select-opt-group v-for="group in groupedNicIpOptions" :key="group.kind" :label="group.label"> + <a-select-option + @click="selectedNic = item" + v-for="item in group.items" + :key="item.nicid + '-' + item.ip" + :label="item.ip + ' ' + item.networkname"> + {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span> + </a-select-option> + </a-select-opt-group> </a-select> </template> <template v-if="column.key === 'state'"> @@ -115,6 +117,7 @@ <script> import { getAPI, postAPI } from '@/api' import Status from '@/components/widgets/Status' +import { buildNicIpOptions, groupNicIpOptionsByKind } from '@/utils/multiNetworkNic' export default { props: { @@ -127,12 +130,17 @@ export default { Status }, inject: ['parentFetchData'], + computed: { + groupedNicIpOptions () { + return groupNicIpOptionsByKind(this.nicIpOptions, this.$t) + } + }, data () { return { loading: false, vmsList: [], selectedVm: null, - nicsList: [], + nicIpOptions: [], searchQuery: null, selectedNic: null, columns: [ @@ -220,22 +228,15 @@ export default { }, fetchNics (e) { this.selectedVm = e.target.value - this.nicsList = [] + this.nicIpOptions = [] this.loading = true getAPI('listNics', { virtualmachineid: this.selectedVm, networkid: this.resource.associatednetworkid || this.selectedVpcTier }).then(response => { - this.nicsList = response.listnicsresponse.nic || [] - - let secondaryIps = this.nicsList.map(item => item.secondaryip) - - if (secondaryIps[0]) { - secondaryIps = secondaryIps[0] - this.nicsList = [...this.nicsList, ...secondaryIps] - } - - this.selectedNic = this.nicsList[0] + const nics = response.listnicsresponse.nic || [] + this.nicIpOptions = nics.flatMap(nic => buildNicIpOptions(nic)) + this.selectedNic = this.nicIpOptions[0] }).catch(error => { this.$notifyError(error) }).finally(() => { @@ -261,8 +262,8 @@ export default { postAPI('enableStaticNat', { ipaddressid: this.resource.id, virtualmachineid: this.selectedVm, - vmguestip: this.selectedNic.ipaddress, - networkid: this.selectedVpcTier + vmguestip: this.selectedNic.ip, + networkid: this.selectedNic.networkid }).then(() => { this.parentFetchData() this.handleClose() @@ -394,6 +395,10 @@ export default { margin-top: 10px; margin-right: auto; min-width: 150px; + + &__network { + color: rgba(0, 0, 0, 0.45); + } } .pagination { diff --git a/ui/src/views/network/InternalLBAssignVmForm.vue b/ui/src/views/network/InternalLBAssignVmForm.vue index dfa2a24a0f6..73f61f38e3d 100644 --- a/ui/src/views/network/InternalLBAssignVmForm.vue +++ b/ui/src/views/network/InternalLBAssignVmForm.vue @@ -47,13 +47,15 @@ :filterOption="(input, option) => { return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 }" > - <a-select-option - v-for="(nic, nicIndex) in nics[index]" - :key="nic" - :value="nic" - :label="nic"> - {{ nic }}{{ nicIndex === 0 ? ` (${this.$t('label.primary')})` : '' }} - </a-select-option> + <a-select-opt-group v-for="group in groupedNicIpOptionsFor(index)" :key="group.kind" :label="group.label"> + <a-select-option + v-for="item in group.items" + :key="item.ip" + :value="item.ip" + :label="item.ip + ' ' + item.networkname"> + {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span> + </a-select-option> + </a-select-opt-group> </a-select> </span> <span><status :text="vm.state" displayText /></span> @@ -99,6 +101,7 @@ <script> import { getAPI, postAPI } from '@/api' import Status from '@/components/widgets/Status' +import { buildNicIpOptions, groupNicIpOptionsByKind } from '@/utils/multiNetworkNic' export default { name: 'InternalLBAssignVmForm', @@ -132,6 +135,9 @@ export default { this.fetchData() }, methods: { + groupedNicIpOptionsFor (index) { + return groupNicIpOptionsByKind(this.nics[index] || [], this.$t) + }, fetchData () { this.fetchLoadBalancers() this.fetchVirtualMachines() @@ -187,13 +193,9 @@ export default { networkid: this.resource.networkid }).then(response => { if (!response.listnicsresponse.nic[0]) return - const newItem = [] - newItem.push(response.listnicsresponse.nic[0].ipaddress) - if (response.listnicsresponse.nic[0].secondaryip) { - newItem.push(...response.listnicsresponse.nic[0].secondaryip.map(ip => ip.ipaddress)) - } - this.nics[index] = newItem - this.iLb.vmguestip[index] = this.nics[index][0] + const nicsReturned = response.listnicsresponse.nic || [] + this.nics[index] = nicsReturned.flatMap(nic => buildNicIpOptions(nic)) + this.iLb.vmguestip[index] = this.nics[index][0]?.ip this.addVmModalNicLoading = false }).catch(error => { this.$notifyError(error) @@ -289,4 +291,8 @@ export default { } } } + +.nic-select__network { + color: rgba(0, 0, 0, 0.45); +} </style> diff --git a/ui/src/views/network/LoadBalancing.vue b/ui/src/views/network/LoadBalancing.vue index 0b9ed7684a8..58fd14e203d 100644 --- a/ui/src/views/network/LoadBalancing.vue +++ b/ui/src/views/network/LoadBalancing.vue @@ -545,13 +545,15 @@ :filterOption="(input, option) => { return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 }" > - <a-select-option - v-for="(nic, nicIndex) in nics[index]" - :key="nic" - :value="nic" - :label="nic + nicIndex === 0 ? ` (${$t('label.primary')})` : null"> - {{ nic }}{{ nicIndex === 0 ? ` (${$t('label.primary')})` : null }} - </a-select-option> + <a-select-opt-group v-for="group in groupedNicIpOptionsFor(index)" :key="group.kind" :label="group.label"> + <a-select-option + v-for="item in group.items" + :key="item.ip" + :value="item.ip" + :label="item.ip + ' ' + item.networkname"> + {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span> + </a-select-option> + </a-select-opt-group> </a-select> </template> @@ -812,6 +814,7 @@ import TooltipButton from '@/components/widgets/TooltipButton' import BulkActionView from '@/components/view/BulkActionView' import eventBus from '@/config/eventBus' import TooltipLabel from '@/components/widgets/TooltipLabel' +import { buildNicIpOptions, groupNicIpOptionsByKind, resolveNetworkIdForIp } from '@/utils/multiNetworkNic' export default { name: 'LoadBalancing', @@ -1060,6 +1063,9 @@ export default { } }, methods: { + groupedNicIpOptionsFor (index) { + return groupNicIpOptionsByKind(this.nics[index] || [], this.$t) + }, initForm () { this.formRef = ref() this.form = reactive({}) @@ -1853,13 +1859,9 @@ export default { networkid: ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid }).then(response => { if (!response || !response.listnicsresponse || !response.listnicsresponse.nic[0]) return - const newItem = [] - newItem.push(response.listnicsresponse.nic[0].ipaddress) - if (response.listnicsresponse.nic[0].secondaryip) { - newItem.push(...response.listnicsresponse.nic[0].secondaryip.map(ip => ip.ipaddress)) - } - this.nics[index] = newItem - this.newRule.vmguestip[index] = [this.nics[index][0]] + const nicsReturned = response.listnicsresponse.nic || [] + this.nics[index] = nicsReturned.flatMap(nic => buildNicIpOptions(nic)) + this.newRule.vmguestip[index] = [this.nics[index][0]?.ip] this.addVmModalNicLoading = false }).catch(error => { this.$notifyError(error) @@ -1956,7 +1958,7 @@ export default { vmIDIpMap[`vmidipmap[${innerCount}].vmid`] = this.newRule.virtualmachineid[count] vmIDIpMap[`vmidipmap[${innerCount}].vmip`] = i if (this.vpcConserveMode) { - vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = this.selectedTier + vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = resolveNetworkIdForIp(this.nics[count], i, this.selectedTier) } innerCount++ }) @@ -1964,7 +1966,7 @@ export default { vmIDIpMap[`vmidipmap[${innerCount}].vmid`] = this.newRule.virtualmachineid[count] vmIDIpMap[`vmidipmap[${innerCount}].vmip`] = ip if (this.vpcConserveMode && ip != null) { - vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = this.selectedTier + vmIDIpMap[`vmidipmap[${innerCount}].vmnetworkid`] = resolveNetworkIdForIp(this.nics[count], ip, this.selectedTier) } innerCount++ } @@ -2563,4 +2565,8 @@ export default { width: 50%; float: right; } + + .nic-select__network { + color: rgba(0, 0, 0, 0.45); + } </style> diff --git a/ui/src/views/network/NicsTab.vue b/ui/src/views/network/NicsTab.vue index 8e21db5b34d..3fd653945ee 100644 --- a/ui/src/views/network/NicsTab.vue +++ b/ui/src/views/network/NicsTab.vue @@ -25,7 +25,11 @@ :disabled="!('addNicToVirtualMachine' in $store.getters.apis) || resource.hypervisor === 'External'"> <template #icon><plus-outlined /></template> {{ $t('label.network.addvm') }} </a-button> - <NicsTable :resource="resource" :loading="loading"> + <NicsTable + :resource="resource" + :loading="loading" + :canManageAssociations="canManageAssociations" + @disassociate-network="onDisassociateNetwork"> <template #actions="record"> <a-popconfirm :title="$t('label.set.default.nic')" @@ -61,6 +65,12 @@ icon="edit-outlined" :disabled="(!('updateVmNic' in $store.getters.apis))" @onClick="onUpdateNic(record)" /> + <tooltip-button + v-if="canManageAssociations && record.nic.type !== 'L2'" + tooltipPlacement="bottom" + :tooltip="$t('label.associate.network')" + icon="apartment-outlined" + @onClick="onAssociateNetworks(record)" /> <a-popconfirm :title="$t('message.network.removenic')" @confirm="removeNIC(record.nic)" @@ -147,6 +157,58 @@ </a-form> </a-modal> + <a-modal + :visible="showAssociateNetworksModal" + :title="$t('label.associate.network')" + :maskClosable="false" + :closable="true" + :footer="null" + @cancel="closeModals"> + {{ $t('message.associate.network.desc') }} + <a-alert + type="warning" + show-icon + style="margin-top: 10px; margin-bottom: 10px" + :message="$t('message.associate.network.loop.warning')" /> + <a-form + @finish="submitAssociateNetworks" + v-ctrl-enter="submitAssociateNetworks" + layout="vertical"> + <a-form-item name="networks" ref="networks"> + <template #label> + <tooltip-label :title="$t('label.networks')" :tooltip="$t('message.associate.network.desc')"/> + </template> + <a-select + mode="multiple" + :placeholder="$t('label.networks')" + v-model:value="associateNetworksData.networkIds" + v-focus="true" + showSearch + optionFilterProp="label" + :filterOption="(input, option) => { + return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 + }" > + <a-select-option + v-for="network in associateNetworksData.availableNetworks" + :key="network.id" + :value="network.id" + :label="network.name"> + <span> + <resource-icon v-if="network.icon" :image="network.icon.base64image" size="1x" style="margin-right: 5px"/> + <apartment-outlined v-else style="margin-right: 5px" /> + {{ network.name }} + </span> + </a-select-option> + </a-select> + </a-form-item> + + <div :span="24" class="action-button"> + <a-button @click="closeModals">{{ $t('label.cancel') }}</a-button> + <a-button type="primary" ref="submit" @click="submitAssociateNetworks">{{ $t('label.ok') }}</a-button> + </div> + </a-form> + </a-modal> + <a-modal :visible="showUpdateIpModal" :title="$t('label.change.ipaddress')" @@ -288,6 +350,7 @@ import NicsTable from '@/views/network/NicsTable' import TooltipLabel from '@/components/widgets/TooltipLabel' import TooltipButton from '@/components/widgets/TooltipButton' import ResourceIcon from '@/components/view/ResourceIcon' +import { isMultiNetworkNicEnabledForZone, fetchMultiNetworkNicEnabledForZone } from '@/utils/multiNetworkNic' export default { name: 'NicsTab', @@ -313,6 +376,7 @@ export default { vm: {}, nic: {}, showAddNetworkModal: false, + showAssociateNetworksModal: false, showUpdateIpModal: false, showSecondaryIpModal: false, showUpdateNicModal: false, @@ -322,6 +386,11 @@ export default { ip: '', makedefault: false }, + associateNetworksData: { + nic: {}, + availableNetworks: [], + networkIds: [] + }, loadingNic: false, editIpAddressNic: '', editIpAddressValue: '', @@ -338,9 +407,16 @@ export default { } } }, + computed: { + canManageAssociations () { + return this.resource.hypervisor === 'KVM' && ('associateNetworkToNic' in this.$store.getters.apis) && + isMultiNetworkNicEnabledForZone(this.resource.zoneid) + } + }, created () { this.vm = this.resource this.addNetworkData.apiParams = this.$getApiParams('addNicToVirtualMachine') + fetchMultiNetworkNicEnabledForZone(this.resource.zoneid) }, methods: { listNetworks () { @@ -397,6 +473,7 @@ export default { }, closeModals () { this.showAddNetworkModal = false + this.showAssociateNetworksModal = false this.showUpdateIpModal = false this.showSecondaryIpModal = false this.showUpdateNicModal = false @@ -404,10 +481,90 @@ export default { this.addNetworkData.ipaddress = '' this.addNetworkData.macaddress = '' this.addNetworkData.makedefault = false + this.associateNetworksData.nic = {} + this.associateNetworksData.availableNetworks = [] + this.associateNetworksData.networkIds = [] this.editIpAddressValue = '' this.newSecondaryIp = '' this.newSecondaryIpDescription = '' }, + onAssociateNetworks (record) { + this.associateNetworksData.nic = record.nic + this.associateNetworksData.networkIds = [] + this.showAssociateNetworksModal = true + const alreadyUsedNetworkIds = [record.nic.networkid, ...(record.nic.associatednetworks || []).map(n => n.networkid)] + getAPI('listNetworks', { + listAll: 'true', + showicon: true, + zoneid: this.vm.zoneid + }).then(response => { + this.associateNetworksData.availableNetworks = (response.listnetworksresponse.network || []) + .filter(network => !alreadyUsedNetworkIds.includes(network.id)) + }) + }, + submitAssociateNetworks () { + if (this.loadingNic || this.associateNetworksData.networkIds.length === 0) return + this.loadingNic = true + this.showAssociateNetworksModal = false + postAPI('associateNetworkToNic', { + nicid: this.associateNetworksData.nic.id, + networkids: this.associateNetworksData.networkIds + }).then(response => { + this.$pollJob({ + jobId: response.associatenetworktonicresponse.jobid, + successMessage: this.$t('message.success.associate.network'), + successMethod: () => { + this.loadingNic = false + this.closeModals() + this.$emit('refresh') + }, + errorMessage: this.$t('message.error.associate.network'), + errorMethod: () => { + this.loadingNic = false + this.closeModals() + }, + loadingMessage: this.$t('message.associate.network.processing'), + catchMessage: this.$t('error.fetching.async.job.result'), + catchMethod: () => { + this.loadingNic = false + this.closeModals() + this.$emit('refresh') + } + }) + }).catch(error => { + this.$notifyError(error) + this.loadingNic = false + }) + }, + onDisassociateNetwork ({ nic, network }) { + this.loadingNic = true + postAPI('disassociateNetworkFromNic', { + nicid: nic.id, + networkid: network.networkid + }).then(response => { + this.$pollJob({ + jobId: response.disassociatenetworkfromnicresponse.jobid, + successMessage: this.$t('message.success.disassociate.network'), + successMethod: () => { + this.loadingNic = false + this.$emit('refresh') + }, + errorMessage: this.$t('message.error.disassociate.network'), + errorMethod: () => { + this.loadingNic = false + }, + loadingMessage: this.$t('message.disassociate.network.processing'), + catchMessage: this.$t('error.fetching.async.job.result'), + catchMethod: () => { + this.loadingNic = false + this.$emit('refresh') + } + }) + }).catch(error => { + this.$notifyError(error) + this.loadingNic = false + }) + }, onChangeIPAddress (record) { this.editNicResource = record.nic this.editIpAddressNic = record.nic.id diff --git a/ui/src/views/network/NicsTable.vue b/ui/src/views/network/NicsTable.vue index 3a70eb3429c..5a6b358a735 100644 --- a/ui/src/views/network/NicsTable.vue +++ b/ui/src/views/network/NicsTable.vue @@ -61,6 +61,39 @@ <a-descriptions-item :label="$t('label.dns.name')" v-if="record.nicdnsname"> {{ record.nicdnsname }} </a-descriptions-item> + <a-descriptions-item :label="$t('label.nic.associatednetworks')" v-if="record.trunked && record.associatednetworks && record.associatednetworks.length > 0"> + <a-list size="small" :dataSource="record.associatednetworks" :rowKey="item => item.networkid"> + <template #renderItem="{ item }"> + <a-list-item> + <a-list-item-meta> + <template #title> + <router-link :to="{ path: '/guestnetwork/' + item.networkid }">{{ item.networkname }}</router-link> + <span v-if="item.broadcasturi" style="margin-left: 8px; color: rgba(0, 0, 0, 0.45)">({{ item.broadcasturi }})</span> + </template> + <template #description> + <span v-if="item.ipaddress">{{ $t('label.ipaddress') }}: {{ item.ipaddress }}</span> + <span v-if="item.gateway" style="margin-left: 10px">{{ $t('label.gateway') }}: {{ item.gateway }}</span> + </template> + </a-list-item-meta> + <template #actions> + <a-popconfirm + v-if="canManageAssociations" + :title="$t('message.disassociate.network.confirm')" + @confirm="$emit('disassociate-network', { nic: record, network: item })" + :okText="$t('label.yes')" + :cancelText="$t('label.no')"> + <tooltip-button + tooltipPlacement="top" + :tooltip="$t('label.disassociate.network')" + type="primary" + :danger="true" + icon="delete-outlined" /> + </a-popconfirm> + </template> + </a-list-item> + </template> + </a-list> + </a-descriptions-item> </a-descriptions> </template> <template #bodyCell="{ column, text, record }"> @@ -73,6 +106,11 @@ <a-tag v-if="record.isdefault"> {{ $t('label.default') }} </a-tag> + <a-tooltip v-if="record.trunked" :title="$t('message.nic.trunked.tooltip')"> + <a-tag color="blue"> + {{ $t('label.nic.trunked') }} + </a-tag> + </a-tooltip> </template> <template v-if="column.key === 'enabled'"> <status :text="text ? 'enabled' : 'disabled'"/> {{ text ? 'Enabled' : 'Disabled' }} @@ -85,6 +123,7 @@ import { getAPI } from '@/api' import ResourceIcon from '@/components/view/ResourceIcon' import Status from '@/components/widgets/Status' +import TooltipButton from '@/components/widgets/TooltipButton' export default { name: 'NicsTable', @@ -96,11 +135,17 @@ export default { loading: { type: Boolean, default: false + }, + canManageAssociations: { + type: Boolean, + default: false } }, + emits: ['disassociate-network'], components: { ResourceIcon, - Status + Status, + TooltipButton }, inject: ['parentFetchData'], data () { diff --git a/ui/src/views/network/PortForwarding.vue b/ui/src/views/network/PortForwarding.vue index ffa89e5b581..40262022ebf 100644 --- a/ui/src/views/network/PortForwarding.vue +++ b/ui/src/views/network/PortForwarding.vue @@ -274,13 +274,15 @@ :filterOption="(input, option) => { return option.label.toLowerCase().indexOf(input.toLowerCase()) >= 0 }" > - <a-select-option - v-for="(nic, nicIndex) in nics" - :key="nic" - :value="nic" - :label="nic"> - {{ nic }}{{ nicIndex === 0 ? ` (${$t('label.primary')})` : null }} - </a-select-option> + <a-select-opt-group v-for="group in groupedNicIpOptions" :key="group.kind" :label="group.label"> + <a-select-option + v-for="item in group.items" + :key="item.ip" + :value="item.ip" + :label="item.ip + ' ' + item.networkname"> + {{ item.ip }} <span class="nic-select__network">({{ item.networkname }})</span> + </a-select-option> + </a-select-opt-group> </a-select> </template> @@ -350,6 +352,7 @@ import TooltipButton from '@/components/widgets/TooltipButton' import BulkActionView from '@/components/view/BulkActionView' import eventBus from '@/config/eventBus' import TooltipLabel from '@/components/widgets/TooltipLabel.vue' +import { buildNicIpOptions, groupNicIpOptionsByKind, resolveNetworkIdForIp } from '@/utils/multiNetworkNic' export default { components: { @@ -399,7 +402,7 @@ export default { addVmModalLoading: false, addVmModalNicLoading: false, vms: [], - nics: [], + nicIpOptions: [], totalCount: 0, page: 1, pageSize: 10, @@ -480,6 +483,9 @@ export default { computed: { hasSelected () { return this.selectedRowKeys.length > 0 + }, + groupedNicIpOptions () { + return groupNicIpOptionsByKind(this.nicIpOptions, this.$t) } }, beforeCreate () { @@ -653,7 +659,8 @@ export default { if (this.loading) return this.loading = true this.addVmModalVisible = false - const networkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid + const primaryNetworkId = ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid + const networkId = resolveNetworkIdForIp(this.nicIpOptions, this.newRule.vmguestip, primaryNetworkId) postAPI('createPortForwardingRule', { ...this.newRule, ipaddressid: this.resource.id, @@ -706,7 +713,7 @@ export default { this.addVmModalLoading = false this.addVmModalNicLoading = false this.showConfirmationAction = false - this.nics = [] + this.nicIpOptions = [] this.checked = false this.resetTagInputs() }, @@ -806,20 +813,16 @@ export default { this.fetchVirtualMachines() }, fetchNics (e) { - this.nics = [] + this.nicIpOptions = [] this.addVmModalNicLoading = true this.newRule.virtualmachineid = e.target.value getAPI('listNics', { virtualmachineid: e.target.value, networkid: ('vpcid' in this.resource && (!('associatednetworkid' in this.resource) || this.vpcConserveMode)) ? this.selectedTier : this.resource.associatednetworkid }).then(response => { - if (!response.listnicsresponse.nic || response.listnicsresponse.nic.length < 1) return - const nic = response.listnicsresponse.nic[0] - this.nics.push(nic.ipaddress) - if (nic.secondaryip && nic.secondaryip.length > 0) { - this.nics.push(...nic.secondaryip.map(ip => ip.ipaddress)) - } - this.newRule.vmguestip = this.nics[0] + const nics = response.listnicsresponse.nic || [] + this.nicIpOptions = nics.flatMap(nic => buildNicIpOptions(nic)) + this.newRule.vmguestip = this.nicIpOptions[0]?.ip this.addVmModalNicLoading = false }).catch(error => { this.$notifyError(error) @@ -1085,4 +1088,8 @@ export default { width: 50%; float: right; } + + .nic-select__network { + color: rgba(0, 0, 0, 0.45); + } </style>
