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]">
+              &nbsp;
+              <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>

Reply via email to