Browse Source

Closes #105: Interface groups (#919)

* Initial work on interface groups

* Simplify to a single LAG form factor

* Correct interface serializer

* Allow for bulk editing of interface LAG

* Additional LAG interface validation

* Fixed API tests
Jeremy Stretch 8 years ago
parent
commit
c6970e1998

+ 11 - 7
netbox/circuits/forms.py

@@ -1,7 +1,7 @@
 from django import forms
 from django.db.models import Count
 
-from dcim.models import Site, Device, Interface, Rack, IFACE_FF_VIRTUAL
+from dcim.models import Site, Device, Interface, Rack, VIRTUAL_IFACE_TYPES
 from extras.forms import CustomFieldForm, CustomFieldBulkEditForm, CustomFieldFilterForm
 from tenancy.models import Tenant
 from utilities.forms import (
@@ -227,14 +227,18 @@ class CircuitTerminationForm(BootstrapMixin, forms.ModelForm):
 
         # Limit interface choices
         if self.is_bound and self.data.get('device'):
-            interfaces = Interface.objects.filter(device=self.data['device'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
-                                                                      'connected_as_b')
+            interfaces = Interface.objects.filter(device=self.data['device']).exclude(
+                form_factor__in=VIRTUAL_IFACE_TYPES
+            ).select_related(
+                'circuit_termination', 'connected_as_a', 'connected_as_b'
+            )
             self.fields['interface'].widget.attrs['initial'] = self.data.get('interface')
         elif self.initial.get('device'):
-            interfaces = Interface.objects.filter(device=self.initial['device'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL).select_related('circuit_termination', 'connected_as_a',
-                                                                      'connected_as_b')
+            interfaces = Interface.objects.filter(device=self.initial['device']).exclude(
+                form_factor__in=VIRTUAL_IFACE_TYPES
+            ).select_related(
+                'circuit_termination', 'connected_as_a', 'connected_as_b'
+            )
             self.fields['interface'].widget.attrs['initial'] = self.initial.get('interface')
         else:
             interfaces = []

+ 16 - 3
netbox/dcim/api/serializers.py

@@ -390,13 +390,24 @@ class PowerPortNestedSerializer(PowerPortSerializer):
 # Interfaces
 #
 
+class LAGInterfaceNestedSerializer(serializers.ModelSerializer):
+    form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
+
+    class Meta:
+        model = Interface
+        fields = ['id', 'name', 'form_factor']
+
+
 class InterfaceSerializer(serializers.ModelSerializer):
     device = DeviceNestedSerializer()
     form_factor = serializers.ReadOnlyField(source='get_form_factor_display')
+    lag = LAGInterfaceNestedSerializer()
 
     class Meta:
         model = Interface
-        fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected']
+        fields = [
+            'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
+        ]
 
 
 class InterfaceNestedSerializer(InterfaceSerializer):
@@ -410,8 +421,10 @@ class InterfaceDetailSerializer(InterfaceSerializer):
     connected_interface = InterfaceSerializer()
 
     class Meta(InterfaceSerializer.Meta):
-        fields = ['id', 'device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description', 'is_connected',
-                  'connected_interface']
+        fields = [
+            'id', 'device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description', 'is_connected',
+            'connected_interface',
+        ]
 
 
 #

+ 5 - 5
netbox/dcim/api/views.py

@@ -10,9 +10,9 @@ from django.http import Http404
 from django.shortcuts import get_object_or_404
 
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, IFACE_FF_VIRTUAL, Interface,
-    InterfaceConnection, Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation,
-    RackRole, Site,
+    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, DeviceType, Interface, InterfaceConnection,
+    Manufacturer, Module, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Site,
+    VIRTUAL_IFACE_TYPES,
 )
 from dcim import filters
 from extras.api.views import CustomFieldModelAPIView
@@ -359,9 +359,9 @@ class InterfaceListView(generics.ListAPIView):
         # Filter by type (physical or virtual)
         iface_type = self.request.query_params.get('type')
         if iface_type == 'physical':
-            queryset = queryset.exclude(form_factor=IFACE_FF_VIRTUAL)
+            queryset = queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
         elif iface_type == 'virtual':
-            queryset = queryset.filter(form_factor=IFACE_FF_VIRTUAL)
+            queryset = queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
         elif iface_type is not None:
             queryset = queryset.empty()
 

+ 17 - 2
netbox/dcim/filters.py

@@ -7,8 +7,9 @@ from extras.filters import CustomFieldFilterSet
 from tenancy.models import Tenant
 from utilities.filters import NullableModelMultipleChoiceFilter
 from .models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, Interface, InterfaceConnection, Manufacturer,
-    Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Site,
+    ConsolePort, ConsoleServerPort, Device, DeviceRole, DeviceType, IFACE_FF_LAG, Interface, InterfaceConnection,
+    Manufacturer, Platform, PowerOutlet, PowerPort, Rack, RackGroup, RackReservation, RackRole, Site,
+    VIRTUAL_IFACE_TYPES,
 )
 
 
@@ -374,11 +375,25 @@ class InterfaceFilter(django_filters.FilterSet):
         to_field_name='name',
         label='Device (name)',
     )
