-
- {% plugin_full_width_page object %}
-
+
+
+ {% include 'panel_table.html' with table=child_interfaces_table heading="Child Interfaces" %}
+
+
+
+ {% plugin_full_width_page object %}
+
+
{% endblock %}
diff --git a/netbox/templates/virtualization/vminterface_edit.html b/netbox/templates/virtualization/vminterface_edit.html
index c0ad6e98c..f3ab4f9c2 100644
--- a/netbox/templates/virtualization/vminterface_edit.html
+++ b/netbox/templates/virtualization/vminterface_edit.html
@@ -17,6 +17,7 @@
{% endif %}
{% render_field form.name %}
{% render_field form.enabled %}
+ {% render_field form.parent %}
{% render_field form.mac_address %}
{% render_field form.mtu %}
{% render_field form.description %}
diff --git a/netbox/virtualization/api/serializers.py b/netbox/virtualization/api/serializers.py
index 0afa8f796..a1428f0cd 100644
--- a/netbox/virtualization/api/serializers.py
+++ b/netbox/virtualization/api/serializers.py
@@ -106,6 +106,7 @@ class VirtualMachineWithConfigContextSerializer(VirtualMachineSerializer):
class VMInterfaceSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='virtualization-api:vminterface-detail')
virtual_machine = NestedVirtualMachineSerializer()
+ parent = NestedVMInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
@@ -118,8 +119,8 @@ class VMInterfaceSerializer(PrimaryModelSerializer):
class Meta:
model = VMInterface
fields = [
- 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'mtu', 'mac_address', 'description', 'mode',
- 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
+ 'id', 'url', 'display', 'virtual_machine', 'name', 'enabled', 'parent', 'mtu', 'mac_address', 'description',
+ 'mode', 'untagged_vlan', 'tagged_vlans', 'tags', 'custom_fields', 'created', 'last_updated',
]
def validate(self, data):
diff --git a/netbox/virtualization/api/views.py b/netbox/virtualization/api/views.py
index ea2b33e4f..5f67a7c74 100644
--- a/netbox/virtualization/api/views.py
+++ b/netbox/virtualization/api/views.py
@@ -80,7 +80,7 @@ class VirtualMachineViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet)
class VMInterfaceViewSet(ModelViewSet):
queryset = VMInterface.objects.prefetch_related(
- 'virtual_machine', 'tags', 'tagged_vlans'
+ 'virtual_machine', 'parent', 'tags', 'tagged_vlans'
)
serializer_class = serializers.VMInterfaceSerializer
filterset_class = filters.VMInterfaceFilterSet
diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py
index be9b70749..d710bcbe2 100644
--- a/netbox/virtualization/filters.py
+++ b/netbox/virtualization/filters.py
@@ -264,6 +264,11 @@ class VMInterfaceFilterSet(BaseFilterSet, CustomFieldModelFilterSet, CreatedUpda
to_field_name='name',
label='Virtual machine',
)
+ parent_id = django_filters.ModelMultipleChoiceFilter(
+ field_name='parent',
+ queryset=VMInterface.objects.all(),
+ label='Parent interface (ID)',
+ )
mac_address = MultiValueMACAddressFilter(
label='MAC address',
)
diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py
index 014b73542..a5c671287 100644
--- a/netbox/virtualization/forms.py
+++ b/netbox/virtualization/forms.py
@@ -603,6 +603,11 @@ class VirtualMachineFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldFil
#
class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
+ parent = DynamicModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ label='Parent interface'
+ )
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False,
@@ -621,8 +626,8 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
class Meta:
model = VMInterface
fields = [
- 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'description', 'mode', 'tags', 'untagged_vlan',
- 'tagged_vlans',
+ 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'description', 'mode', 'tags',
+ 'untagged_vlan', 'tagged_vlans',
]
widgets = {
'virtual_machine': forms.HiddenInput(),
@@ -637,9 +642,12 @@ class VMInterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+ # Restrict parent interface assignment by VM
+ self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
# Limit VLAN choices by virtual machine
- vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
@@ -655,6 +663,14 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
required=False,
initial=True
)
+ parent = DynamicModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ display_field='display_name',
+ query_params={
+ 'virtualmachine_id': 'virtual_machine',
+ }
+ )
mtu = forms.IntegerField(
required=False,
min_value=INTERFACE_MTU_MIN,
@@ -689,9 +705,12 @@ class VMInterfaceCreateForm(BootstrapMixin, InterfaceCommonForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+ # Restrict parent interface assignment by VM
+ self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
# Limit VLAN choices by virtual machine
- vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
@@ -730,6 +749,11 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
disabled=True,
widget=forms.HiddenInput()
)
+ parent = DynamicModelChoiceField(
+ queryset=VMInterface.objects.all(),
+ required=False,
+ display_field='display_name'
+ )
enabled = forms.NullBooleanField(
required=False,
widget=BulkEditNullBooleanSelect()
@@ -760,14 +784,17 @@ class VMInterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm):
class Meta:
nullable_fields = [
- 'mtu', 'description',
+ 'parent', 'mtu', 'description',
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
+ vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
+
+ # Restrict parent interface assignment by VM
+ self.fields['parent'].widget.add_query_param('virtualmachine_id', vm_id)
# Limit VLAN choices by virtual machine
- vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)
diff --git a/netbox/virtualization/migrations/0022_vminterface_parent.py b/netbox/virtualization/migrations/0022_vminterface_parent.py
new file mode 100644
index 000000000..d1249985f
--- /dev/null
+++ b/netbox/virtualization/migrations/0022_vminterface_parent.py
@@ -0,0 +1,17 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('virtualization', '0021_virtualmachine_vcpus_decimal'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='vminterface',
+ name='parent',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='child_interfaces', to='virtualization.vminterface'),
+ ),
+ ]
diff --git a/netbox/virtualization/models.py b/netbox/virtualization/models.py
index a34d09662..ee8b3e62b 100644
--- a/netbox/virtualization/models.py
+++ b/netbox/virtualization/models.py
@@ -395,6 +395,14 @@ class VMInterface(PrimaryModel, BaseInterface):
max_length=200,
blank=True
)
+ parent = models.ForeignKey(
+ to='self',
+ on_delete=models.SET_NULL,
+ related_name='child_interfaces',
+ null=True,
+ blank=True,
+ verbose_name='Parent interface'
+ )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -438,6 +446,7 @@ class VMInterface(PrimaryModel, BaseInterface):
self.virtual_machine.name,
self.name,
self.enabled,
+ self.parent.name if self.parent else None,
self.mac_address,
self.mtu,
self.description,
@@ -447,6 +456,13 @@ class VMInterface(PrimaryModel, BaseInterface):
def clean(self):
super().clean()
+ # An interface's parent must belong to the same virtual machine
+ if self.parent and self.parent.virtual_machine != self.virtual_machine:
+ raise ValidationError({
+ 'parent': f"The selected parent interface ({self.parent}) belongs to a different virtual machine "
+ f"({self.parent.virtual_machine})."
+ })
+
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.virtual_machine.site, None]:
raise ValidationError({
diff --git a/netbox/virtualization/tables.py b/netbox/virtualization/tables.py
index c30f14165..65bd2b5d1 100644
--- a/netbox/virtualization/tables.py
+++ b/netbox/virtualization/tables.py
@@ -170,6 +170,9 @@ class VMInterfaceTable(BaseInterfaceTable):
name = tables.Column(
linkify=True
)
+ parent = tables.Column(
+ linkify=True
+ )
tags = TagColumn(
url_name='virtualization:vminterface_list'
)
@@ -177,10 +180,10 @@ class VMInterfaceTable(BaseInterfaceTable):
class Meta(BaseTable.Meta):
model = VMInterface
fields = (
- 'pk', 'virtual_machine', 'name', 'enabled', 'mac_address', 'mtu', 'mode', 'description', 'tags',
+ 'pk', 'virtual_machine', 'name', 'enabled', 'parent', 'mac_address', 'mtu', 'mode', 'description', 'tags',
'ip_addresses', 'untagged_vlan', 'tagged_vlans',
)
- default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'description')
+ default_columns = ('pk', 'virtual_machine', 'name', 'enabled', 'parent', 'description')
class VirtualMachineVMInterfaceTable(VMInterfaceTable):
diff --git a/netbox/virtualization/tests/test_filters.py b/netbox/virtualization/tests/test_filters.py
index e822d8763..c11423663 100644
--- a/netbox/virtualization/tests/test_filters.py
+++ b/netbox/virtualization/tests/test_filters.py
@@ -453,12 +453,25 @@ class VMInterfaceTestCase(TestCase):
params = {'name': ['Interface 1', 'Interface 2']}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
- def test_assigned_to_interface(self):
+ def test_enabled(self):
params = {'enabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_parent(self):
+ # Create child interfaces
+ parent_interface = VMInterface.objects.first()
+ child_interfaces = (
+ VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 1', parent=parent_interface),
+ VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 2', parent=parent_interface),
+ VMInterface(virtual_machine=parent_interface.virtual_machine, name='Child 3', parent=parent_interface),
+ )
+ VMInterface.objects.bulk_create(child_interfaces)
+
+ params = {'parent_id': [parent_interface.pk]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
+
def test_mtu(self):
params = {'mtu': [100, 200]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py
index 6bf9eb6dd..6b316de0e 100644
--- a/netbox/virtualization/views.py
+++ b/netbox/virtualization/views.py
@@ -421,6 +421,14 @@ class VMInterfaceView(generic.ObjectView):
orderable=False
)
+ # Get child interfaces
+ child_interfaces = VMInterface.objects.restrict(request.user, 'view').filter(parent=instance)
+ child_interfaces_tables = tables.VMInterfaceTable(
+ child_interfaces,
+ orderable=False
+ )
+ child_interfaces_tables.columns.hide('virtual_machine')
+
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
@@ -437,6 +445,7 @@ class VMInterfaceView(generic.ObjectView):
return {
'ipaddress_table': ipaddress_table,
+ 'child_interfaces_table': child_interfaces_tables,
'vlan_table': vlan_table,
}