Jelajahi Sumber

Closes #20972: Add support for channelized subinterfaces (#22647)

Jeremy Stretch 1 bulan lalu
induk
melakukan
d5dca3ae81

+ 15 - 2
docs/models/dcim/interface.md

@@ -28,11 +28,17 @@ An alternative physical label identifying the interface.
 
 ### Type
 
-The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables.
+The type of interface. Interfaces may be physical or virtual in nature, but only physical interfaces may be connected via cables. The generic **channel** type identifies a [channelized subinterface](#channel-id) bound to a parent interface.
 
 !!! note
     The interface type refers to the physical termination or port on the device. Interfaces which employ a removable optic or similar transceiver should be defined to represent the type of transceiver in use, irrespective of the physical termination to that transceiver.
 
+### Channels
+
+For a channelized (breakout) interface, the number of physical channels into which the interface is divided. For example, a 40GE interface broken out into four 10GE channels would have `channels` set to four. Each channel is modeled as a channel-type subinterface bound to this interface via its [channel ID](#channel-id).
+
+A single physical cable terminates to the channelized (parent) interface, occupying one connector shared by all of its channels; NetBox traces a distinct cable path for each channel subinterface. Only one layer of channelization is supported: an interface cannot be both channelized and itself bound to a channel.
+
 ### Speed
 
 The operating speed, in kilobits per second (kbps).
@@ -78,11 +84,18 @@ If selected, this component will be treated as if a cable has been connected.
 
 ### Parent Interface
 
-Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface.
+Virtual interfaces can be bound to a physical parent interface. This is helpful for modeling virtual interfaces which employ encapsulation on a physical interface, such as an 802.1Q VLAN-tagged subinterface. Channel-type subinterfaces are likewise bound to their [channelized](#channels) parent interface.
 
 !!! note
     An interface with one or more child interfaces assigned cannot be deleted until all its child interfaces have been deleted or reassigned.
 
+### Channel ID
+
+For a channel-type subinterface, the numeric channel on its [channelized](#channels) parent interface to which this subinterface is bound. The channel ID must fall within the range of channels provided by the parent (e.g. one through four for a parent with four channels). A channel subinterface derives its cable connection from the parent's; it cannot be cabled directly.
+
+!!! note "Channel IDs are one-indexed"
+    Channel IDs increment starting at one, even for interfaces with a zero-based identifier. This ensures that each subinterface maps cleanly to the profile of an attached cable.
+
 ### Bridged Interface
 
 Interfaces can be bridged to other interfaces on a device in two manners: symmetric or grouped.

+ 2 - 1
netbox/dcim/api/serializers_/device_components.py

@@ -263,7 +263,8 @@ class InterfaceSerializer(
     class Meta:
         model = Interface
         fields = [
-            'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'enabled',
+            'id', 'url', 'display_url', 'display', 'device', 'vdcs', 'module', 'name', 'label', 'type', 'channels',
+            'channel_id', 'enabled',
             'parent', 'bridge', 'bridge_interfaces', 'lag', 'mtu', 'mac_address', 'primary_mac_address',
             'mac_addresses', 'speed', 'duplex', 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel',
             'poe_mode', 'poe_type', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'untagged_vlan',

+ 7 - 2
netbox/dcim/api/serializers_/devicetype_components.py

@@ -184,6 +184,10 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
         default=None
     )
     type = ChoiceField(choices=InterfaceTypeChoices)
+    parent = NestedInterfaceTemplateSerializer(
+        required=False,
+        allow_null=True
+    )
     bridge = NestedInterfaceTemplateSerializer(
         required=False,
         allow_null=True
@@ -210,8 +214,9 @@ class InterfaceTemplateSerializer(ComponentTemplateSerializer):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'enabled',
-            'mgmt_only', 'description', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created', 'last_updated',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id',
+            'enabled', 'mgmt_only', 'description', 'parent', 'bridge', 'poe_mode', 'poe_type', 'rf_role', 'created',
+            'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
 

+ 2 - 0
netbox/dcim/choices.py

@@ -914,6 +914,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_VIRTUAL = 'virtual'
     TYPE_BRIDGE = 'bridge'
     TYPE_LAG = 'lag'
+    TYPE_CHANNEL = 'channel'
 
     # FastEthernet
     TYPE_100ME_FX = '100base-fx'
@@ -1185,6 +1186,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 Choice(TYPE_VIRTUAL, _('Virtual')),
                 Choice(TYPE_BRIDGE, _('Bridge')),
                 Choice(TYPE_LAG, _('Link Aggregation Group (LAG)')),
+                Choice(TYPE_CHANNEL, _('Channel')),
             ),
         ),
         (

+ 10 - 1
netbox/dcim/constants.py

@@ -48,6 +48,12 @@ PORT_POSITION_MAX = 1024
 INTERFACE_MTU_MIN = 1
 INTERFACE_MTU_MAX = 65536
 
+# The number of channels on a channelized interface, and the channel to which a subinterface is bound. A subinterface's
+# channel_id maps directly to a position on the parent interface's cable connector, so these are bounded by the maximum
+# cable position.
+INTERFACE_CHANNELS_MIN = CABLE_POSITION_MIN
+INTERFACE_CHANNELS_MAX = CABLE_POSITION_MAX
+
 VIRTUAL_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_VIRTUAL,
     InterfaceTypeChoices.TYPE_LAG,
@@ -73,7 +79,10 @@ WIRELESS_IFACE_TYPES = [
     InterfaceTypeChoices.TYPE_5G,
 ]
 
-NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
+NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES + [
+    # Channel subinterfaces derive their cable from the (channelized) parent interface and cannot be cabled directly
+    InterfaceTypeChoices.TYPE_CHANNEL,
+]
 
 
 #

+ 9 - 4
netbox/dcim/filtersets.py

@@ -1058,6 +1058,11 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
         distinct=False,
         null_value=None
     )
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='parent',
+        queryset=InterfaceTemplate.objects.all(),
+        distinct=False,
+    )
     bridge_id = django_filters.ModelMultipleChoiceFilter(
         field_name='bridge',
         queryset=InterfaceTemplate.objects.all(),
@@ -1078,7 +1083,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCo
 
     class Meta:
         model = InterfaceTemplate
-        fields = ('id', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description')
+        fields = ('id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description')
 
 
 @register_filterset
@@ -2416,9 +2421,9 @@ class InterfaceFilterSet(
     class Meta:
         model = Interface
         fields = (
-            'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'poe_mode', 'poe_type', 'mode', 'rf_role',
-            'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'description', 'mark_connected',
-            'cable_id', 'cable_end', 'cable_connector',
+            'id', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mtu', 'mgmt_only', 'poe_mode',
+            'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
+            'description', 'mark_connected', 'cable_id', 'cable_end', 'cable_connector',
         )
 
     def filter_virtual_chassis_member_or_master(self, queryset, name, value):

+ 17 - 9
netbox/dcim/forms/bulk_edit.py

@@ -1191,6 +1191,14 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         choices=add_blank_choice(InterfaceTypeChoices),
         required=False
     )
+    channels = forms.IntegerField(
+        label=_('Channels'),
+        required=False
+    )
+    channel_id = forms.IntegerField(
+        label=_('Channel ID'),
+        required=False
+    )
     enabled = forms.NullBooleanField(
         label=_('Enabled'),
         required=False,
@@ -1224,7 +1232,7 @@ class InterfaceTemplateBulkEditForm(ComponentTemplateBulkEditForm):
         label=_('Wireless role')
     )
 
-    nullable_fields = ('label', 'description', 'poe_mode', 'poe_type', 'rf_role')
+    nullable_fields = ('label', 'channels', 'channel_id', 'description', 'poe_mode', 'poe_type', 'rf_role')
 
 
 class FrontPortTemplateBulkEditForm(ComponentTemplateBulkEditForm):
@@ -1477,9 +1485,9 @@ class PowerOutletBulkEditForm(
 class InterfaceBulkEditForm(
     ComponentBulkEditForm,
     form_from_model(Interface, [
-        'label', 'type', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu', 'mgmt_only', 'mark_connected',
-        'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-        'wireless_lans', 'vlan_translation_policy'
+        'label', 'type', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'mtu',
+        'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+        'rf_channel_width', 'tx_power', 'wireless_lans', 'vlan_translation_policy'
     ])
 ):
     enabled = forms.NullBooleanField(
@@ -1627,11 +1635,11 @@ class InterfaceBulkEditForm(
 
     model = Interface
     fieldsets = (
-        FieldSet('module', 'type', 'label', 'speed', 'duplex', 'description'),
+        FieldSet('module', 'type', 'channels', 'label', 'speed', 'duplex', 'description'),
         FieldSet('vrf', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
-        FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'qinq_svlan', 'vlan_translation_policy', name=_('802.1Q Switching')
         ),
@@ -1647,9 +1655,9 @@ class InterfaceBulkEditForm(
         ),
     )
     nullable_fields = (
-        'module', 'label', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs', 'mtu', 'description',
-        'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-        'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
+        'module', 'label', 'channels', 'channel_id', 'parent', 'bridge', 'lag', 'speed', 'duplex', 'wwn', 'vdcs',
+        'mtu', 'description', 'poe_mode', 'poe_type', 'mode', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
+        'tx_power', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'wireless_lans', 'vlan_translation_policy',
     )
 
     def __init__(self, *args, **kwargs):

+ 3 - 3
netbox/dcim/forms/bulk_import.py

@@ -1080,9 +1080,9 @@ class InterfaceImportForm(OwnerCSVMixin, NetBoxModelImportForm):
     class Meta:
         model = Interface
         fields = (
-            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'speed', 'duplex', 'enabled',
-            'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type', 'mode',
-            'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
+            'device', 'name', 'label', 'parent', 'bridge', 'lag', 'type', 'channels', 'channel_id', 'speed', 'duplex',
+            'enabled', 'mark_connected', 'wwn', 'vdcs', 'mtu', 'mgmt_only', 'description', 'poe_mode', 'poe_type',
+            'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vrf', 'rf_role', 'rf_channel',
             'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'owner', 'tags'
         )
 

+ 12 - 1
netbox/dcim/forms/filtersets.py

@@ -1687,7 +1687,10 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
     model = Interface
     fieldsets = (
         FieldSet('q', 'filter_id', 'tag'),
-        FieldSet('name', 'label', 'kind', 'type', 'speed', 'duplex', 'enabled', 'mgmt_only', name=_('Attributes')),
+        FieldSet(
+            'name', 'label', 'kind', 'type', 'channels', 'channel_id', 'speed', 'duplex', 'enabled', 'mgmt_only',
+            name=_('Attributes')
+        ),
         FieldSet('vrf_id', 'l2vpn_id', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('mode', 'vlan_translation_policy_id', name=_('802.1Q Switching')),
@@ -1720,6 +1723,14 @@ class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm):
         choices=InterfaceTypeChoices,
         required=False
     )
+    channels = forms.IntegerField(
+        label=_('Channels'),
+        required=False
+    )
+    channel_id = forms.IntegerField(
+        label=_('Channel ID'),
+        required=False
+    )
     speed = PositiveBigIntegerField(
         label=_('Speed'),
         required=False,

+ 21 - 10
netbox/dcim/forms/model_forms.py

@@ -1391,6 +1391,15 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
         choices=add_blank_choice(WirelessRoleChoices),
         required=False,
     )
+    parent = DynamicModelChoiceField(
+        label=_('Parent'),
+        queryset=InterfaceTemplate.objects.all(),
+        required=False,
+        query_params={
+            'device_type_id': '$device_type',
+            'module_type_id': '$module_type',
+        }
+    )
     bridge = DynamicModelChoiceField(
         label=_('Bridge'),
         queryset=InterfaceTemplate.objects.all(),
@@ -1407,7 +1416,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
                 FieldSet('device_type', name=_('Device Type')),
                 FieldSet('module_type', name=_('Module Type')),
             ),
-            'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'bridge',
+            'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only', 'description', 'parent',
+            'bridge',
         ),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet('rf_role', name=_('Wireless')),
@@ -1416,8 +1426,8 @@ class InterfaceTemplateForm(ModularComponentTemplateForm):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'enabled', 'description', 'poe_mode',
-            'poe_type', 'bridge', 'rf_role',
+            'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'mgmt_only', 'enabled',
+            'description', 'poe_mode', 'poe_type', 'parent', 'bridge', 'rf_role',
         ]
 
 
@@ -1939,11 +1949,12 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
 
     fieldsets = (
         FieldSet(
-            'device', 'module', 'name', 'label', 'type', 'speed', 'duplex', 'description', 'tags', name=_('Interface')
+            'device', 'module', 'name', 'label', 'type', 'channels', 'speed', 'duplex', 'description', 'tags',
+            name=_('Interface')
         ),
         FieldSet('vrf', 'mac_address', 'wwn', name=_('Addressing')),
         FieldSet('vdcs', 'mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected', name=_('Operation')),
-        FieldSet('parent', 'bridge', 'lag', name=_('Related Interfaces')),
+        FieldSet('parent', 'channel_id', 'bridge', 'lag', name=_('Related Interfaces')),
         FieldSet('poe_mode', 'poe_type', name=_('PoE')),
         FieldSet(
             'mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
@@ -1959,11 +1970,11 @@ class InterfaceForm(InterfaceCommonForm, ModularDeviceComponentForm):
     class Meta:
         model = Interface
         fields = [
-            'device', 'module', 'vdcs', 'name', 'label', 'type', 'speed', 'duplex', 'enabled', 'parent', 'bridge',
-            'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description', 'poe_mode', 'poe_type', 'mode',
-            'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'wireless_lans',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy', 'vrf',
-            'owner', 'tags',
+            'device', 'module', 'vdcs', 'name', 'label', 'type', 'channels', 'channel_id', 'speed', 'duplex',
+            'enabled', 'parent', 'bridge', 'lag', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description',
+            'poe_mode', 'poe_type', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
+            'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'vlan_translation_policy',
+            'vrf', 'owner', 'tags',
         ]
         widgets = {
             'speed': NumberWithOptions(

+ 18 - 3
netbox/dcim/forms/object_create.py

@@ -4,7 +4,12 @@ from django.utils.translation import gettext_lazy as _
 from dcim.models import *
 from netbox.forms import NetBoxModelForm
 from netbox.forms.mixins import OwnerMixin
-from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField
+from utilities.forms.fields import (
+    DynamicModelChoiceField,
+    DynamicModelMultipleChoiceField,
+    ExpandableNameField,
+    ExpandableNumericField,
+)
 from utilities.forms.rendering import FieldSet, TabbedGroups
 from utilities.forms.widgets import APISelect
 
@@ -105,9 +110,14 @@ class PowerOutletTemplateCreateForm(ComponentCreateForm, model_forms.PowerOutlet
 
 
 class InterfaceTemplateCreateForm(ComponentCreateForm, model_forms.InterfaceTemplateForm):
+    channel_id = ExpandableNumericField(
+        label=_('Channel ID'),
+        required=False
+    )
+    replication_fields = ('name', 'label', 'channel_id')
 
     class Meta(model_forms.InterfaceTemplateForm.Meta):
-        exclude = ('name', 'label')
+        exclude = ('name', 'label', 'channel_id')
 
 
 class FrontPortTemplateCreateForm(ComponentCreateForm, model_forms.FrontPortTemplateForm):
@@ -197,9 +207,14 @@ class PowerOutletCreateForm(ComponentCreateForm, model_forms.PowerOutletForm):
 
 
 class InterfaceCreateForm(ComponentCreateForm, model_forms.InterfaceForm):
+    channel_id = ExpandableNumericField(
+        label=_('Channel ID'),
+        required=False
+    )
+    replication_fields = ('name', 'label', 'channel_id')
 
     class Meta(model_forms.InterfaceForm.Meta):
-        exclude = ('name', 'label')
+        exclude = ('name', 'label', 'channel_id')
 
 
 class FrontPortCreateForm(ComponentCreateForm, model_forms.FrontPortForm):

+ 2 - 2
netbox/dcim/forms/object_import.py

@@ -104,8 +104,8 @@ class InterfaceTemplateImportForm(forms.ModelForm):
     class Meta:
         model = InterfaceTemplate
         fields = [
-            'device_type', 'module_type', 'name', 'label', 'type', 'enabled', 'mgmt_only', 'description', 'poe_mode',
-            'poe_type', 'rf_role'
+            'device_type', 'module_type', 'name', 'label', 'type', 'channels', 'channel_id', 'enabled', 'mgmt_only',
+            'description', 'poe_mode', 'poe_type', 'rf_role'
         ]
 
 

+ 16 - 0
netbox/dcim/graphql/filters.py

@@ -540,6 +540,12 @@ class InterfaceFilter(
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
     speed: Annotated['BigIntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
@@ -629,8 +635,18 @@ class InterfaceTemplateFilter(ModularComponentTemplateFilterMixin, ChangeLoggedM
     type: BaseFilterLookup[Annotated['InterfaceTypeEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
+    channels: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
+    channel_id: Annotated['IntegerLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
+        strawberry_django.filter_field()
+    )
     enabled: FilterLookup[bool] | None = strawberry_django.filter_field()
     mgmt_only: FilterLookup[bool] | None = strawberry_django.filter_field()
+    parent: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field()
+    )
+    parent_id: ID | None = strawberry_django.filter_field()
     bridge: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )

+ 2 - 0
netbox/dcim/graphql/types.py

@@ -482,9 +482,11 @@ class InterfaceType(IPAddressesMixin, ModularComponentType, CabledObjectMixin, P
 )
 class InterfaceTemplateType(ModularComponentTemplateType):
     _name: str
+    parent: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
     bridge: Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')] | None
 
     bridge_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
+    child_interfaces: list[Annotated["InterfaceTemplateType", strawberry.lazy('dcim.graphql.types')]]
 
 
 @strawberry_django.type(

+ 54 - 0
netbox/dcim/migrations/0246_interfacetemplate_channels.py

@@ -0,0 +1,54 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0245_modulebaytype'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='parent',
+            field=models.ForeignKey(
+                blank=True,
+                null=True,
+                on_delete=django.db.models.deletion.RESTRICT,
+                related_name='child_interfaces',
+                to='dcim.interfacetemplate',
+            ),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='channel_id',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='channels',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='interfacetemplate',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='dcim_interfacetemplate_unique_parent_channel_id',
+            ),
+        ),
+    ]

+ 43 - 0
netbox/dcim/migrations/0247_interface_channels.py

@@ -0,0 +1,43 @@
+import django.core.validators
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('dcim', '0246_interfacetemplate_channels'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='interface',
+            name='channel_id',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddField(
+            model_name='interface',
+            name='channels',
+            field=models.PositiveSmallIntegerField(
+                blank=True,
+                null=True,
+                validators=[
+                    django.core.validators.MinValueValidator(1),
+                    django.core.validators.MaxValueValidator(1024),
+                ],
+            ),
+        ),
+        migrations.AddConstraint(
+            model_name='interface',
+            constraint=models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='dcim_interface_unique_parent_channel_id',
+            ),
+        ),
+    ]

+ 14 - 0
netbox/dcim/models/cables.py

@@ -636,6 +636,14 @@ class CableTermination(ChangeLoggedModel):
                     cable_pk=existing_termination.cable.pk
                 )
             )
+        # A channel subinterface derives its cable from its parent interface and cannot be cabled directly. Checked
+        # ahead of the generic type validation below (channel is a nonconnectable type) to surface the more specific
+        # guidance.
+        if self.termination_type.model == 'interface' and self.termination.channel_id:
+            raise ValidationError(
+                _("Cables cannot be terminated directly to a channel subinterface; cable the parent interface instead.")
+            )
+
         # Validate the interface type (if applicable)
         if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES:
             raise ValidationError(
@@ -971,6 +979,12 @@ class CablePath(models.Model):
                     peer_results = cable_profile.get_peer_terminations(term_position_pairs)
                     seen = set()
                     for peer, new_pos in peer_results:
+                        # If the far-end termination is a channelized interface, resolve to the specific channel
+                        # subinterface bound to the mapped connector position (the far end is channelized on the same
+                        # physical connector, so the peer lookup returns the parent rather than the channel). A
+                        # channelized parent is never itself a path endpoint, so an unoccupied position yields no peer.
+                        if new_pos is not None and getattr(peer, 'channels', None):
+                            peer = peer.child_interfaces.filter(channel_id=new_pos).first()
                         # Deduplicate peer terminations by model type & PK.
                         key = None if peer is None else (peer._meta.concrete_model, peer.pk)
                         if key not in seen:

+ 62 - 0
netbox/dcim/models/device_component_templates.py

@@ -454,6 +454,26 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         max_length=50,
         choices=InterfaceTypeChoices
     )
+    channels = models.PositiveSmallIntegerField(
+        verbose_name=_('channels'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The number of channels into which this interface is channelized')
+    )
+    channel_id = models.PositiveSmallIntegerField(
+        verbose_name=_('channel ID'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The channel on the parent interface to which this subinterface is bound')
+    )
     enabled = models.BooleanField(
         verbose_name=_('enabled'),
         default=True
@@ -462,6 +482,14 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         default=False,
         verbose_name=_('management only')
     )
+    parent = models.ForeignKey(
+        to='self',
+        on_delete=models.RESTRICT,
+        related_name='child_interfaces',
+        null=True,
+        blank=True,
+        verbose_name=_('parent interface')
+    )
     bridge = models.ForeignKey(
         to='self',
         on_delete=models.SET_NULL,
@@ -495,12 +523,41 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
     component_model = Interface
 
     class Meta(ModularComponentTemplateModel.Meta):
+        constraints = (
+            *ModularComponentTemplateModel.Meta.constraints,
+            models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='%(app_label)s_%(class)s_unique_parent_channel_id'
+            ),
+        )
         verbose_name = _('interface template')
         verbose_name_plural = _('interface templates')
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache the original channel count for use by InterfaceValidationMixin.clean() to detect a channel-count
+        # reduction that would orphan a bound subinterface.
+        self._original_channels = self.__dict__.get('channels')
+
     def clean(self):
         super().clean()
 
+        # Self-reference and interface-type restrictions are enforced by InterfaceValidationMixin
+        if self.parent:
+            if self.device_type and self.device_type != self.parent.device_type:
+                raise ValidationError({
+                    'parent': _(
+                        "Parent interface ({parent}) must belong to the same device type"
+                    ).format(parent=self.parent)
+                })
+            if self.module_type and self.module_type != self.parent.module_type:
+                raise ValidationError({
+                    'parent': _(
+                        "Parent interface ({parent}) must belong to the same module type"
+                    ).format(parent=self.parent)
+                })
+
         if self.bridge:
             if self.device_type and self.device_type != self.bridge.device_type:
                 raise ValidationError({
@@ -520,6 +577,8 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
             name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
             label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             type=self.type,
+            channels=self.channels,
+            channel_id=self.channel_id,
             enabled=self.enabled,
             mgmt_only=self.mgmt_only,
             poe_mode=self.poe_mode,
@@ -533,10 +592,13 @@ class InterfaceTemplate(InterfaceValidationMixin, ModularComponentTemplateModel)
         return {
             'name': self.name,
             'type': self.type,
+            'channels': self.channels,
+            'channel_id': self.channel_id,
             'enabled': self.enabled,
             'mgmt_only': self.mgmt_only,
             'label': self.label,
             'description': self.description,
+            'parent': self.parent.name if self.parent else None,
             'bridge': self.bridge.name if self.bridge else None,
             'poe_mode': self.poe_mode,
             'poe_type': self.poe_type,

+ 101 - 12
netbox/dcim/models/device_components.py

@@ -860,6 +860,26 @@ class Interface(
         max_length=50,
         choices=InterfaceTypeChoices
     )
+    channels = models.PositiveSmallIntegerField(
+        verbose_name=_('channels'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The number of channels into which this interface is channelized')
+    )
+    channel_id = models.PositiveSmallIntegerField(
+        verbose_name=_('channel ID'),
+        blank=True,
+        null=True,
+        validators=(
+            MinValueValidator(INTERFACE_CHANNELS_MIN),
+            MaxValueValidator(INTERFACE_CHANNELS_MAX)
+        ),
+        help_text=_('The channel on the parent interface to which this subinterface is bound')
+    )
     mgmt_only = models.BooleanField(
         default=False,
         verbose_name=_('management only'),
@@ -989,14 +1009,33 @@ class Interface(
     )
 
     clone_fields = (
-        'device', 'module', 'parent', 'bridge', 'lag', 'type', 'mgmt_only', 'mtu', 'mode', 'speed', 'duplex', 'rf_role',
-        'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode', 'poe_type', 'vrf',
+        'device', 'module', 'parent', 'bridge', 'lag', 'type', 'channels', 'mgmt_only', 'mtu', 'mode', 'speed',
+        'duplex', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power', 'poe_mode',
+        'poe_type', 'vrf',
     )
 
     class Meta(ModularComponentModel.Meta):
         ordering = ('device', CollateAsChar('_name'))
         verbose_name = _('interface')
         verbose_name_plural = _('interfaces')
+        constraints = (
+            *ModularComponentModel.Meta.constraints,
+            models.UniqueConstraint(
+                fields=('parent', 'channel_id'),
+                name='%(app_label)s_%(class)s_unique_parent_channel_id'
+            ),
+        )
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache channelization-related fields so post-save signal handlers can detect changes which require rebuilding
+        # cable paths (channelization does not involve modifying the Cable itself, so the cable signals do not fire).
+        # _original_channels is additionally used by InterfaceValidationMixin.clean() to detect a channel-count
+        # reduction that would orphan a bound subinterface.
+        self._original_channels = self.__dict__.get('channels')
+        self._original_channel_id = self.__dict__.get('channel_id')
+        self._original_parent_id = self.__dict__.get('parent_id')
 
     def clean(self):
         super().clean()
@@ -1017,15 +1056,7 @@ class Interface(
                 )
             })
 
-        # Parent validation
-
-        # An interface cannot be its own parent
-        if self.pk and self.parent_id == self.pk:
-            raise ValidationError({'parent': _("An interface cannot be its own parent.")})
-
-        # A physical interface cannot have a parent interface
-        if self.type != InterfaceTypeChoices.TYPE_VIRTUAL and self.parent is not None:
-            raise ValidationError({'parent': _("Only virtual interfaces may be assigned to a parent interface.")})
+        # Parent validation (self-reference and interface-type restrictions are enforced by InterfaceValidationMixin)
 
         # An interface's parent must belong to the same device or virtual chassis
         if self.parent and self.parent.device != self.device:
@@ -1147,7 +1178,9 @@ class Interface(
 
     @property
     def is_wired(self):
-        return not self.is_virtual and not self.is_wireless
+        # Excludes virtual, wireless, and channel-type interfaces (channel subinterfaces derive their cable from the
+        # channelized parent and cannot be cabled directly).
+        return self.type not in NONCONNECTABLE_IFACE_TYPES
 
     @property
     def is_virtual(self):
@@ -1165,6 +1198,10 @@ class Interface(
     def is_bridge(self):
         return self.type == InterfaceTypeChoices.TYPE_BRIDGE
 
+    @property
+    def is_channel(self):
+        return self.type == InterfaceTypeChoices.TYPE_CHANNEL
+
     @property
     def link(self):
         return self.cable or self.wireless_link
@@ -1192,6 +1229,58 @@ class Interface(
             return self.virtual_circuit_termination.peer_terminations
         return super().connected_endpoints
 
+    def set_cable_termination(self, termination):
+        super().set_cable_termination(termination)
+
+        # A channelized interface carries no path of its own; instead, its cable is mirrored onto each channel
+        # subinterface (occupying a single position of the shared connector) so that each channel traces independently.
+        if self.channels:
+            self.propagate_channel_cables()
+
+    def clear_cable_termination(self, termination):
+        super().clear_cable_termination(termination)
+
+        if self.channels:
+            self.clear_channel_cables()
+
+    def propagate_channel_cables(self):
+        """
+        Mirror this channelized interface's cable attributes onto each of its channel subinterfaces, restricting each
+        child to the single connector position identified by its channel_id. Only profiled cables map connector
+        positions to channels; a positionless (unprofiled) cable carries no per-channel path, so nothing is mirrored.
+        """
+        # Only a profiled cable defines the connector positions that channels map onto; without one, clear any
+        # previously-mirrored attributes rather than propagate an unusable cable reference.
+        if not (self.cable and self.cable.profile):
+            self.clear_channel_cables()
+            return
+
+        # Mirror via bulk_update() to issue a single UPDATE and, crucially, to bypass the post_save signal — a
+        # per-child save() would re-trigger update_channelized_cable_paths() and recurse indefinitely.
+        children = list(self.child_interfaces.filter(channel_id__isnull=False))
+        for child in children:
+            child.cable = self.cable
+            child.cable_end = self.cable_end
+            child.cable_connector = self.cable_connector
+            child.cable_positions = [child.channel_id]
+        type(self).objects.bulk_update(
+            children, ['cable', 'cable_end', 'cable_connector', 'cable_positions']
+        )
+
+    def clear_channel_cables(self):
+        """
+        Clear the mirrored cable attributes from this channelized interface's channel subinterfaces.
+        """
+        # A queryset update() clears every child in a single query and bypasses the post_save signal (see above).
+        # cable_end is cleared to '' to match the convention used elsewhere when nullifying a termination (see
+        # nullify_connected_endpoints() and update_channelized_cable_paths() in dcim.signals).
+        self.child_interfaces.filter(channel_id__isnull=False).update(
+            cable=None,
+            cable_end='',
+            cable_connector=None,
+            cable_positions=None,
+        )
+
 
 #
 # Pass-through ports

+ 4 - 2
netbox/dcim/models/devices.py

@@ -19,7 +19,7 @@ from django.utils.translation import gettext_lazy as _
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
-from dcim.utils import create_port_mappings, update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import ConfigContextModel, CustomField
 from extras.querysets import ConfigContextModelQuerySet
 from netbox.choices import ColorChoices
@@ -1070,7 +1070,9 @@ class Device(
             self._instantiate_components(self.device_type.devicebaytemplates.all())
             # Disable bulk_create to accommodate MPTT
             self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
-            # Interface bridges have to be set after interface instantiation
+            # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so
+            # that channel subinterfaces validate against a populated parent.
+            update_interface_parents(self, self.device_type.interfacetemplates.all())
             update_interface_bridges(self, self.device_type.interfacetemplates.all())
 
         # Update Site and Rack assignment for any child Devices

+ 77 - 1
netbox/dcim/models/mixins.py

@@ -4,7 +4,8 @@ from django.core.exceptions import ValidationError
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
-from dcim.constants import VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
+from dcim.choices import InterfaceTypeChoices
+from dcim.constants import NONCONNECTABLE_IFACE_TYPES, VIRTUAL_IFACE_TYPES, WIRELESS_IFACE_TYPES
 
 __all__ = (
     'CachedScopeMixin',
@@ -130,6 +131,81 @@ class InterfaceValidationMixin:
     def clean(self):
         super().clean()
 
+        # An interface cannot be its own parent
+        if self.pk and self.parent_id == self.pk:
+            raise ValidationError({'parent': _("An interface cannot be its own parent.")})
+
+        # Only virtual and channel interfaces may have a parent interface
+        if self.parent_id and self.type not in (InterfaceTypeChoices.TYPE_VIRTUAL, InterfaceTypeChoices.TYPE_CHANNEL):
+            raise ValidationError({
+                'parent': _("Only virtual and channel interfaces may be assigned to a parent interface.")
+            })
+
+        # Only one layer of channelization is permitted: an interface cannot be both channelized and a channel
+        if self.channels and self.channel_id:
+            raise ValidationError(
+                _("An interface cannot be both channelized and bound to a channel on a parent interface.")
+            )
+
+        # Only physical interfaces may be channelized
+        if self.channels and self.type in NONCONNECTABLE_IFACE_TYPES:
+            raise ValidationError({
+                'channels': _("{display_type} interfaces cannot be channelized.").format(
+                    display_type=self.get_type_display()
+                )
+            })
+
+        # The channel type and channel_id are mutually dependent. The channel_id requirement is relaxed for a
+        # replication base (bulk creation), where each channel_id is supplied per-instance during expansion.
+        is_channel = self.type == InterfaceTypeChoices.TYPE_CHANNEL
+        if is_channel and self.channel_id is None and not getattr(self, '_replicated_base', False):
+            raise ValidationError({
+                'channel_id': _("Channel interfaces must have a channel ID assigned.")
+            })
+        if self.channel_id is not None and not is_channel:
+            raise ValidationError({
+                'channel_id': _("A channel ID can be assigned only to a channel-type interface.")
+            })
+
+        # A channel subinterface must be bound to a channelized parent interface
+        if is_channel:
+            if self.parent is None:
+                raise ValidationError({
+                    'parent': _("Channel interfaces must be assigned to a parent interface.")
+                })
+            if not self.parent.channels:
+                raise ValidationError({
+                    'parent': _("The parent interface ({interface}) is not channelized.").format(
+                        interface=self.parent
+                    )
+                })
+            if self.channel_id and self.channel_id > self.parent.channels:
+                raise ValidationError({
+                    'channel_id': _(
+                        "Invalid channel ID ({channel_id}): the parent interface provides only {channels} channels."
+                    ).format(channel_id=self.channel_id, channels=self.parent.channels)
+                })
+
+        # Reducing or clearing the channel count cannot orphan an existing channel subinterface bound to a higher
+        # channel (clearing channelization entirely would orphan every bound subinterface). Gated on the current or
+        # original channel count so the child lookup stays off the hot path for ordinary (never-channelized) interfaces.
+        if self.pk and (self.channels or self._original_channels):
+            max_child_channel_id = self.child_interfaces.filter(
+                channel_id__gt=self.channels or 0
+            ).aggregate(models.Max('channel_id'))['channel_id__max']
+            if max_child_channel_id is not None:
+                if self.channels:
+                    message = _(
+                        "Cannot set channels to {channels}: a channel subinterface is bound to channel "
+                        "{channel_id}. Delete or reassign the affected subinterface(s) first."
+                    ).format(channels=self.channels, channel_id=max_child_channel_id)
+                else:
+                    message = _(
+                        "Cannot remove channelization: a channel subinterface is bound to channel {channel_id}. "
+                        "Delete or reassign the affected subinterface(s) first."
+                    ).format(channel_id=max_child_channel_id)
+                raise ValidationError({'channels': message})
+
         # An interface cannot be bridged to itself
         if self.pk and self.bridge_id == self.pk:
             raise ValidationError({'bridge': _("An interface cannot be bridged to itself.")})

+ 4 - 2
netbox/dcim/models/modules.py

@@ -9,7 +9,7 @@ from django.utils.translation import gettext_lazy as _
 from jsonschema.exceptions import ValidationError as JSONValidationError
 
 from dcim.choices import *
-from dcim.utils import create_port_mappings, update_interface_bridges
+from dcim.utils import create_port_mappings, update_interface_bridges, update_interface_parents
 from extras.models import CustomField
 from netbox.models import PrimaryModel
 from netbox.models.features import ImageAttachmentsMixin
@@ -591,7 +591,9 @@ class Module(TrackingModelMixin, PrimaryModel):
         # Replicate any front/rear port mappings from the ModuleType
         create_port_mappings(self.device, self.module_type, self)
 
-        # Interface bridges have to be set after interface instantiation
+        # Interface parents & bridges have to be set after interface instantiation. Parents are applied first so that
+        # channel subinterfaces validate against a populated parent.
+        update_interface_parents(self.device, self.module_type.interfacetemplates, self)
         update_interface_bridges(self.device, self.module_type.interfacetemplates, self)
 
     def _save_existing(self, *args, **kwargs):

+ 69 - 1
netbox/dcim/signals.py

@@ -23,7 +23,7 @@ from .models import (
 )
 from .models.cables import trace_paths
 from .search import DeviceIndex
-from .utils import create_cablepaths, rebuild_paths
+from .utils import create_cablepaths, rebuild_cable_paths, rebuild_paths
 
 #
 # Location/rack/device assignment
@@ -156,6 +156,14 @@ def nullify_connected_endpoints(instance, **kwargs):
     model = instance.termination_type.model_class()
     model.objects.filter(pk=instance.termination_id).update(cable=None, cable_end='')
 
+    # If the removed termination was a channelized interface, also clear the cable attributes mirrored onto its channel
+    # subinterfaces. This must happen before the retrace below so that each channel's (now dead) path is torn down
+    # rather than rebuilt from a stale cable reference.
+    if model is Interface:
+        Interface.objects.filter(parent_id=instance.termination_id, channel_id__isnull=False).update(
+            cable=None, cable_end='', cable_connector=None, cable_positions=None
+        )
+
     # If the parent Cable is being deleted in this same operation, skip the
     # per-termination retrace; retrace_cable_paths() will retrace each affected
     # path once after the Cable is deleted.
@@ -171,6 +179,66 @@ def nullify_connected_endpoints(instance, **kwargs):
         cablepath.retrace()
 
 
+@receiver(post_save, sender=Interface)
+def update_channelized_cable_paths(instance, created, raw=False, **kwargs):
+    """
+    Rebuild cable paths when an interface's channelization changes without the Cable itself being modified: a channel
+    subinterface is added, moved between parents, or has its channel_id changed, or channelization is toggled on an
+    already-cabled interface. (The cable-tracing signals only fire when a Cable is saved.)
+    """
+    if raw:
+        return
+
+    parent_ids = set()
+
+    # A channel subinterface was added, moved between parents, or had its channel_id changed
+    if instance.channel_id or instance._original_channel_id:
+        parent_ids.update(pk for pk in (instance.parent_id, instance._original_parent_id) if pk)
+
+    # Channelization was toggled on this interface while it carries a cable
+    if instance.channels != instance._original_channels and instance.cable_id:
+        parent_ids.add(instance.pk)
+
+    # select_related('cable') avoids a per-parent round-trip to fetch the Cable, which both
+    # propagate_channel_cables() and rebuild_cable_paths() dereference. (Cable.profile is a plain field, not a
+    # relation, so it needs no prefetching.)
+    parents = Interface.objects.filter(pk__in=parent_ids, cable__isnull=False).select_related('cable')
+    for parent in parents:
+        if parent.channels:
+            parent.propagate_channel_cables()
+        rebuild_cable_paths(parent.cable)
+
+    # A channel subinterface whose parent no longer provides a cable must not retain stale mirrored cable attributes
+    if instance.channel_id and instance.cable_id:
+        parent = instance.parent
+        if not (parent and parent.channels and parent.cable_id):
+            Interface.objects.filter(pk=instance.pk).update(
+                cable=None, cable_end='', cable_connector=None, cable_positions=None
+            )
+            for cablepath in CablePath.objects.filter(_nodes__contains=instance):
+                if instance in cablepath.origins:
+                    cablepath.delete()
+
+    # Refresh the cached channelization state so that saving this same in-memory instance again compares against its
+    # current values rather than re-triggering propagation from a stale baseline.
+    instance._original_channels = instance.channels
+    instance._original_channel_id = instance.channel_id
+    instance._original_parent_id = instance.parent_id
+
+
+@receiver(post_delete, sender=Interface)
+def cleanup_channel_subinterface_paths(instance, **kwargs):
+    """
+    When a channel subinterface is deleted, rebuild its channelized parent's cable paths so the removed channel's path
+    is torn down.
+    """
+    if instance.channel_id and instance.parent_id:
+        parent = Interface.objects.filter(pk=instance.parent_id, cable__isnull=False).first()
+        if parent and parent.channels:
+            parent.propagate_channel_cables()
+            rebuild_cable_paths(parent.cable)
+
+
 @receiver(post_save, sender=Interface)
 @receiver(post_save, sender=VMInterface)
 def update_mac_address_interface(instance, created, raw, **kwargs):

+ 22 - 5
netbox/dcim/svg/cables.py

@@ -19,6 +19,22 @@ FANOUT_LEG_HEIGHT = 15
 CABLE_HEIGHT = 5 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
 
 
+def _cable_side_nodes(term_nodes, cable_terminations):
+    """
+    Filter a list of termination nodes to those connected to the given side of a Cable. A channel subinterface does
+    not terminate the cable directly; it derives its connection from its (channelized) parent interface, which carries
+    the actual CableTermination, so it is matched via its parent.
+    """
+    def matches(obj):
+        if obj in cable_terminations:
+            return True
+        if getattr(obj, 'channel_id', None):
+            return obj.parent in cable_terminations
+        return False
+
+    return [node for node in term_nodes if matches(node.object)]
+
+
 class Node(Hyperlink):
     """
     Create a node to be represented in the SVG document as a rectangular box with a hyperlink.
@@ -380,13 +396,14 @@ class CableTraceSVG:
                             description.append(f"{cable.length} {cable.get_length_unit_display()}")
                         color = cable.color or '000000'
 
-                        # Collect all connected nodes to this cable
-                        near = [term for term in near_terminations if term.object in cable.a_terminations]
-                        far = [term for term in far_terminations if term.object in cable.b_terminations]
+                        # Collect all connected nodes to this cable. Channel subinterfaces are matched via their
+                        # parent interface, which carries the actual cable termination.
+                        near = _cable_side_nodes(near_terminations, cable.a_terminations)
+                        far = _cable_side_nodes(far_terminations, cable.b_terminations)
                         if not (near and far):
                             # a and b terminations may be swapped
-                            near = [term for term in near_terminations if term.object in cable.b_terminations]
-                            far = [term for term in far_terminations if term.object in cable.a_terminations]
+                            near = _cable_side_nodes(near_terminations, cable.b_terminations)
+                            far = _cable_side_nodes(far_terminations, cable.a_terminations)
                     elif isinstance(cable, WirelessLink):
                         labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
                         if cable.ssid:

+ 6 - 6
netbox/dcim/tables/devices.py

@@ -710,12 +710,12 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
     class Meta(DeviceComponentTable.Meta):
         model = models.Interface
         fields = (
-            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'mgmt_only', 'mtu',
-            'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses', 'primary_mac_address', 'wwn',
-            'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'tx_power',
-            'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer',
-            'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses', 'fhrp_groups',
-            'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
+            'pk', 'id', 'name', 'device', 'module_bay', 'module', 'label', 'enabled', 'type', 'channels',
+            'channel_id', 'mgmt_only', 'mtu', 'speed', 'speed_formatted', 'duplex', 'mode', 'mac_addresses',
+            'primary_mac_address', 'wwn', 'poe_mode', 'poe_type', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+            'rf_channel_width', 'tx_power', 'description', 'mark_connected', 'cable', 'cable_color', 'wireless_link',
+            'wireless_lans', 'link_peer', 'connection', 'tags', 'vdcs', 'vrf', 'l2vpn', 'tunnel', 'ip_addresses',
+            'fhrp_groups', 'untagged_vlan', 'tagged_vlans', 'qinq_svlan', 'inventory_items', 'created', 'last_updated',
             'vlan_translation_policy',
         )
         default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')

+ 2 - 2
netbox/dcim/tables/devicetypes.py

@@ -244,8 +244,8 @@ class InterfaceTemplateTable(ComponentTemplateTable):
     class Meta(ComponentTemplateTable.Meta):
         model = models.InterfaceTemplate
         fields = (
-            'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'description', 'bridge', 'poe_mode', 'poe_type',
-            'rf_role', 'actions',
+            'pk', 'name', 'label', 'enabled', 'mgmt_only', 'type', 'channels', 'channel_id', 'description', 'parent',
+            'bridge', 'poe_mode', 'poe_type', 'rf_role', 'actions',
         )
         empty_text = "None"
 

+ 36 - 2
netbox/dcim/tests/test_api.py

@@ -1372,9 +1372,11 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
         interface_templates = (
             InterfaceTemplate(device_type=devicetype, name='Interface Template 1', type='1000base-t'),
             InterfaceTemplate(device_type=devicetype, name='Interface Template 2', type='1000base-t'),
-            InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t'),
+            # Interface Template 3 is channelized, so that channel subinterface templates may be bound to it
+            InterfaceTemplate(device_type=devicetype, name='Interface Template 3', type='1000base-t', channels=4),
         )
         InterfaceTemplate.objects.bulk_create(interface_templates)
+        channelized_parent = interface_templates[2]
 
         cls.create_data = [
             {
@@ -1397,6 +1399,21 @@ class InterfaceTemplateTestCase(APIViewTestCases.APIViewTestCase):
                 'name': 'Interface Template 7',
                 'type': '1000base-t',
             },
+            {
+                # A channelized parent template
+                'device_type': devicetype.pk,
+                'name': 'Interface Template 8',
+                'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
+                'channels': 4,
+            },
+            {
+                # A channel subinterface template bound to a channelized parent
+                'device_type': devicetype.pk,
+                'name': 'Interface Template 9',
+                'type': InterfaceTypeChoices.TYPE_CHANNEL,
+                'parent': channelized_parent.pk,
+                'channel_id': 1,
+            },
         ]
 
 
@@ -2923,9 +2940,11 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
         interfaces = (
             Interface(device=device, name='Interface 1', type='1000base-t'),
             Interface(device=device, name='Interface 2', type='1000base-t'),
-            Interface(device=device, name='Interface 3', type='1000base-t'),
+            # Interface 3 is channelized, so that channel subinterfaces may be bound to it
+            Interface(device=device, name='Interface 3', type='1000base-t', channels=4),
         )
         Interface.objects.bulk_create(interfaces)
+        channelized_parent = interfaces[2]
 
         vdcs = (
             VirtualDeviceContext(name='VDC 1', identifier=1, device=device),
@@ -3013,6 +3032,21 @@ class InterfaceTestCase(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTest
                 'rf_channel': "",
                 'qinq_svlan': vlans[3].pk,
             },
+            {
+                # A channelized parent interface
+                'device': device.pk,
+                'name': 'Interface 9',
+                'type': InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS,
+                'channels': 4,
+            },
+            {
+                # A channel subinterface bound to a channelized parent
+                'device': device.pk,
+                'name': 'Interface 10',
+                'type': InterfaceTypeChoices.TYPE_CHANNEL,
+                'parent': channelized_parent.pk,
+                'channel_id': 1,
+            },
         ]
 
     def _perform_interface_test_with_invalid_data(self, mode: str = None, invalid_data: dict = {}):

+ 630 - 0
netbox/dcim/tests/test_channelization.py

@@ -0,0 +1,630 @@
+from django.core.exceptions import ValidationError
+from django.test import TestCase
+from django.urls import reverse
+
+from dcim.choices import CableProfileChoices, InterfaceTypeChoices
+from dcim.models import (
+    Cable,
+    CablePath,
+    Device,
+    DeviceRole,
+    DeviceType,
+    Interface,
+    InterfaceTemplate,
+    Manufacturer,
+    Site,
+)
+from dcim.svg import CableTraceSVG
+from dcim.svg.cables import Connector
+from dcim.tests.utils import BaseCablePathTestCase
+from utilities.testing import TestCase as ViewTestCase
+
+
+class ChannelizedCablePathTestCase(BaseCablePathTestCase):
+    """
+    Test cable path tracing for channelized interfaces. A single physical cable terminates to a channelized (parent)
+    interface, and each of the parent's channel subinterfaces traces an independent path from the connector position
+    identified by its channel_id.
+    """
+
+    def _create_channelized_interface(self, name, channels, device=None):
+        """Create a channelized parent interface and its channel subinterfaces."""
+        device = device or self.device
+        parent = Interface.objects.create(
+            device=device, name=name, type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=channels
+        )
+        children = [
+            Interface.objects.create(
+                device=device,
+                name=f'{name}:{i}',
+                type=InterfaceTypeChoices.TYPE_CHANNEL,
+                parent=parent,
+                channel_id=i,
+            )
+            for i in range(1, channels + 1)
+        ]
+        return parent, children
+
+    def test_101_channelized_breakout_to_discrete_interfaces(self):
+        """
+        A 4-channel parent broken out to four discrete far-end interfaces via a 1C4P:4C1P breakout cable. Each channel
+        subinterface traces to its corresponding far-end interface (and vice versa); the parent itself has no path.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # One forward and one reverse path per channel; the parent originates no path
+        self.assertEqual(CablePath.objects.count(), 8)
+        parent.refresh_from_db()
+        self.assertPathIsNotSet(parent)
+
+        for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
+            channel.refresh_from_db()
+            far_iface.refresh_from_db()
+
+            # The parent's cable is mirrored onto the channel, restricted to its single connector position
+            self.assertEqual(channel.cable_id, cable.pk)
+            self.assertEqual(channel.cable_connector, 1)
+            self.assertEqual(channel.cable_positions, [i])
+
+            forward = self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
+            reverse = self.assertPathExists((far_iface, cable, channel), is_complete=True, is_active=True)
+            self.assertPathIsSet(channel, forward)
+            self.assertPathIsSet(far_iface, reverse)
+
+        # The trace SVG must render from both a channel subinterface and a discrete far-end interface
+        CableTraceSVG(channels[0]).render()
+        CableTraceSVG(far[0]).render()
+
+    def test_102_channelized_to_channelized(self):
+        """
+        Two channelized interfaces connected by a single 1C4P cable (both ends channelized on one connector). Each
+        near-end channel traces to the far-end channel bound to the same position.
+        """
+        near_parent, near_channels = self._create_channelized_interface('et0', 4)
+        far_device = Device.objects.create(
+            site=self.site, device_type=self.device.device_type, role=self.device.role, name='Device 2'
+        )
+        far_parent, far_channels = self._create_channelized_interface('et0', 4, device=far_device)
+
+        cable = Cable(
+            profile=CableProfileChoices.SINGLE_1C4P,
+            a_terminations=[near_parent],
+            b_terminations=[far_parent],
+        )
+        cable.clean()
+        cable.save()
+
+        self.assertEqual(CablePath.objects.count(), 8)
+        for near, far in zip(near_channels, far_channels):
+            near.refresh_from_db()
+            far.refresh_from_db()
+            self.assertPathExists((near, cable, far), is_complete=True, is_active=True)
+            self.assertPathExists((far, cable, near), is_complete=True, is_active=True)
+
+        # The trace SVG for a channel subinterface must render, drawing the cable between the two channels. The cable
+        # terminates on the parent interfaces, so the connector is matched to the channels via their parents.
+        svg = CableTraceSVG(near_channels[0])
+        svg.render()
+        self.assertTrue(
+            any(isinstance(c, Connector) for c in svg.connectors),
+            msg="Trace SVG did not render a cable connector for the channelized path"
+        )
+
+    def test_103_add_channel_after_cabling(self):
+        """
+        On an already-cabled parent, deleting a channel subinterface tears down its path, and adding a channel
+        subinterface (re-adding one on the freed position) builds a fresh path for it in both directions.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Removing the fourth channel tears down its complete path in both directions
+        channels[3].delete()
+        self.assertPathDoesNotExist((channels[3], cable, far[3]))
+        self.assertPathDoesNotExist((far[3], cable, channels[3]))
+
+        # Re-adding a channel on position 4 restores the complete path in both directions
+        new_channel = Interface.objects.create(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=parent, channel_id=4
+        )
+        new_channel.refresh_from_db()
+        self.assertEqual(new_channel.cable_positions, [4])
+        self.assertPathExists((new_channel, cable, far[3]), is_complete=True, is_active=True)
+        self.assertPathExists((far[3], cable, new_channel), is_complete=True, is_active=True)
+
+    def test_104_change_channel_id(self):
+        """
+        Changing a channel's channel_id re-binds it to a different connector position, in both directions.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        # Delete channels 3 and 4 so their positions are free to reassign to
+        channels[2].delete()
+        channels[3].delete()
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Channel 1 initially traces to far[0]
+        self.assertPathExists((channels[0], cable, far[0]), is_complete=True, is_active=True)
+
+        # Move channel 1 to position 3
+        channels[0].channel_id = 3
+        channels[0].save()
+        channels[0].refresh_from_db()
+
+        self.assertEqual(channels[0].cable_positions, [3])
+        self.assertPathDoesNotExist((channels[0], cable, far[0]))
+        self.assertPathExists((channels[0], cable, far[2]), is_complete=True, is_active=True)
+        self.assertPathExists((far[2], cable, channels[0]), is_complete=True, is_active=True)
+
+    def test_105_incomplete_channel(self):
+        """
+        A channel whose position has no far-end termination yields an incomplete path (rather than an error).
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        # Only two far-end interfaces exist, on connectors 1 and 2
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(2)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # Channels 1 & 2 are complete; channels 3 & 4 have no far-end termination and trace an incomplete path
+        channels[0].refresh_from_db()
+        channels[2].refresh_from_db()
+        self.assertPathExists((channels[0], cable, far[0]), is_complete=True)
+        self.assertIsNotNone(channels[2]._path_id)
+        self.assertFalse(channels[2].path.is_complete)
+
+    def test_106_cable_removal_teardown(self):
+        """
+        Removing the cable from a channelized parent tears down every channel's path and clears the mirrored cable
+        attributes from the channels.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 8)
+
+        cable.delete()
+
+        self.assertEqual(CablePath.objects.count(), 0)
+        for channel in channels:
+            channel.refresh_from_db()
+            self.assertIsNone(channel.cable_id)
+            self.assertIsNone(channel.cable_connector)
+            self.assertIsNone(channel.cable_positions)
+            self.assertPathIsNotSet(channel)
+
+    def test_107_direct_cabling_of_channel_rejected(self):
+        """
+        A cable cannot be terminated directly to a channel subinterface.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        cable = Cable(a_terminations=[channels[0]], b_terminations=[far])
+        with self.assertRaises(ValidationError):
+            cable.clean()
+
+    def test_108_unprofiled_cable_not_propagated(self):
+        """
+        An unprofiled cable carries no per-channel positions, so its attributes are not mirrored onto the parent's
+        channel subinterfaces.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        cable = Cable(a_terminations=[parent], b_terminations=[far])
+        cable.clean()
+        cable.save()
+
+        # The parent itself is cabled, but no cable attributes are mirrored onto the channels
+        parent.refresh_from_db()
+        self.assertEqual(parent.cable_id, cable.pk)
+        for channel in channels:
+            channel.refresh_from_db()
+            self.assertIsNone(channel.cable_id)
+            self.assertIsNone(channel.cable_positions)
+
+    def test_109_change_channel_count_after_cabling(self):
+        """
+        Increasing the channel count on an already-cabled parent re-propagates the cable to its existing channel
+        subinterfaces and rebuilds their paths (the Cable itself is unchanged, so only the post_save signal fires).
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+        self.assertEqual(CablePath.objects.count(), 8)
+
+        # Increase the channel count; the existing channels' paths must survive
+        parent.refresh_from_db()
+        parent.channels = 8
+        parent.save()
+
+        self.assertEqual(CablePath.objects.count(), 8)
+        for i, (channel, far_iface) in enumerate(zip(channels, far), start=1):
+            channel.refresh_from_db()
+            self.assertEqual(channel.cable_positions, [i])
+            self.assertPathExists((channel, cable, far_iface), is_complete=True, is_active=True)
+
+    def test_110_move_channel_to_uncabled_parent(self):
+        """
+        Moving a channel subinterface from a cabled parent to a channelized-but-uncabled parent tears down the
+        channel's mirrored cable attributes and its (now orphaned) path.
+        """
+        parent, channels = self._create_channelized_interface('et0', 4)
+        far = [
+            Interface.objects.create(device=self.device, name=f'xe{i}', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS)
+            for i in range(4)
+        ]
+        cable = Cable(
+            profile=CableProfileChoices.BREAKOUT_1C4P_4C1P,
+            a_terminations=[parent],
+            b_terminations=far,
+        )
+        cable.clean()
+        cable.save()
+
+        # A second channelized parent with no cable
+        uncabled_parent = Interface.objects.create(
+            device=self.device, name='et1', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+
+        # Move the first channel to the uncabled parent; its mirrored cable & path must be torn down
+        channel = channels[0]
+        channel.refresh_from_db()
+        self.assertEqual(channel.cable_id, cable.pk)
+        channel.parent = uncabled_parent
+        channel.save()
+
+        channel.refresh_from_db()
+        self.assertIsNone(channel.cable_id)
+        self.assertIsNone(channel.cable_connector)
+        self.assertIsNone(channel.cable_positions)
+        self.assertPathIsNotSet(channel)
+        self.assertPathDoesNotExist((channel, cable, far[0]))
+        self.assertPathDoesNotExist((far[0], cable, channel))
+
+
+class ChannelizedInterfaceValidationTestCase(TestCase):
+    """
+    Test validation of the channels and channel_id fields on Interface.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device')
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        site = Site.objects.create(name='Site', slug='site')
+        cls.device = Device.objects.create(site=site, device_type=device_type, role=role, name='Device 1')
+        cls.parent = Interface.objects.create(
+            device=cls.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+
+    def test_valid_channel_subinterface(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        interface.full_clean()  # Should not raise
+
+    def test_channel_type_requires_channel_id(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_id_requires_channel_type(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_requires_parent(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_requires_channelized_parent(self):
+        plain_parent = Interface.objects.create(
+            device=self.device, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        interface = Interface(
+            device=self.device, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=plain_parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channel_id_within_parent_range(self):
+        interface = Interface(
+            device=self.device, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=5
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channels_and_channel_id_mutually_exclusive(self):
+        interface = Interface(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=self.parent, channel_id=1, channels=4
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_channels_not_allowed_on_virtual_type(self):
+        interface = Interface(
+            device=self.device, name='vlan10', type=InterfaceTypeChoices.TYPE_VIRTUAL, channels=4
+        )
+        with self.assertRaises(ValidationError):
+            interface.full_clean()
+
+    def test_reduce_channels_below_bound_child_rejected(self):
+        # Bind a channel to the highest channel of the parent, then attempt to reduce the parent's channel count
+        Interface.objects.create(
+            device=self.device, name='et0:4', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=4
+        )
+        self.parent.channels = 2
+        with self.assertRaises(ValidationError):
+            self.parent.full_clean()
+
+    def test_clear_channels_with_bound_child_rejected(self):
+        # De-channelizing a parent entirely must be rejected while any channel subinterface is still bound to it
+        Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        self.parent.channels = None
+        with self.assertRaises(ValidationError):
+            self.parent.full_clean()
+
+    def test_clear_channels_without_bound_child_allowed(self):
+        # De-channelizing is permitted once no channel subinterfaces remain bound to the parent
+        self.parent.channels = None
+        self.parent.full_clean()  # Should not raise
+
+    def test_parent_channel_id_must_be_unique(self):
+        Interface.objects.create(
+            device=self.device, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        duplicate = Interface(
+            device=self.device, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL, parent=self.parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            duplicate.full_clean()
+
+
+class ChannelizedInterfaceTemplateTestCase(TestCase):
+    """
+    Test that the channels, channel_id, and parent fields are replicated from InterfaceTemplate to the Interfaces
+    instantiated for a new Device, and that parent interfaces are populated before their channel subinterfaces.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        cls.device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Test Device', slug='test-device')
+        cls.role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        cls.site = Site.objects.create(name='Site', slug='site')
+
+        # A channelized parent template broken out into four channel subinterface templates bound to it
+        parent_template = InterfaceTemplate.objects.create(
+            device_type=cls.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        for i in range(1, 5):
+            InterfaceTemplate.objects.create(
+                device_type=cls.device_type,
+                name=f'et0:{i}',
+                type=InterfaceTypeChoices.TYPE_CHANNEL,
+                parent=parent_template,
+                channel_id=i,
+            )
+
+    def test_channelization_replicated_on_instantiation(self):
+        device = Device.objects.create(
+            site=self.site, device_type=self.device_type, role=self.role, name='Device 1'
+        )
+
+        # The channelized parent carries its channel count
+        parent = device.interfaces.get(name='et0')
+        self.assertEqual(parent.channels, 4)
+        self.assertIsNone(parent.channel_id)
+
+        # Each channel subinterface carries its channel ID and is bound to the instantiated parent interface
+        for i in range(1, 5):
+            channel = device.interfaces.get(name=f'et0:{i}')
+            self.assertEqual(channel.channel_id, i)
+            self.assertIsNone(channel.channels)
+            self.assertEqual(channel.parent, parent)
+
+    def test_parent_template_validation(self):
+        # A parent template must belong to the same device type
+        other_type = DeviceType.objects.create(
+            manufacturer=self.device_type.manufacturer, model='Other Device', slug='other-device'
+        )
+        foreign_parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        template = InterfaceTemplate(
+            device_type=other_type, name='et0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=foreign_parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_parent_channel_id_must_be_unique(self):
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        # Channel 1 already exists on the parent (created in setUpTestData)
+        duplicate = InterfaceTemplate(
+            device_type=self.device_type, name='et0:1b', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            duplicate.full_clean()
+
+    def test_template_channel_id_within_parent_range(self):
+        # A channel_id beyond the parent's channel count is rejected at the template level
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='et0:5', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=parent, channel_id=5
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_channel_requires_channelized_parent(self):
+        # A channel template bound to a non-channelized parent template is rejected
+        plain_parent = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='xe0', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS
+        )
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='xe0:1', type=InterfaceTypeChoices.TYPE_CHANNEL,
+            parent=plain_parent, channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_channel_id_requires_channel_type(self):
+        # A channel_id on a non-channel-type template is rejected
+        template = InterfaceTemplate(
+            device_type=self.device_type, name='xe1', type=InterfaceTypeChoices.TYPE_10GE_SFP_PLUS,
+            channel_id=1
+        )
+        with self.assertRaises(ValidationError):
+            template.full_clean()
+
+    def test_template_reduce_channels_below_bound_child_rejected(self):
+        # Reducing a parent template's channel count below a bound child template's channel_id is rejected (channels
+        # 3 & 4 are bound in setUpTestData)
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        parent.channels = 2
+        with self.assertRaises(ValidationError):
+            parent.full_clean()
+
+    def test_template_clear_channels_with_bound_child_rejected(self):
+        # De-channelizing a parent template entirely is rejected while a channel subinterface template is bound to it
+        parent = InterfaceTemplate.objects.get(device_type=self.device_type, name='et0')
+        parent.channels = None
+        with self.assertRaises(ValidationError):
+            parent.full_clean()
+
+
+class ChannelizedBulkCreateTestCase(ViewTestCase):
+    """
+    Test channel_id pattern expansion when bulk-creating channel subinterfaces (and interface templates) so that each
+    generated object receives a distinct channel_id.
+    """
+
+    def setUp(self):
+        super().setUp()
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        self.device_type = DeviceType.objects.create(
+            manufacturer=manufacturer, model='Test Device', slug='test-device'
+        )
+        role = DeviceRole.objects.create(name='Device Role', slug='device-role')
+        site = Site.objects.create(name='Site', slug='site')
+        self.device = Device.objects.create(
+            site=site, device_type=self.device_type, role=role, name='Device 1'
+        )
+
+    def test_bulk_create_channel_subinterfaces(self):
+        parent = Interface.objects.create(
+            device=self.device, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        self.add_permissions('dcim.add_interface', 'dcim.view_interface')
+
+        request_data = {
+            'device': self.device.pk,
+            'name': 'et0:[1-4]',
+            'type': InterfaceTypeChoices.TYPE_CHANNEL,
+            'parent': parent.pk,
+            'channel_id': '[1-4]',
+        }
+        response = self.client.post(reverse('dcim:interface_add'), request_data)
+        self.assertHttpStatus(response, 302)
+
+        # Four channel subinterfaces are created, each bound to a distinct channel on the parent
+        channels = Interface.objects.filter(parent=parent).order_by('channel_id')
+        self.assertEqual(channels.count(), 4)
+        for i, channel in enumerate(channels, start=1):
+            self.assertEqual(channel.name, f'et0:{i}')
+            self.assertEqual(channel.channel_id, i)
+
+    def test_bulk_create_channel_subinterface_templates(self):
+        parent = InterfaceTemplate.objects.create(
+            device_type=self.device_type, name='et0', type=InterfaceTypeChoices.TYPE_40GE_QSFP_PLUS, channels=4
+        )
+        self.add_permissions('dcim.add_interfacetemplate', 'dcim.view_interfacetemplate')
+
+        request_data = {
+            'device_type': self.device_type.pk,
+            'name': 'et0:[1-4]',
+            'type': InterfaceTypeChoices.TYPE_CHANNEL,
+            'parent': parent.pk,
+            'channel_id': '[1-4]',
+        }
+        response = self.client.post(reverse('dcim:interfacetemplate_add'), request_data)
+        self.assertHttpStatus(response, 302)
+
+        templates = InterfaceTemplate.objects.filter(parent=parent).order_by('channel_id')
+        self.assertEqual(templates.count(), 4)
+        for i, template in enumerate(templates, start=1):
+            self.assertEqual(template.name, f'et0:{i}')
+            self.assertEqual(template.channel_id, i)

+ 2 - 0
netbox/dcim/ui/panels.py

@@ -523,6 +523,7 @@ class InterfacePanel(panels.ObjectAttributesPanel):
     name = attrs.TextAttr('name')
     label = attrs.TextAttr('label')
     type = attrs.ChoiceAttr('type')
+    channels = attrs.NumericAttr('channels')
     speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
     duplex = attrs.ChoiceAttr('duplex')
     mtu = attrs.NumericAttr('mtu', label=_('MTU'))
@@ -543,6 +544,7 @@ class RelatedInterfacesPanel(panels.ObjectAttributesPanel):
     title = _('Related Interfaces')
 
     parent = attrs.RelatedObjectAttr('parent', linkify=True)
+    channel_id = attrs.NumericAttr('channel_id', label=_('Channel ID'))
     bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
     lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))
 

+ 69 - 4
netbox/dcim/utils.py

@@ -131,12 +131,29 @@ def create_cablepaths(objects):
 
     :param objects: Iterable of cabled objects (e.g. Interfaces)
     """
-    from dcim.models import CablePath
+    from dcim.models import CablePath, Interface
 
-    # Arrange objects by cable connector. All objects with a null connector are grouped together.
-    origins = defaultdict(list)
+    # Expand any channelized interface into its channel subinterfaces. A channelized parent originates no path of its
+    # own; instead, each channel subinterface traces independently from the single connector position it occupies.
+    # Plain (non-channelized) origins pass through unchanged, keeping this expansion re-entrant so that
+    # rebuild_paths() -> create_cablepaths(cp.origins) does not re-expand the channel subinterfaces it already holds.
+    expanded = []
     for obj in objects:
-        origins[obj.cable_connector].append(obj)
+        if isinstance(obj, Interface) and obj.channels:
+            expanded.extend(obj.child_interfaces.filter(channel_id__isnull=False, cable__isnull=False))
+        else:
+            expanded.append(obj)
+
+    # Arrange objects by cable connector. All objects with a null connector are grouped together. Channel
+    # subinterfaces must each originate their own path, as sharing a connector would otherwise collapse a group of
+    # siblings into a single malformed path.
+    origins = defaultdict(list)
+    for obj in expanded:
+        if isinstance(obj, Interface) and obj.channel_id:
+            if cp := CablePath.from_origin([obj]):
+                cp.save()
+        else:
+            origins[obj.cable_connector].append(obj)
 
     for connector, objects in origins.items():
         if cp := CablePath.from_origin(objects):
@@ -158,6 +175,54 @@ def rebuild_paths(terminations):
                 create_cablepaths(cp.origins)
 
 
+def rebuild_cable_paths(cable):
+    """
+    Delete and rebuild every CablePath traversing the given Cable, tracing freshly from the Cable's current
+    terminations in both directions. Used when the channelization of a terminated interface changes (e.g. a channel
+    subinterface is added, moved, or removed) without the Cable itself being modified.
+    """
+    from dcim.choices import CableEndChoices
+    from dcim.models import CablePath, CableTermination, PathEndpoint
+
+    with transaction.atomic(using=router.db_for_write(CablePath)):
+        # Delete existing paths individually so each clears its `_path` back-reference on the originating endpoints.
+        for cp in CablePath.objects.filter(_nodes__contains=cable):
+            cp.delete()
+
+        a_terminations, b_terminations = [], []
+        for ct in CableTermination.objects.filter(cable=cable):
+            if ct.cable_end == CableEndChoices.SIDE_A:
+                a_terminations.append(ct.termination)
+            else:
+                b_terminations.append(ct.termination)
+
+        for nodes in (a_terminations, b_terminations):
+            if not nodes:
+                continue
+            if isinstance(nodes[0], PathEndpoint):
+                create_cablepaths(nodes)
+            else:
+                rebuild_paths(nodes)
+
+
+def update_interface_parents(device, interface_templates, module=None):
+    """
+    Used for device and module instantiation. Iterates all InterfaceTemplates with a parent assigned and applies it to
+    the actual interfaces. Must run after all interfaces have been instantiated (so that every parent interface exists)
+    and before update_interface_bridges() (so that channel subinterfaces validate against a populated parent).
+    """
+    Interface = apps.get_model('dcim', 'Interface')
+
+    for interface_template in interface_templates.exclude(parent=None):
+        interface = Interface.objects.get(device=device, name=interface_template.resolve_name(module=module))
+        interface.parent = Interface.objects.get(
+            device=device,
+            name=interface_template.parent.resolve_name(module=module)
+        )
+        interface.full_clean()
+        interface.save()
+
+
 def update_interface_bridges(device, interface_templates, module=None):
     """
     Used for device and module instantiation. Iterates all InterfaceTemplates with a bridge assigned

+ 14 - 0
netbox/utilities/forms/fields/expandable.py

@@ -10,6 +10,7 @@ from utilities.forms.utils import expand_alphanumeric_pattern, expand_ipnetwork_
 __all__ = (
     'ExpandableIPNetworkField',
     'ExpandableNameField',
+    'ExpandableNumericField',
 )
 
 
@@ -35,6 +36,19 @@ class ExpandableNameField(forms.CharField):
         return [value]
 
 
+class ExpandableNumericField(ExpandableNameField):
+    """
+    An ExpandableNameField intended for numeric values, yielding integer-compatible strings suitable for bulk creation.
+      Example: '[1-3]' => ['1', '2', '3']
+    """
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        # Replace the inherited alphanumeric default with numeric-specific guidance (unless one was supplied)
+        if not kwargs.get('help_text'):
+            self.help_text = _("Numeric ranges are supported for bulk creation (example: <code>[1-24]</code>).")
+
+
 class ExpandableIPNetworkField(forms.CharField):
     """
     A CharField that expands numeric range patterns in IPv4/IPv6 CIDR notation into multiple entries.