+    type = django_filters.MethodFilter(
+        action='filter_type',
+        label='Interface type',
+    )
 
     class Meta:
         model = Interface
         fields = ['name']
 
+    def filter_type(self, queryset, value):
+        value = value.strip().lower()
+        if value == 'physical':
+            return queryset.exclude(form_factor__in=VIRTUAL_IFACE_TYPES)
+        elif value == 'virtual':
+            return queryset.filter(form_factor__in=VIRTUAL_IFACE_TYPES)
+        elif value == 'lag':
+            return queryset.filter(form_factor=IFACE_FF_LAG)
+        return queryset
+
 
 class ConsoleConnectionFilter(django_filters.FilterSet):
     site = django_filters.MethodFilter(

+ 79 - 24
netbox/dcim/forms.py

@@ -18,9 +18,10 @@ from .formfields import MACAddressFormField
 from .models import (
     DeviceBay, DeviceBayTemplate, CONNECTION_STATUS_CHOICES, CONNECTION_STATUS_PLANNED, CONNECTION_STATUS_CONNECTED,
     ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceRole, DeviceType,
-    Interface, IFACE_FF_CHOICES, IFACE_FF_VIRTUAL, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
+    Interface, IFACE_FF_CHOICES, IFACE_FF_LAG, IFACE_ORDERING_CHOICES, InterfaceConnection, InterfaceTemplate,
     Manufacturer, Module, Platform, PowerOutlet, PowerOutletTemplate, PowerPort, PowerPortTemplate, RACK_TYPE_CHOICES,
-    RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD
+    RACK_WIDTH_CHOICES, Rack, RackGroup, RackReservation, RackRole, Site, STATUS_CHOICES, SUBDEVICE_ROLE_CHILD,
+    VIRTUAL_IFACE_TYPES
 )
 
 
@@ -53,6 +54,15 @@ def validate_connection_status(value):
         raise ValidationError('Invalid connection status ({}); must be either "planned" or "connected".'.format(value))
 
 
+class DeviceComponentForm(BootstrapMixin, forms.Form):
+    """
+    Allow inclusion of the parent device as context for limiting field choices.
+    """
+    def __init__(self, device, *args, **kwargs):
+        self.device = device
+        super(DeviceComponentForm, self).__init__(*args, **kwargs)
+
+
 #
 # Sites
 #
@@ -331,7 +341,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class ConsolePortTemplateCreateForm(BootstrapMixin, forms.Form):
+class ConsolePortTemplateCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -345,7 +355,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class ConsoleServerPortTemplateCreateForm(BootstrapMixin, forms.Form):
+class ConsoleServerPortTemplateCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -359,7 +369,7 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class PowerPortTemplateCreateForm(BootstrapMixin, forms.Form):
+class PowerPortTemplateCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -373,7 +383,7 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class PowerOutletTemplateCreateForm(BootstrapMixin, forms.Form):
+class PowerOutletTemplateCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -387,7 +397,7 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class InterfaceTemplateCreateForm(BootstrapMixin, forms.Form):
+class InterfaceTemplateCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
@@ -411,7 +421,7 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class DeviceBayTemplateCreateForm(BootstrapMixin, forms.Form):
+class DeviceBayTemplateCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -743,7 +753,7 @@ class ConsolePortForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class ConsolePortCreateForm(BootstrapMixin, forms.Form):
+class ConsolePortCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -914,7 +924,7 @@ class ConsoleServerPortForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form):
+class ConsoleServerPortCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -1012,7 +1022,7 @@ class PowerPortForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class PowerPortCreateForm(BootstrapMixin, forms.Form):
+class PowerPortCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -1181,7 +1191,7 @@ class PowerOutletForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class PowerOutletCreateForm(BootstrapMixin, forms.Form):
+class PowerOutletCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 
@@ -1273,27 +1283,65 @@ class InterfaceForm(BootstrapMixin, forms.ModelForm):
 
     class Meta:
         model = Interface
-        fields = ['device', 'name', 'form_factor', 'mac_address', 'mgmt_only', 'description']
+        fields = ['device', 'name', 'form_factor', 'lag', 'mac_address', 'mgmt_only', 'description']
         widgets = {
             'device': forms.HiddenInput(),
         }
 
+    def __init__(self, *args, **kwargs):
+        super(InterfaceForm, self).__init__(*args, **kwargs)
+
+        # Limit LAG choices to interfaces belonging to this device
+        if self.is_bound:
+            self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
+                device_id=self.data['device'], form_factor=IFACE_FF_LAG
+            )
+        else:
+            self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
+                device=self.instance.device, form_factor=IFACE_FF_LAG
+            )
+
 
-class InterfaceCreateForm(BootstrapMixin, forms.Form):
+class InterfaceCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
     form_factor = forms.ChoiceField(choices=IFACE_FF_CHOICES)
+    lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     mac_address = MACAddressFormField(required=False, label='MAC Address')
     mgmt_only = forms.BooleanField(required=False, label='OOB Management')
     description = forms.CharField(max_length=100, required=False)
 
+    def __init__(self, *args, **kwargs):
+        super(InterfaceCreateForm, self).__init__(*args, **kwargs)
+
+        # Limit LAG choices to interfaces belonging to this device
+        if self.device is not None:
+            self.fields['lag'].queryset = Interface.objects.order_naturally().filter(
+                device=self.device, form_factor=IFACE_FF_LAG
+            )
+        else:
+            self.fields['lag'].queryset = Interface.objects.none()
+
 
 class InterfaceBulkEditForm(BootstrapMixin, BulkEditForm):
     pk = forms.ModelMultipleChoiceField(queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput)
+    device = forms.ModelChoiceField(queryset=Device.objects.all(), widget=forms.HiddenInput)
+    lag = forms.ModelChoiceField(queryset=Interface.objects.all(), required=False, label='Parent LAG')
     form_factor = forms.ChoiceField(choices=add_blank_choice(IFACE_FF_CHOICES), required=False)
     description = forms.CharField(max_length=100, required=False)
 
     class Meta:
-        nullable_fields = ['description']
+        nullable_fields = ['lag', 'description']
+
+    def __init__(self, *args, **kwargs):
+        super(InterfaceBulkEditForm, self).__init__(*args, **kwargs)
+
+        # Limit LAG choices to interfaces which belong to the parent device.
+        if self.initial.get('device'):
+            self.fields['lag'].queryset = Interface.objects.filter(
+                device=self.initial['device'], form_factor=IFACE_FF_LAG
+            )
+        else:
+            self.fields['lag'].choices = []
 
 
 #
@@ -1360,8 +1408,11 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
         super(InterfaceConnectionForm, self).__init__(*args, **kwargs)
 
         # Initialize interface A choices
-        device_a_interfaces = Interface.objects.filter(device=device_a).exclude(form_factor=IFACE_FF_VIRTUAL)\
-            .select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
+        device_a_interfaces = Interface.objects.filter(device=device_a).exclude(
+            form_factor__in=VIRTUAL_IFACE_TYPES
+        ).select_related(
+            'circuit_termination', 'connected_as_a', 'connected_as_b'
+        )
         self.fields['interface_a'].choices = [
             (iface.id, {'label': iface.name, 'disabled': iface.is_connected}) for iface in device_a_interfaces
         ]
@@ -1388,13 +1439,17 @@ class InterfaceConnectionForm(BootstrapMixin, forms.ModelForm):
 
         # Initialize interface_b choices if device_b is set
         if self.is_bound:
-            device_b_interfaces = Interface.objects.filter(device=self.data['device_b'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL)\
-                .select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
+            device_b_interfaces = Interface.objects.filter(device=self.data['device_b']).exclude(
+                form_factor__in=VIRTUAL_IFACE_TYPES
+            ).select_related(
+                'circuit_termination', 'connected_as_a', 'connected_as_b'
+            )
         elif self.initial.get('device_b'):
-            device_b_interfaces = Interface.objects.filter(device=self.initial['device_b'])\
-                .exclude(form_factor=IFACE_FF_VIRTUAL)\
-                .select_related('circuit_termination', 'connected_as_a', 'connected_as_b')
+            device_b_interfaces = Interface.objects.filter(device=self.initial['device_b']).exclude(
+                form_factor__in=VIRTUAL_IFACE_TYPES
+            ).select_related(
+                'circuit_termination', 'connected_as_a', 'connected_as_b'
+            )
         else:
             device_b_interfaces = []
         self.fields['interface_b'].choices = [
@@ -1512,7 +1567,7 @@ class DeviceBayForm(BootstrapMixin, forms.ModelForm):
         }
 
 
-class DeviceBayCreateForm(BootstrapMixin, forms.Form):
+class DeviceBayCreateForm(DeviceComponentForm):
     name_pattern = ExpandableNameField(label='Name')
 
 

File diff suppressed because it is too large
+ 31 - 0
netbox/dcim/migrations/0030_interface_add_lag.py


+ 68 - 3
netbox/dcim/models.py

@@ -68,6 +68,7 @@ IFACE_ORDERING_CHOICES = [
 
 # Virtual
 IFACE_FF_VIRTUAL = 0
+IFACE_FF_LAG = 200
 # Ethernet
 IFACE_FF_100ME_FIXED = 800
 IFACE_FF_1GE_FIXED = 1000
@@ -106,6 +107,7 @@ IFACE_FF_CHOICES = [
         'Virtual interfaces',
         [
             [IFACE_FF_VIRTUAL, 'Virtual'],
+            [IFACE_FF_LAG, 'Link Aggregation Group (LAG)'],
         ]
     ],
     [
@@ -148,6 +150,7 @@ IFACE_FF_CHOICES = [
             [IFACE_FF_E1, 'E1 (2.048 Mbps)'],
             [IFACE_FF_T3, 'T3 (45 Mbps)'],
             [IFACE_FF_E3, 'E3 (34 Mbps)'],
+            [IFACE_FF_E3, 'E3 (34 Mbps)'],
         ]
     ],
     [
@@ -167,6 +170,11 @@ IFACE_FF_CHOICES = [
     ],
 ]
 
+VIRTUAL_IFACE_TYPES = [
+    IFACE_FF_VIRTUAL,
+    IFACE_FF_LAG,
+]
+
 STATUS_ACTIVE = True
 STATUS_OFFLINE = False
 STATUS_CHOICES = [
@@ -1062,6 +1070,10 @@ class Device(CreatedUpdatedModel, CustomFieldModel):
         return RPC_CLIENTS.get(self.platform.rpc_client)
 
 
+#
+# Console ports
+#
+
 @python_2_unicode_compatible
 class ConsolePort(models.Model):
     """
@@ -1091,6 +1103,10 @@ class ConsolePort(models.Model):
         ])
 
 
+#
+# Console server ports
+#
+
 class ConsoleServerPortManager(models.Manager):
 
     def get_queryset(self):
@@ -1123,6 +1139,10 @@ class ConsoleServerPort(models.Model):
         return self.name
 
 
+#
+# Power ports
+#
+
 @python_2_unicode_compatible
 class PowerPort(models.Model):
     """
@@ -1152,6 +1172,10 @@ class PowerPort(models.Model):
         ])
 
 
+#
+# Power outlets
+#
+
 class PowerOutletManager(models.Manager):
 
     def get_queryset(self):
@@ -1178,6 +1202,10 @@ class PowerOutlet(models.Model):
         return self.name
 
 
+#
+# Interfaces
+#
+
 @python_2_unicode_compatible
 class Interface(models.Model):
     """
@@ -1185,6 +1213,8 @@ class Interface(models.Model):
     of an InterfaceConnection.
     """
     device = models.ForeignKey('Device', related_name='interfaces', on_delete=models.CASCADE)
+    lag = models.ForeignKey('self', related_name='member_interfaces', null=True, blank=True, on_delete=models.SET_NULL,
+                            verbose_name='Parent LAG')
     name = models.CharField(max_length=30)
     form_factor = models.PositiveSmallIntegerField(choices=IFACE_FF_CHOICES, default=IFACE_FF_10GE_SFP_PLUS)
     mac_address = MACAddressField(null=True, blank=True, verbose_name='MAC Address')
@@ -1203,15 +1233,42 @@ class Interface(models.Model):
 
     def clean(self):
 
-        if self.form_factor == IFACE_FF_VIRTUAL and self.is_connected:
+        # Virtual interfaces cannot be connected
+        if self.form_factor in VIRTUAL_IFACE_TYPES and self.is_connected:
             raise ValidationError({
                 'form_factor': "Virtual interfaces cannot be connected to another interface or circuit. Disconnect the "
                                "interface or choose a physical form factor."
             })
 
+        # An interface's LAG must belong to the same device
+        if self.lag and self.lag.device != self.device:
+            raise ValidationError({
+                'lag': u"The selected LAG interface ({}) belongs to a different device ({}).".format(
+                    self.lag.name, self.lag.device.name
+                )
+            })
+
+        # A LAG interface cannot have a parent LAG
+        if self.form_factor == IFACE_FF_LAG and self.lag is not None:
+            raise ValidationError({
+                'lag': u"{} interfaces cannot have a parent LAG interface.".format(self.get_form_factor_display())
+            })
+
+        # Only a LAG can have LAG members
+        if self.form_factor != IFACE_FF_LAG and self.member_interfaces.exists():
+            raise ValidationError({
+                'form_factor': "Cannot change interface form factor; it has LAG members ({}).".format(
+                    u", ".join([iface.name for iface in self.member_interfaces.all()])
+                )
+            })
+
     @property
-    def is_physical(self):
-        return self.form_factor != IFACE_FF_VIRTUAL
+    def is_virtual(self):
+        return self.form_factor in VIRTUAL_IFACE_TYPES
+
+    @property
+    def is_lag(self):
+        return self.form_factor == IFACE_FF_LAG
 
     @property
     def is_connected(self):
@@ -1275,6 +1332,10 @@ class InterfaceConnection(models.Model):
         ])
 
 
+#
+# Device bays
+#
+
 @python_2_unicode_compatible
 class DeviceBay(models.Model):
     """
@@ -1305,6 +1366,10 @@ class DeviceBay(models.Model):
             raise ValidationError("Cannot install a device into itself.")
 
 
+#
+# Modules
+#
+
 @python_2_unicode_compatible
 class Module(models.Model):
     """

+ 2 - 0
netbox/dcim/tests/test_apis.py

@@ -576,6 +576,7 @@ class InterfaceTest(APITestCase):
         'device',
         'name',
         'form_factor',
+        'lag',
         'mac_address',
         'mgmt_only',
         'description',
@@ -589,6 +590,7 @@ class InterfaceTest(APITestCase):
         'device',
         'name',
         'form_factor',
+        'lag',
         'mac_address',
         'mgmt_only',
         'description',

+ 3 - 2
netbox/dcim/views.py

@@ -66,11 +66,12 @@ class ComponentCreateView(View):
     def get(self, request, pk):
 
         parent = get_object_or_404(self.parent_model, pk=pk)
+        form = self.form(parent, initial=request.GET)
 
         return render(request, 'dcim/device_component_add.html', {
             'parent': parent,
             'component_type': self.model._meta.verbose_name,
-            'form': self.form(initial=request.GET),
+            'form': form,
             'return_url': parent.get_absolute_url(),
         })
 
@@ -78,7 +79,7 @@ class ComponentCreateView(View):
 
         parent = get_object_or_404(self.parent_model, pk=pk)
 
-        form = self.form(request.POST)
+        form = self.form(parent, request.POST)
         if form.is_valid():
 
             new_components = []

+ 1 - 0
netbox/templates/dcim/device.html

@@ -396,6 +396,7 @@
             {% if perms.dcim.delete_interface %}
                 <form method="post">
                 {% csrf_token %}
+                <input type="hidden" name="device" value="{{ device.pk }}" />
             {% endif %}
             <div class="panel panel-default">
                 <div class="panel-heading">

+ 1 - 1
netbox/templates/dcim/device_component_add.html

@@ -3,7 +3,7 @@
 
 {% block title %}Create {{ component_type }} ({{ parent }}){% endblock %}
 
-{% block content %}{{ form.errors }}
+{% block content %}
 <form action="." method="post" class="form form-horizontal">
     {% csrf_token %}
     <div class="row">

+ 8 - 2
netbox/templates/dcim/inc/interface.html

@@ -6,14 +6,20 @@
     {% endif %}
     <td>
         <i class="fa fa-fw fa-{{ icon|default:"exchange" }}"></i> <span title="{{ iface.get_form_factor_display }}">{{ iface.name }}</span>
+        {% if iface.lag %}
+            <span class="label label-primary">{{ iface.lag.name }}</span>
+        {% endif %}
         {% if iface.description %}
             <i class="fa fa-fw fa-comment-o" title="{{ iface.description }}"></i>
         {% endif %}
+        {% if iface.is_lag %}
+            <br /><small class="text-muted">{{ iface.member_interfaces.all|join:", "|default:"No members" }}</small>
+        {% endif %}
     </td>
     <td>
         <small>{{ iface.mac_address|default:'' }}</small>
     </td>
-    {% if not iface.is_physical %}
+    {% if iface.is_virtual %}
         <td colspan="2" class="text-muted">Virtual interface</td>
     {% elif iface.connection %}
         {% with iface.connected_interface as connected_iface %}
@@ -48,7 +54,7 @@
             {% endif %}
         {% endif %}
         {% if perms.dcim.change_interface %}
-            {% if iface.is_physical %}
+            {% if not iface.is_virtual %}
                 {% if iface.connection %}
                     {% if iface.connection.connection_status %}
                         <a href="#" class="btn btn-warning btn-xs interface-toggle connected" data="{{ iface.connection.pk }}" title="Mark planned">

+ 3 - 1
netbox/utilities/views.py

@@ -471,7 +471,9 @@ class BulkEditView(View):
                 return redirect(return_url)
 
         else:
-            form = self.form(self.cls, initial={'pk': pk_list})
+            initial_data = request.POST.copy()
+            initial_data['pk'] = pk_list
+            form = self.form(self.cls, initial=initial_data)
 
         selected_objects = self.cls.objects.filter(pk__in=pk_list)
         if not selected_objects: