Просмотр исходного кода

Closes #13086: Virtual circuits (#17933)

* WIP

* Add API tests

* Add remaining tests

* Add model docs

* Show virtual circuit connections on interfaces

* Misc cleanup per PR feedback

* Renumber migration

* Support nested terminations for virtual circuit bulk import
Jeremy Stretch 1 год назад
Родитель
Сommit
d2168b107f
36 измененных файлов с 2164 добавлено и 15 удалено
  1. 33 0
      docs/models/circuits/virtualcircuit.md
  2. 21 0
      docs/models/circuits/virtualcircuittermination.md
  3. 2 0
      mkdocs.yml
  4. 37 2
      netbox/circuits/api/serializers_/circuits.py
  5. 4 0
      netbox/circuits/api/urls.py
  6. 20 0
      netbox/circuits/api/views.py
  7. 16 0
      netbox/circuits/choices.py
  8. 108 1
      netbox/circuits/filtersets.py
  9. 64 1
      netbox/circuits/forms/bulk_edit.py
  10. 74 0
      netbox/circuits/forms/bulk_import.py
  11. 77 1
      netbox/circuits/forms/filtersets.py
  12. 73 3
      netbox/circuits/forms/model_forms.py
  13. 15 1
      netbox/circuits/graphql/filters.py
  14. 6 0
      netbox/circuits/graphql/schema.py
  15. 31 0
      netbox/circuits/graphql/types.py
  16. 67 0
      netbox/circuits/migrations/0050_virtual_circuits.py
  17. 1 0
      netbox/circuits/models/__init__.py
  18. 164 0
      netbox/circuits/models/virtual_circuits.py
  19. 20 0
      netbox/circuits/search.py
  20. 1 0
      netbox/circuits/tables/__init__.py
  21. 95 0
      netbox/circuits/tables/virtual_circuits.py
  22. 239 1
      netbox/circuits/tests/test_api.py
  23. 292 1
      netbox/circuits/tests/test_filtersets.py
  24. 319 2
      netbox/circuits/tests/test_views.py
  25. 16 0
      netbox/circuits/urls.py
  26. 106 0
      netbox/circuits/views.py
  27. 11 1
      netbox/dcim/filtersets.py
  28. 8 0
      netbox/dcim/models/device_components.py
  29. 8 0
      netbox/dcim/tables/devices.py
  30. 14 0
      netbox/dcim/tables/template_code.py
  31. 2 0
      netbox/extras/tests/test_filtersets.py
  32. 7 0
      netbox/netbox/navigation/menu.py
  33. 13 0
      netbox/templates/circuits/providernetwork.html
  34. 84 0
      netbox/templates/circuits/virtualcircuit.html
  35. 81 0
      netbox/templates/circuits/virtualcircuittermination.html
  36. 35 1
      netbox/templates/dcim/interface.html

+ 33 - 0
docs/models/circuits/virtualcircuit.md

@@ -0,0 +1,33 @@
+# Virtual Circuits
+
+A virtual circuit can connect two or more interfaces atop a set of decoupled physical connections. For example, it's very common to form a virtual connection between two virtual interfaces, each of which is bound to a physical interface on its respective device and physically connected to a [provider network](./providernetwork.md) via an independent [physical circuit](./circuit.md).
+
+## Fields
+
+### Provider Network
+
+The [provider network](./providernetwork.md) across which the virtual circuit is formed.
+
+### Provider Account
+
+The [provider account](./provideraccount.md) with which the virtual circuit is associated (if any).
+
+### Circuit ID
+
+The unique identifier assigned to the virtual circuit by its [provider](./provider.md).
+
+### Status
+
+The operational status of the virtual circuit. By default, the following statuses are available:
+
+| Name           |
+|----------------|
+| Planned        |
+| Provisioning   |
+| Active         |
+| Offline        |
+| Deprovisioning |
+| Decommissioned |
+
+!!! tip "Custom circuit statuses"
+    Additional circuit statuses may be defined by setting `Circuit.status` under the [`FIELD_CHOICES`](../../configuration/data-validation.md#field_choices) configuration parameter.

+ 21 - 0
docs/models/circuits/virtualcircuittermination.md

@@ -0,0 +1,21 @@
+# Virtual Circuit Terminations
+
+This model represents the connection of a virtual [interface](../dcim/interface.md) to a [virtual circuit](./virtualcircuit.md).
+
+## Fields
+
+### Virtual Circuit
+
+The [virtual circuit](./virtualcircuit.md) to which the interface is connected.
+
+### Interface
+
+The [interface](../dcim/interface.md) connected to the virtual circuit.
+
+### Role
+
+The functional role of the termination. This depends on the virtual circuit's topology, which is typically either peer-to-peer or hub-and-spoke (multipoint). Valid choices include:
+
+* Peer
+* Hub
+* Spoke

+ 2 - 0
mkdocs.yml

@@ -174,6 +174,8 @@ nav:
             - Provider: 'models/circuits/provider.md'
             - Provider Account: 'models/circuits/provideraccount.md'
             - Provider Network: 'models/circuits/providernetwork.md'
+            - Virtual Circuit: 'models/circuits/virtualcircuit.md'
+            - Virtual Circuit Termination: 'models/circuits/virtualcircuittermination.md'
         - Core:
             - DataFile: 'models/core/datafile.md'
             - DataSource: 'models/core/datasource.md'

+ 37 - 2
netbox/circuits/api/serializers_/circuits.py

@@ -2,9 +2,13 @@ from django.contrib.contenttypes.models import ContentType
 from drf_spectacular.utils import extend_schema_field
 from rest_framework import serializers
 
-from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices
+from circuits.choices import CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
-from circuits.models import Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType
+from circuits.models import (
+    Circuit, CircuitGroup, CircuitGroupAssignment, CircuitTermination, CircuitType, VirtualCircuit,
+    VirtualCircuitTermination,
+)
+from dcim.api.serializers_.device_components import InterfaceSerializer
 from dcim.api.serializers_.cables import CabledObjectSerializer
 from netbox.api.fields import ChoiceField, ContentTypeField, RelatedObjectCountField
 from netbox.api.serializers import NetBoxModelSerializer, WritableNestedSerializer
@@ -20,6 +24,8 @@ __all__ = (
     'CircuitGroupSerializer',
     'CircuitTerminationSerializer',
     'CircuitTypeSerializer',
+    'VirtualCircuitSerializer',
+    'VirtualCircuitTerminationSerializer',
 )
 
 
@@ -156,3 +162,32 @@ class CircuitGroupAssignmentSerializer(CircuitGroupAssignmentSerializer_):
             'id', 'url', 'display_url', 'display', 'group', 'circuit', 'priority', 'tags', 'created', 'last_updated',
         ]
         brief_fields = ('id', 'url', 'display', 'group', 'circuit', 'priority')
+
+
+class VirtualCircuitSerializer(NetBoxModelSerializer):
+    provider_network = ProviderNetworkSerializer(nested=True)
+    provider_account = ProviderAccountSerializer(nested=True, required=False, allow_null=True, default=None)
+    status = ChoiceField(choices=CircuitStatusChoices, required=False)
+    tenant = TenantSerializer(nested=True, required=False, allow_null=True)
+
+    class Meta:
+        model = VirtualCircuit
+        fields = [
+            'id', 'url', 'display_url', 'display', 'cid', 'provider_network', 'provider_account', 'status', 'tenant',
+            'description', 'comments', 'tags', 'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'provider_network', 'cid', 'description')
+
+
+class VirtualCircuitTerminationSerializer(NetBoxModelSerializer, CabledObjectSerializer):
+    virtual_circuit = VirtualCircuitSerializer(nested=True)
+    role = ChoiceField(choices=VirtualCircuitTerminationRoleChoices, required=False)
+    interface = InterfaceSerializer(nested=True)
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'id', 'url', 'display_url', 'display', 'virtual_circuit', 'role', 'interface', 'description', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+        brief_fields = ('id', 'url', 'display', 'virtual_circuit', 'role', 'interface', 'description')

+ 4 - 0
netbox/circuits/api/urls.py

@@ -17,5 +17,9 @@ router.register('circuit-terminations', views.CircuitTerminationViewSet)
 router.register('circuit-groups', views.CircuitGroupViewSet)
 router.register('circuit-group-assignments', views.CircuitGroupAssignmentViewSet)
 
+# Virtual circuits
+router.register('virtual-circuits', views.VirtualCircuitViewSet)
+router.register('virtual-circuit-terminations', views.VirtualCircuitTerminationViewSet)
+
 app_name = 'circuits-api'
 urlpatterns = router.urls

+ 20 - 0
netbox/circuits/api/views.py

@@ -93,3 +93,23 @@ class ProviderNetworkViewSet(NetBoxModelViewSet):
     queryset = ProviderNetwork.objects.all()
     serializer_class = serializers.ProviderNetworkSerializer
     filterset_class = filtersets.ProviderNetworkFilterSet
+
+
+#
+# Virtual circuits
+#
+
+class VirtualCircuitViewSet(NetBoxModelViewSet):
+    queryset = VirtualCircuit.objects.all()
+    serializer_class = serializers.VirtualCircuitSerializer
+    filterset_class = filtersets.VirtualCircuitFilterSet
+
+
+#
+# Virtual circuit terminations
+#
+
+class VirtualCircuitTerminationViewSet(PassThroughPortMixin, NetBoxModelViewSet):
+    queryset = VirtualCircuitTermination.objects.all()
+    serializer_class = serializers.VirtualCircuitTerminationSerializer
+    filterset_class = filtersets.VirtualCircuitTerminationFilterSet

+ 16 - 0
netbox/circuits/choices.py

@@ -92,3 +92,19 @@ class CircuitPriorityChoices(ChoiceSet):
         (PRIORITY_TERTIARY, _('Tertiary')),
         (PRIORITY_INACTIVE, _('Inactive')),
     ]
+
+
+#
+# Virtual circuits
+#
+
+class VirtualCircuitTerminationRoleChoices(ChoiceSet):
+    ROLE_PEER = 'peer'
+    ROLE_HUB = 'hub'
+    ROLE_SPOKE = 'spoke'
+
+    CHOICES = [
+        (ROLE_PEER, _('Peer'), 'green'),
+        (ROLE_HUB, _('Hub'), 'blue'),
+        (ROLE_SPOKE, _('Spoke'), 'orange'),
+    ]

+ 108 - 1
netbox/circuits/filtersets.py

@@ -3,7 +3,7 @@ from django.db.models import Q
 from django.utils.translation import gettext as _
 
 from dcim.filtersets import CabledObjectFilterSet
-from dcim.models import Location, Region, Site, SiteGroup
+from dcim.models import Interface, Location, Region, Site, SiteGroup
 from ipam.models import ASN
 from netbox.filtersets import NetBoxModelFilterSet, OrganizationalModelFilterSet
 from tenancy.filtersets import ContactModelFilterSet, TenancyFilterSet
@@ -20,6 +20,8 @@ __all__ = (
     'ProviderNetworkFilterSet',
     'ProviderAccountFilterSet',
     'ProviderFilterSet',
+    'VirtualCircuitFilterSet',
+    'VirtualCircuitTerminationFilterSet',
 )
 
 
@@ -404,3 +406,108 @@ class CircuitGroupAssignmentFilterSet(NetBoxModelFilterSet):
             Q(circuit__cid__icontains=value) |
             Q(group__name__icontains=value)
         )
+
+
+class VirtualCircuitFilterSet(NetBoxModelFilterSet, TenancyFilterSet):
+    provider_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_network__provider',
+        queryset=Provider.objects.all(),
+        label=_('Provider (ID)'),
+    )
+    provider = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_network__provider__slug',
+        queryset=Provider.objects.all(),
+        to_field_name='slug',
+        label=_('Provider (slug)'),
+    )
+    provider_account_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_account',
+        queryset=ProviderAccount.objects.all(),
+        label=_('Provider account (ID)'),
+    )
+    provider_account = django_filters.ModelMultipleChoiceFilter(
+        field_name='provider_account__account',
+        queryset=Provider.objects.all(),
+        to_field_name='account',
+        label=_('Provider account (account)'),
+    )
+    provider_network_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ProviderNetwork.objects.all(),
+        label=_('Provider network (ID)'),
+    )
+    status = django_filters.MultipleChoiceFilter(
+        choices=CircuitStatusChoices,
+        null_value=None
+    )
+
+    class Meta:
+        model = VirtualCircuit
+        fields = ('id', 'cid', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(cid__icontains=value) |
+            Q(description__icontains=value) |
+            Q(comments__icontains=value)
+        ).distinct()
+
+
+class VirtualCircuitTerminationFilterSet(NetBoxModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label=_('Search'),
+    )
+    virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=VirtualCircuit.objects.all(),
+        label=_('Virtual circuit'),
+    )
+    role = django_filters.MultipleChoiceFilter(
+        choices=VirtualCircuitTerminationRoleChoices,
+        null_value=None
+    )
+    provider_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_network__provider',
+        queryset=Provider.objects.all(),
+        label=_('Provider (ID)'),
+    )
+    provider = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_network__provider__slug',
+        queryset=Provider.objects.all(),
+        to_field_name='slug',
+        label=_('Provider (slug)'),
+    )
+    provider_account_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_account',
+        queryset=ProviderAccount.objects.all(),
+        label=_('Provider account (ID)'),
+    )
+    provider_account = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit__provider_account__account',
+        queryset=ProviderAccount.objects.all(),
+        to_field_name='account',
+        label=_('Provider account (account)'),
+    )
+    provider_network_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ProviderNetwork.objects.all(),
+        field_name='virtual_circuit__provider_network',
+        label=_('Provider network (ID)'),
+    )
+    interface_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Interface.objects.all(),
+        field_name='interface',
+        label=_('Interface (ID)'),
+    )
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = ('id', 'interface_id', 'description')
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(virtual_circuit__cid__icontains=value) |
+            Q(description__icontains=value)
+        ).distinct()

+ 64 - 1
netbox/circuits/forms/bulk_edit.py

@@ -3,7 +3,9 @@ from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
-from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices
+from circuits.choices import (
+    CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, VirtualCircuitTerminationRoleChoices,
+)
 from circuits.constants import CIRCUIT_TERMINATION_TERMINATION_TYPES
 from circuits.models import *
 from dcim.models import Site
@@ -28,6 +30,8 @@ __all__ = (
     'ProviderBulkEditForm',
     'ProviderAccountBulkEditForm',
     'ProviderNetworkBulkEditForm',
+    'VirtualCircuitBulkEditForm',
+    'VirtualCircuitTerminationBulkEditForm',
 )
 
 
@@ -291,3 +295,62 @@ class CircuitGroupAssignmentBulkEditForm(NetBoxModelBulkEditForm):
         FieldSet('circuit', 'priority'),
     )
     nullable_fields = ('priority',)
+
+
+class VirtualCircuitBulkEditForm(NetBoxModelBulkEditForm):
+    provider_network = DynamicModelChoiceField(
+        label=_('Provider network'),
+        queryset=ProviderNetwork.objects.all(),
+        required=False
+    )
+    provider_account = DynamicModelChoiceField(
+        label=_('Provider account'),
+        queryset=ProviderAccount.objects.all(),
+        required=False
+    )
+    status = forms.ChoiceField(
+        label=_('Status'),
+        choices=add_blank_choice(CircuitStatusChoices),
+        required=False,
+        initial=''
+    )
+    tenant = DynamicModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=100,
+        required=False
+    )
+    comments = CommentField()
+
+    model = VirtualCircuit
+    fieldsets = (
+        FieldSet('provider_network', 'provider_account', 'status', 'description', name=_('Virtual circuit')),
+        FieldSet('tenant', name=_('Tenancy')),
+    )
+    nullable_fields = (
+        'provider_account', 'tenant', 'description', 'comments',
+    )
+
+
+class VirtualCircuitTerminationBulkEditForm(NetBoxModelBulkEditForm):
+    role = forms.ChoiceField(
+        label=_('Role'),
+        choices=add_blank_choice(VirtualCircuitTerminationRoleChoices),
+        required=False,
+        initial=''
+    )
+    description = forms.CharField(
+        label=_('Description'),
+        max_length=200,
+        required=False
+    )
+
+    model = VirtualCircuitTermination
+    fieldsets = (
+        FieldSet('role', 'description'),
+    )
+    nullable_fields = ('description',)

+ 74 - 0
netbox/circuits/forms/bulk_import.py

@@ -5,6 +5,7 @@ from django.utils.translation import gettext_lazy as _
 from circuits.choices import *
 from circuits.constants import *
 from circuits.models import *
+from dcim.models import Interface
 from netbox.choices import DistanceUnitChoices
 from netbox.forms import NetBoxModelImportForm
 from tenancy.models import Tenant
@@ -20,6 +21,9 @@ __all__ = (
     'ProviderImportForm',
     'ProviderAccountImportForm',
     'ProviderNetworkImportForm',
+    'VirtualCircuitImportForm',
+    'VirtualCircuitTerminationImportForm',
+    'VirtualCircuitTerminationImportRelatedForm',
 )
 
 
@@ -179,3 +183,73 @@ class CircuitGroupAssignmentImportForm(NetBoxModelImportForm):
     class Meta:
         model = CircuitGroupAssignment
         fields = ('circuit', 'group', 'priority')
+
+
+class VirtualCircuitImportForm(NetBoxModelImportForm):
+    provider_network = CSVModelChoiceField(
+        label=_('Provider network'),
+        queryset=ProviderNetwork.objects.all(),
+        to_field_name='name',
+        help_text=_('The network to which this virtual circuit belongs')
+    )
+    provider_account = CSVModelChoiceField(
+        label=_('Provider account'),
+        queryset=ProviderAccount.objects.all(),
+        to_field_name='account',
+        help_text=_('Assigned provider account (if any)'),
+        required=False
+    )
+    status = CSVChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        help_text=_('Operational status')
+    )
+    tenant = CSVModelChoiceField(
+        label=_('Tenant'),
+        queryset=Tenant.objects.all(),
+        required=False,
+        to_field_name='name',
+        help_text=_('Assigned tenant')
+    )
+
+    class Meta:
+        model = VirtualCircuit
+        fields = [
+            'cid', 'provider_network', 'provider_account', 'status', 'tenant', 'description', 'comments', 'tags',
+        ]
+
+
+class BaseVirtualCircuitTerminationImportForm(forms.ModelForm):
+    virtual_circuit = CSVModelChoiceField(
+        label=_('Virtual circuit'),
+        queryset=VirtualCircuit.objects.all(),
+        to_field_name='cid',
+    )
+    role = CSVChoiceField(
+        label=_('Role'),
+        choices=VirtualCircuitTerminationRoleChoices,
+        help_text=_('Operational role')
+    )
+    interface = CSVModelChoiceField(
+        label=_('Interface'),
+        queryset=Interface.objects.all(),
+        to_field_name='pk',
+    )
+
+
+class VirtualCircuitTerminationImportRelatedForm(BaseVirtualCircuitTerminationImportForm):
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'virtual_circuit', 'role', 'interface', 'description',
+        ]
+
+
+class VirtualCircuitTerminationImportForm(NetBoxModelImportForm, BaseVirtualCircuitTerminationImportForm):
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'virtual_circuit', 'role', 'interface', 'description', 'tags',
+        ]

+ 77 - 1
netbox/circuits/forms/filtersets.py

@@ -1,7 +1,10 @@
 from django import forms
 from django.utils.translation import gettext as _
 
-from circuits.choices import CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices
+from circuits.choices import (
+    CircuitCommitRateChoices, CircuitPriorityChoices, CircuitStatusChoices, CircuitTerminationSideChoices,
+    VirtualCircuitTerminationRoleChoices,
+)
 from circuits.models import *
 from dcim.models import Location, Region, Site, SiteGroup
 from ipam.models import ASN
@@ -22,6 +25,8 @@ __all__ = (
     'ProviderFilterForm',
     'ProviderAccountFilterForm',
     'ProviderNetworkFilterForm',
+    'VirtualCircuitFilterForm',
+    'VirtualCircuitTerminationFilterForm',
 )
 
 
@@ -292,3 +297,74 @@ class CircuitGroupAssignmentFilterForm(NetBoxModelFilterSetForm):
         required=False
     )
     tag = TagFilterField(model)
+
+
+class VirtualCircuitFilterForm(TenancyFilterForm, ContactModelFilterForm, NetBoxModelFilterSetForm):
+    model = VirtualCircuit
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
+        FieldSet('status', name=_('Attributes')),
+        FieldSet('tenant_group_id', 'tenant_id', name=_('Tenant')),
+    )
+    selector_fields = ('filter_id', 'q', 'provider_id', 'provider_network_id')
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider')
+    )
+    provider_account_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderAccount.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider account')
+    )
+    provider_network_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderNetwork.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider network')
+    )
+    status = forms.MultipleChoiceField(
+        label=_('Status'),
+        choices=CircuitStatusChoices,
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
+class VirtualCircuitTerminationFilterForm(NetBoxModelFilterSetForm):
+    model = VirtualCircuitTermination
+    fieldsets = (
+        FieldSet('q', 'filter_id', 'tag'),
+        FieldSet('virtual_circuit_id', 'role', name=_('Virtual circuit')),
+        FieldSet('provider_id', 'provider_account_id', 'provider_network_id', name=_('Provider')),
+    )
+    virtual_circuit_id = DynamicModelMultipleChoiceField(
+        queryset=VirtualCircuit.objects.all(),
+        required=False,
+        label=_('Virtual circuit')
+    )
+    role = forms.MultipleChoiceField(
+        label=_('Role'),
+        choices=VirtualCircuitTerminationRoleChoices,
+        required=False
+    )
+    provider_network_id = DynamicModelMultipleChoiceField(
+        queryset=ProviderNetwork.objects.all(),
+        required=False,
+        query_params={
+            'provider_id': '$provider_id'
+        },
+        label=_('Provider network')
+    )
+    provider_id = DynamicModelMultipleChoiceField(
+        queryset=Provider.objects.all(),
+        required=False,
+        label=_('Provider')
+    )
+    tag = TagFilterField(model)

+ 73 - 3
netbox/circuits/forms/model_forms.py

@@ -1,16 +1,21 @@
+from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist
 from django.utils.translation import gettext_lazy as _
 
-from circuits.choices import CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices
+from circuits.choices import (
+    CircuitCommitRateChoices, CircuitTerminationPortSpeedChoices, VirtualCircuitTerminationRoleChoices,
+)
 from circuits.constants import *
 from circuits.models import *
-from dcim.models import Site
+from dcim.models import Interface, Site
 from ipam.models import ASN
 from netbox.forms import NetBoxModelForm
 from tenancy.forms import TenancyForm
 from utilities.forms import get_field_value
-from utilities.forms.fields import CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField
+from utilities.forms.fields import (
+    CommentField, ContentTypeChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, SlugField,
+)
 from utilities.forms.rendering import FieldSet, InlineFields
 from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
 from utilities.templatetags.builtins.filters import bettertitle
@@ -24,6 +29,8 @@ __all__ = (
     'ProviderForm',
     'ProviderAccountForm',
     'ProviderNetworkForm',
+    'VirtualCircuitForm',
+    'VirtualCircuitTerminationForm',
 )
 
 
@@ -255,3 +262,66 @@ class CircuitGroupAssignmentForm(NetBoxModelForm):
         fields = [
             'group', 'circuit', 'priority', 'tags',
         ]
+
+
+class VirtualCircuitForm(TenancyForm, NetBoxModelForm):
+    provider_network = DynamicModelChoiceField(
+        label=_('Provider network'),
+        queryset=ProviderNetwork.objects.all(),
+        selector=True
+    )
+    provider_account = DynamicModelChoiceField(
+        label=_('Provider account'),
+        queryset=ProviderAccount.objects.all(),
+        required=False
+    )
+    comments = CommentField()
+
+    fieldsets = (
+        FieldSet(
+            'provider_network', 'provider_account', 'cid', 'status', 'description', 'tags', name=_('Virtual circuit'),
+        ),
+        FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
+    )
+
+    class Meta:
+        model = VirtualCircuit
+        fields = [
+            'cid', 'provider_network', 'provider_account', 'status', 'description', 'tenant_group', 'tenant',
+            'comments', 'tags',
+        ]
+
+
+class VirtualCircuitTerminationForm(NetBoxModelForm):
+    virtual_circuit = DynamicModelChoiceField(
+        label=_('Virtual circuit'),
+        queryset=VirtualCircuit.objects.all(),
+        selector=True
+    )
+    role = forms.ChoiceField(
+        choices=VirtualCircuitTerminationRoleChoices,
+        widget=HTMXSelect(),
+        label=_('Role')
+    )
+    interface = DynamicModelChoiceField(
+        label=_('Interface'),
+        queryset=Interface.objects.all(),
+        selector=True,
+        query_params={
+            'kind': 'virtual',
+            'virtual_circuit_termination_id': 'null',
+        },
+        context={
+            'parent': 'device',
+        }
+    )
+
+    fieldsets = (
+        FieldSet('virtual_circuit', 'role', 'interface', 'description', 'tags'),
+    )
+
+    class Meta:
+        model = VirtualCircuitTermination
+        fields = [
+            'virtual_circuit', 'role', 'interface', 'description', 'tags',
+        ]

+ 15 - 1
netbox/circuits/graphql/filters.py

@@ -4,14 +4,16 @@ from circuits import filtersets, models
 from netbox.graphql.filter_mixins import autotype_decorator, BaseFilterMixin
 
 __all__ = (
-    'CircuitTerminationFilter',
     'CircuitFilter',
     'CircuitGroupAssignmentFilter',
     'CircuitGroupFilter',
+    'CircuitTerminationFilter',
     'CircuitTypeFilter',
     'ProviderFilter',
     'ProviderAccountFilter',
     'ProviderNetworkFilter',
+    'VirtualCircuitFilter',
+    'VirtualCircuitTerminationFilter',
 )
 
 
@@ -61,3 +63,15 @@ class ProviderAccountFilter(BaseFilterMixin):
 @autotype_decorator(filtersets.ProviderNetworkFilterSet)
 class ProviderNetworkFilter(BaseFilterMixin):
     pass
+
+
+@strawberry_django.filter(models.VirtualCircuit, lookups=True)
+@autotype_decorator(filtersets.VirtualCircuitFilterSet)
+class VirtualCircuitFilter(BaseFilterMixin):
+    pass
+
+
+@strawberry_django.filter(models.VirtualCircuitTermination, lookups=True)
+@autotype_decorator(filtersets.VirtualCircuitTerminationFilterSet)
+class VirtualCircuitTerminationFilter(BaseFilterMixin):
+    pass

+ 6 - 0
netbox/circuits/graphql/schema.py

@@ -31,3 +31,9 @@ class CircuitsQuery:
 
     provider_network: ProviderNetworkType = strawberry_django.field()
     provider_network_list: List[ProviderNetworkType] = strawberry_django.field()
+
+    virtual_circuit: VirtualCircuitType = strawberry_django.field()
+    virtual_circuit_list: List[VirtualCircuitType] = strawberry_django.field()
+
+    virtual_circuit_termination: VirtualCircuitTerminationType = strawberry_django.field()
+    virtual_circuit_termination_list: List[VirtualCircuitTerminationType] = strawberry_django.field()

+ 31 - 0
netbox/circuits/graphql/types.py

@@ -19,6 +19,8 @@ __all__ = (
     'ProviderType',
     'ProviderAccountType',
     'ProviderNetworkType',
+    'VirtualCircuitTerminationType',
+    'VirtualCircuitType',
 )
 
 
@@ -120,3 +122,32 @@ class CircuitGroupType(OrganizationalObjectType):
 class CircuitGroupAssignmentType(TagsMixin, BaseObjectType):
     group: Annotated["CircuitGroupType", strawberry.lazy('circuits.graphql.types')]
     circuit: Annotated["CircuitType", strawberry.lazy('circuits.graphql.types')]
+
+
+@strawberry_django.type(
+    models.VirtualCircuitTermination,
+    fields='__all__',
+    filters=VirtualCircuitTerminationFilter
+)
+class VirtualCircuitTerminationType(CustomFieldsMixin, TagsMixin, ObjectType):
+    virtual_circuit: Annotated[
+        "VirtualCircuitType",
+        strawberry.lazy('circuits.graphql.types')
+    ] = strawberry_django.field(select_related=["virtual_circuit"])
+    interface: Annotated[
+        "InterfaceType",
+        strawberry.lazy('dcim.graphql.types')
+    ] = strawberry_django.field(select_related=["interface"])
+
+
+@strawberry_django.type(
+    models.VirtualCircuit,
+    fields='__all__',
+    filters=VirtualCircuitFilter
+)
+class VirtualCircuitType(NetBoxObjectType):
+    provider_network: ProviderNetworkType = strawberry_django.field(select_related=["provider_network"])
+    provider_account: ProviderAccountType | None
+    tenant: TenantType | None
+
+    terminations: List[VirtualCircuitTerminationType]

+ 67 - 0
netbox/circuits/migrations/0050_virtual_circuits.py

@@ -0,0 +1,67 @@
+import django.db.models.deletion
+import taggit.managers
+from django.db import migrations, models
+
+import utilities.json
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('circuits', '0049_natural_ordering'),
+        ('dcim', '0196_qinq_svlan'),
+        ('extras', '0122_charfield_null_choices'),
+        ('tenancy', '0016_charfield_null_choices'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VirtualCircuit',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('comments', models.TextField(blank=True)),
+                ('cid', models.CharField(max_length=100)),
+                ('status', models.CharField(default='active', max_length=50)),
+                ('provider_account', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_circuits', to='circuits.provideraccount')),
+                ('provider_network', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='virtual_circuits', to='circuits.providernetwork')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                ('tenant', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='virtual_circuits', to='tenancy.tenant')),
+            ],
+            options={
+                'verbose_name': 'circuit',
+                'verbose_name_plural': 'circuits',
+                'ordering': ['provider_network', 'provider_account', 'cid'],
+            },
+        ),
+        migrations.CreateModel(
+            name='VirtualCircuitTermination',
+            fields=[
+                ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+                ('created', models.DateTimeField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder)),
+                ('role', models.CharField(default='peer', max_length=50)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('interface', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='virtual_circuit_termination', to='dcim.interface')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+                ('virtual_circuit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='terminations', to='circuits.virtualcircuit')),
+            ],
+            options={
+                'verbose_name': 'virtual circuit termination',
+                'verbose_name_plural': 'virtual circuit terminations',
+                'ordering': ['virtual_circuit', 'role', 'pk'],
+            },
+        ),
+        migrations.AddConstraint(
+            model_name='virtualcircuit',
+            constraint=models.UniqueConstraint(fields=('provider_network', 'cid'), name='circuits_virtualcircuit_unique_provider_network_cid'),
+        ),
+        migrations.AddConstraint(
+            model_name='virtualcircuit',
+            constraint=models.UniqueConstraint(fields=('provider_account', 'cid'), name='circuits_virtualcircuit_unique_provideraccount_cid'),
+        ),
+    ]

+ 1 - 0
netbox/circuits/models/__init__.py

@@ -1,2 +1,3 @@
 from .circuits import *
 from .providers import *
+from .virtual_circuits import *

+ 164 - 0
netbox/circuits/models/virtual_circuits.py

@@ -0,0 +1,164 @@
+from functools import cached_property
+
+from django.core.exceptions import ValidationError
+from django.db import models
+from django.urls import reverse
+from django.utils.translation import gettext_lazy as _
+
+from circuits.choices import *
+from netbox.models import ChangeLoggedModel, PrimaryModel
+from netbox.models.features import CustomFieldsMixin, CustomLinksMixin, TagsMixin
+
+__all__ = (
+    'VirtualCircuit',
+    'VirtualCircuitTermination',
+)
+
+
+class VirtualCircuit(PrimaryModel):
+    """
+    A virtual connection between two or more endpoints, delivered across one or more physical circuits.
+    """
+    cid = models.CharField(
+        max_length=100,
+        verbose_name=_('circuit ID'),
+        help_text=_('Unique circuit ID')
+    )
+    provider_network = models.ForeignKey(
+        to='circuits.ProviderNetwork',
+        on_delete=models.PROTECT,
+        related_name='virtual_circuits'
+    )
+    provider_account = models.ForeignKey(
+        to='circuits.ProviderAccount',
+        on_delete=models.PROTECT,
+        related_name='virtual_circuits',
+        blank=True,
+        null=True
+    )
+    status = models.CharField(
+        verbose_name=_('status'),
+        max_length=50,
+        choices=CircuitStatusChoices,
+        default=CircuitStatusChoices.STATUS_ACTIVE
+    )
+    tenant = models.ForeignKey(
+        to='tenancy.Tenant',
+        on_delete=models.PROTECT,
+        related_name='virtual_circuits',
+        blank=True,
+        null=True
+    )
+
+    clone_fields = (
+        'provider_network', 'provider_account', 'status', 'tenant', 'description',
+    )
+    prerequisite_models = (
+        'circuits.ProviderNetwork',
+    )
+
+    class Meta:
+        ordering = ['provider_network', 'provider_account', 'cid']
+        constraints = (
+            models.UniqueConstraint(
+                fields=('provider_network', 'cid'),
+                name='%(app_label)s_%(class)s_unique_provider_network_cid'
+            ),
+            models.UniqueConstraint(
+                fields=('provider_account', 'cid'),
+                name='%(app_label)s_%(class)s_unique_provideraccount_cid'
+            ),
+        )
+        verbose_name = _('virtual circuit')
+        verbose_name_plural = _('virtual circuits')
+
+    def __str__(self):
+        return self.cid
+
+    def get_status_color(self):
+        return CircuitStatusChoices.colors.get(self.status)
+
+    def clean(self):
+        super().clean()
+
+        if self.provider_account and self.provider_network.provider != self.provider_account.provider:
+            raise ValidationError({
+                'provider_account': "The assigned account must belong to the provider of the assigned network."
+            })
+
+    @property
+    def provider(self):
+        return self.provider_network.provider
+
+
+class VirtualCircuitTermination(
+    CustomFieldsMixin,
+    CustomLinksMixin,
+    TagsMixin,
+    ChangeLoggedModel
+):
+    virtual_circuit = models.ForeignKey(
+        to='circuits.VirtualCircuit',
+        on_delete=models.CASCADE,
+        related_name='terminations'
+    )
+    role = models.CharField(
+        verbose_name=_('role'),
+        max_length=50,
+        choices=VirtualCircuitTerminationRoleChoices,
+        default=VirtualCircuitTerminationRoleChoices.ROLE_PEER
+    )
+    interface = models.OneToOneField(
+        to='dcim.Interface',
+        on_delete=models.CASCADE,
+        related_name='virtual_circuit_termination'
+    )
+    description = models.CharField(
+        verbose_name=_('description'),
+        max_length=200,
+        blank=True
+    )
+
+    class Meta:
+        ordering = ['virtual_circuit', 'role', 'pk']
+        verbose_name = _('virtual circuit termination')
+        verbose_name_plural = _('virtual circuit terminations')
+
+    def __str__(self):
+        return f'{self.virtual_circuit}: {self.get_role_display()} termination'
+
+    def get_absolute_url(self):
+        return reverse('circuits:virtualcircuittermination', args=[self.pk])
+
+    def get_role_color(self):
+        return VirtualCircuitTerminationRoleChoices.colors.get(self.role)
+
+    def to_objectchange(self, action):
+        objectchange = super().to_objectchange(action)
+        objectchange.related_object = self.virtual_circuit
+        return objectchange
+
+    @property
+    def parent_object(self):
+        return self.virtual_circuit
+
+    @cached_property
+    def peer_terminations(self):
+        if self.role == VirtualCircuitTerminationRoleChoices.ROLE_PEER:
+            return self.virtual_circuit.terminations.exclude(pk=self.pk).filter(
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER
+            )
+        if self.role == VirtualCircuitTerminationRoleChoices.ROLE_HUB:
+            return self.virtual_circuit.terminations.filter(
+                role=VirtualCircuitTerminationRoleChoices.ROLE_SPOKE
+            )
+        if self.role == VirtualCircuitTerminationRoleChoices.ROLE_SPOKE:
+            return self.virtual_circuit.terminations.filter(
+                role=VirtualCircuitTerminationRoleChoices.ROLE_HUB
+            )
+
+    def clean(self):
+        super().clean()
+
+        if self.interface and not self.interface.is_virtual:
+            raise ValidationError("Virtual circuits may be terminated only to virtual interfaces.")

+ 20 - 0
netbox/circuits/search.py

@@ -80,3 +80,23 @@ class ProviderNetworkIndex(SearchIndex):
         ('comments', 5000),
     )
     display_attrs = ('provider', 'service_id', 'description')
+
+
+@register_search
+class VirtualCircuitIndex(SearchIndex):
+    model = models.VirtualCircuit
+    fields = (
+        ('cid', 100),
+        ('description', 500),
+        ('comments', 5000),
+    )
+    display_attrs = ('provider', 'provider_network', 'provider_account', 'status', 'tenant', 'description')
+
+
+@register_search
+class VirtualCircuitTerminationIndex(SearchIndex):
+    model = models.VirtualCircuitTermination
+    fields = (
+        ('description', 500),
+    )
+    display_attrs = ('virtual_circuit', 'role', 'description')

+ 1 - 0
netbox/circuits/tables/__init__.py

@@ -1,3 +1,4 @@
 from .circuits import *
 from .columns import *
 from .providers import *
+from .virtual_circuits import *

+ 95 - 0
netbox/circuits/tables/virtual_circuits.py

@@ -0,0 +1,95 @@
+import django_tables2 as tables
+from django.utils.translation import gettext_lazy as _
+
+from circuits.models import *
+from netbox.tables import NetBoxTable, columns
+from tenancy.tables import ContactsColumnMixin, TenancyColumnsMixin
+
+__all__ = (
+    'VirtualCircuitTable',
+    'VirtualCircuitTerminationTable',
+)
+
+
+class VirtualCircuitTable(TenancyColumnsMixin, ContactsColumnMixin, NetBoxTable):
+    cid = tables.Column(
+        linkify=True,
+        verbose_name=_('Circuit ID')
+    )
+    provider = tables.Column(
+        accessor=tables.A('provider_network__provider'),
+        verbose_name=_('Provider'),
+        linkify=True
+    )
+    provider_network = tables.Column(
+        linkify=True,
+        verbose_name=_('Provider network')
+    )
+    provider_account = tables.Column(
+        linkify=True,
+        verbose_name=_('Account')
+    )
+    status = columns.ChoiceFieldColumn()
+    termination_count = columns.LinkedCountColumn(
+        viewname='circuits:virtualcircuittermination_list',
+        url_params={'virtual_circuit_id': 'pk'},
+        verbose_name=_('Terminations')
+    )
+    comments = columns.MarkdownColumn(
+        verbose_name=_('Comments')
+    )
+    tags = columns.TagColumn(
+        url_name='circuits:virtualcircuit_list'
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = VirtualCircuit
+        fields = (
+            'pk', 'id', 'cid', 'provider', 'provider_account', 'provider_network', 'status', 'tenant', 'tenant_group',
+            'description', 'comments', 'tags', 'created', 'last_updated',
+        )
+        default_columns = (
+            'pk', 'cid', 'provider', 'provider_account', 'provider_network', 'status', 'tenant', 'termination_count',
+            'description',
+        )
+
+
+class VirtualCircuitTerminationTable(NetBoxTable):
+    virtual_circuit = tables.Column(
+        verbose_name=_('Virtual circuit'),
+        linkify=True
+    )
+    provider = tables.Column(
+        accessor=tables.A('virtual_circuit__provider_network__provider'),
+        verbose_name=_('Provider'),
+        linkify=True
+    )
+    provider_network = tables.Column(
+        accessor=tables.A('virtual_circuit__provider_network'),
+        linkify=True,
+        verbose_name=_('Provider network')
+    )
+    provider_account = tables.Column(
+        linkify=True,
+        verbose_name=_('Account')
+    )
+    role = columns.ChoiceFieldColumn()
+    device = tables.Column(
+        accessor=tables.A('interface__device'),
+        linkify=True,
+        verbose_name=_('Device')
+    )
+    interface = tables.Column(
+        verbose_name=_('Interface'),
+        linkify=True
+    )
+
+    class Meta(NetBoxTable.Meta):
+        model = VirtualCircuitTermination
+        fields = (
+            'pk', 'id', 'virtual_circuit', 'provider', 'provider_network', 'provider_account', 'role', 'interfaces',
+            'description', 'created', 'last_updated', 'actions',
+        )
+        default_columns = (
+            'pk', 'id', 'virtual_circuit', 'role', 'device', 'interface', 'description',
+        )

+ 239 - 1
netbox/circuits/tests/test_api.py

@@ -2,7 +2,8 @@ from django.urls import reverse
 
 from circuits.choices import *
 from circuits.models import *
-from dcim.models import Site
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from ipam.models import ASN, RIR
 from utilities.testing import APITestCase, APIViewTestCases
 
@@ -397,3 +398,240 @@ class ProviderNetworkTest(APIViewTestCases.APIViewTestCase):
             'provider': providers[1].pk,
             'description': 'New description',
         }
+
+
+class VirtualCircuitTest(APIViewTestCases.APIViewTestCase):
+    model = VirtualCircuit
+    brief_fields = ['cid', 'description', 'display', 'id', 'provider_network', 'url']
+    bulk_update_data = {
+        'status': 'planned',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
+        provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 3'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        cls.create_data = [
+            {
+                'cid': 'Virtual Circuit 4',
+                'provider_network': provider_network.pk,
+                'provider_account': provider_account.pk,
+                'status': CircuitStatusChoices.STATUS_PLANNED,
+            },
+            {
+                'cid': 'Virtual Circuit 5',
+                'provider_network': provider_network.pk,
+                'provider_account': provider_account.pk,
+                'status': CircuitStatusChoices.STATUS_PLANNED,
+            },
+            {
+                'cid': 'Virtual Circuit 6',
+                'provider_network': provider_network.pk,
+                'provider_account': provider_account.pk,
+                'status': CircuitStatusChoices.STATUS_PLANNED,
+            },
+        ]
+
+
+class VirtualCircuitTerminationTest(APIViewTestCases.APIViewTestCase):
+    model = VirtualCircuitTermination
+    brief_fields = ['description', 'display', 'id', 'interface', 'role', 'url', 'virtual_circuit']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        devices = (
+            Device(site=site, name='hub', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke1', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke2', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke3', device_type=device_type, role=device_role),
+        )
+        Device.objects.bulk_create(devices)
+
+        physical_interfaces = (
+            Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(physical_interfaces)
+
+        virtual_interfaces = (
+            # Point-to-point VCs
+            Interface(
+                device=devices[0],
+                name='eth0.1',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.2',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.3',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.1',
+                parent=physical_interfaces[1],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.1',
+                parent=physical_interfaces[2],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.1',
+                parent=physical_interfaces[3],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+
+            # Hub and spoke VCs
+            Interface(
+                device=devices[0],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+        )
+        Interface.objects.bulk_create(virtual_interfaces)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
+        provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 3'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 4'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        virtual_circuit_terminations = (
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[0]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[3]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[1]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[4]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[2]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[5]
+            ),
+        )
+        VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
+
+        cls.create_data = [
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_HUB,
+                'interface': virtual_interfaces[6].pk
+            },
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                'interface': virtual_interfaces[7].pk
+            },
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                'interface': virtual_interfaces[8].pk
+            },
+            {
+                'virtual_circuit': virtual_circuits[3].pk,
+                'role': VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                'interface': virtual_interfaces[9].pk
+            },
+        ]

+ 292 - 1
netbox/circuits/tests/test_filtersets.py

@@ -3,7 +3,8 @@ from django.test import TestCase
 from circuits.choices import *
 from circuits.filtersets import *
 from circuits.models import *
-from dcim.models import Cable, Region, Site, SiteGroup
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Region, Site, SiteGroup
 from ipam.models import ASN, RIR
 from netbox.choices import DistanceUnitChoices
 from tenancy.models import Tenant, TenantGroup
@@ -678,3 +679,293 @@ class ProviderAccountTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         params = {'provider': [providers[0].slug, providers[1].slug]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class VirtualCircuitTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VirtualCircuit.objects.all()
+    filterset = VirtualCircuitFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        tenant_groups = (
+            TenantGroup(name='Tenant group 1', slug='tenant-group-1'),
+            TenantGroup(name='Tenant group 2', slug='tenant-group-2'),
+            TenantGroup(name='Tenant group 3', slug='tenant-group-3'),
+        )
+        for tenantgroup in tenant_groups:
+            tenantgroup.save()
+
+        tenants = (
+            Tenant(name='Tenant 1', slug='tenant-1', group=tenant_groups[0]),
+            Tenant(name='Tenant 2', slug='tenant-2', group=tenant_groups[1]),
+            Tenant(name='Tenant 3', slug='tenant-3', group=tenant_groups[2]),
+        )
+        Tenant.objects.bulk_create(tenants)
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+
+        provider_accounts = (
+            ProviderAccount(name='Provider Account 1', provider=providers[0], account='A'),
+            ProviderAccount(name='Provider Account 2', provider=providers[1], account='B'),
+            ProviderAccount(name='Provider Account 3', provider=providers[2], account='C'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        provider_networks = (
+            ProviderNetwork(name='Provider Network 1', provider=providers[0]),
+            ProviderNetwork(name='Provider Network 2', provider=providers[1]),
+            ProviderNetwork(name='Provider Network 3', provider=providers[2]),
+        )
+        ProviderNetwork.objects.bulk_create(provider_networks)
+
+        virutal_circuits = (
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                tenant=tenants[0],
+                cid='Virtual Circuit 1',
+                status=CircuitStatusChoices.STATUS_PLANNED,
+                description='virtualcircuit1',
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[1],
+                provider_account=provider_accounts[1],
+                tenant=tenants[1],
+                cid='Virtual Circuit 2',
+                status=CircuitStatusChoices.STATUS_ACTIVE,
+                description='virtualcircuit2',
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[2],
+                provider_account=provider_accounts[2],
+                tenant=tenants[2],
+                cid='Virtual Circuit 3',
+                status=CircuitStatusChoices.STATUS_DEPROVISIONING,
+                description='virtualcircuit3',
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virutal_circuits)
+
+    def test_q(self):
+        params = {'q': 'virtualcircuit1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_cid(self):
+        params = {'cid': ['Virtual Circuit 1', 'Virtual Circuit 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_provider(self):
+        providers = Provider.objects.all()[:2]
+        params = {'provider_id': [providers[0].pk, providers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'provider': [providers[0].slug, providers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_provider_account(self):
+        provider_accounts = ProviderAccount.objects.all()[:2]
+        params = {'provider_account_id': [provider_accounts[0].pk, provider_accounts[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_provider_network(self):
+        provider_networks = ProviderNetwork.objects.all()[:2]
+        params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_status(self):
+        params = {'status': [CircuitStatusChoices.STATUS_ACTIVE, CircuitStatusChoices.STATUS_PLANNED]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['virtualcircuit1', 'virtualcircuit2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_tenant(self):
+        tenants = Tenant.objects.all()[:2]
+        params = {'tenant_id': [tenants[0].pk, tenants[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant': [tenants[0].slug, tenants[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_tenant_group(self):
+        tenant_groups = TenantGroup.objects.all()[:2]
+        params = {'tenant_group_id': [tenant_groups[0].pk, tenant_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'tenant_group': [tenant_groups[0].slug, tenant_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
+class VirtualCircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = VirtualCircuitTerminationFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        devices = (
+            Device(site=site, name='Device 1', device_type=device_type, role=device_role),
+            Device(site=site, name='Device 2', device_type=device_type, role=device_role),
+            Device(site=site, name='Device 3', device_type=device_type, role=device_role),
+        )
+        Device.objects.bulk_create(devices)
+
+        virtual_interfaces = (
+            # Device 1
+            Interface(
+                device=devices[0],
+                name='eth0.1',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.2',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            # Device 2
+            Interface(
+                device=devices[1],
+                name='eth0.1',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.2',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            # Device 3
+            Interface(
+                device=devices[2],
+                name='eth0.1',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.2',
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+        )
+        Interface.objects.bulk_create(virtual_interfaces)
+
+        providers = (
+            Provider(name='Provider 1', slug='provider-1'),
+            Provider(name='Provider 2', slug='provider-2'),
+            Provider(name='Provider 3', slug='provider-3'),
+        )
+        Provider.objects.bulk_create(providers)
+        provider_networks = (
+            ProviderNetwork(provider=providers[0], name='Provider Network 1'),
+            ProviderNetwork(provider=providers[1], name='Provider Network 2'),
+            ProviderNetwork(provider=providers[2], name='Provider Network 3'),
+        )
+        ProviderNetwork.objects.bulk_create(provider_networks)
+        provider_accounts = (
+            ProviderAccount(provider=providers[0], account='Provider Account 1'),
+            ProviderAccount(provider=providers[1], account='Provider Account 2'),
+            ProviderAccount(provider=providers[2], account='Provider Account 3'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[1],
+                provider_account=provider_accounts[1],
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[2],
+                provider_account=provider_accounts[2],
+                cid='Virtual Circuit 3'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        virtual_circuit_terminations = (
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_HUB,
+                interface=virtual_interfaces[0],
+                description='termination1'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_SPOKE,
+                interface=virtual_interfaces[3],
+                description='termination2'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[1],
+                description='termination3'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[4],
+                description='termination4'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[2],
+                description='termination5'
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[5],
+                description='termination6'
+            ),
+        )
+        VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
+
+    def test_q(self):
+        params = {'q': 'termination1'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_description(self):
+        params = {'description': ['termination1', 'termination2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_virtual_circuit_id(self):
+        virtual_circuits = VirtualCircuit.objects.filter()[:2]
+        params = {'virtual_circuit_id': [virtual_circuits[0].pk, virtual_circuits[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_provider(self):
+        providers = Provider.objects.all()[:2]
+        params = {'provider_id': [providers[0].pk, providers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'provider': [providers[0].slug, providers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_provider_network(self):
+        provider_networks = ProviderNetwork.objects.all()[:2]
+        params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_provider_account(self):
+        provider_accounts = ProviderAccount.objects.all()[:2]
+        params = {'provider_account_id': [provider_accounts[0].pk, provider_accounts[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+        params = {'provider_account': [provider_accounts[0].account, provider_accounts[1].account]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_interface(self):
+        interfaces = Interface.objects.all()[:2]
+        params = {'interface_id': [interfaces[0].pk, interfaces[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)

+ 319 - 2
netbox/circuits/tests/test_views.py

@@ -7,7 +7,8 @@ from django.urls import reverse
 from circuits.choices import *
 from circuits.models import *
 from core.models import ObjectType
-from dcim.models import Cable, Interface, Site
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Cable, Device, DeviceRole, DeviceType, Interface, Manufacturer, Site
 from ipam.models import ASN, RIR
 from netbox.choices import ImportFormatChoices
 from users.models import ObjectPermission
@@ -341,7 +342,7 @@ class ProviderNetworkTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
-class TestCase(ViewTestCases.PrimaryObjectViewTestCase):
+class CircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = CircuitTermination
 
     @classmethod
@@ -518,3 +519,319 @@ class CircuitGroupAssignmentTestCase(
         cls.bulk_edit_data = {
             'priority': CircuitPriorityChoices.PRIORITY_INACTIVE,
         }
+
+
+class VirtualCircuitTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = VirtualCircuit
+
+    def setUp(self):
+        super().setUp()
+
+        self.add_permissions(
+            'circuits.add_virtualcircuittermination',
+        )
+
+    @classmethod
+    def setUpTestData(cls):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_networks = (
+            ProviderNetwork(provider=provider, name='Provider Network 1'),
+            ProviderNetwork(provider=provider, name='Provider Network 2'),
+        )
+        ProviderNetwork.objects.bulk_create(provider_networks)
+        provider_accounts = (
+            ProviderAccount(provider=provider, account='Provider Account 1'),
+            ProviderAccount(provider=provider, account='Provider Account 2'),
+        )
+        ProviderAccount.objects.bulk_create(provider_accounts)
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_networks[0],
+                provider_account=provider_accounts[0],
+                cid='Virtual Circuit 3'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        device = create_test_device('Device 1')
+        interfaces = (
+            Interface(device=device, name='Interface 1', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 2', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+            Interface(device=device, name='Interface 3', type=InterfaceTypeChoices.TYPE_VIRTUAL),
+        )
+        Interface.objects.bulk_create(interfaces)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'cid': 'Virtual Circuit X',
+            'provider_network': provider_networks[1].pk,
+            'provider_account': provider_accounts[1].pk,
+            'status': CircuitStatusChoices.STATUS_PLANNED,
+            'description': 'A new virtual circuit',
+            'comments': 'Some comments',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "cid,provider_network,provider_account,status",
+            f"Virtual Circuit 4,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
+            f"Virtual Circuit 5,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
+            f"Virtual Circuit 6,Provider Network 1,Provider Account 1,{CircuitStatusChoices.STATUS_PLANNED}",
+        )
+
+        cls.csv_update_data = (
+            "id,cid,description,status",
+            f"{virtual_circuits[0].pk},Virtual Circuit A,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+            f"{virtual_circuits[1].pk},Virtual Circuit B,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+            f"{virtual_circuits[2].pk},Virtual Circuit C,New description,{CircuitStatusChoices.STATUS_DECOMMISSIONED}",
+        )
+
+        cls.bulk_edit_data = {
+            'provider_network': provider_networks[1].pk,
+            'provider_account': provider_accounts[1].pk,
+            'status': CircuitStatusChoices.STATUS_DECOMMISSIONED,
+            'description': 'New description',
+            'comments': 'New comments',
+        }
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'], EXEMPT_EXCLUDE_MODELS=[])
+    def test_bulk_import_objects_with_terminations(self):
+        interfaces = Interface.objects.filter(type=InterfaceTypeChoices.TYPE_VIRTUAL)
+        json_data = f"""
+            [
+              {{
+                "cid": "Virtual Circuit 7",
+                "provider_network": "Provider Network 1",
+                "status": "active",
+                "terminations": [
+                  {{
+                    "role": "hub",
+                    "interface": {interfaces[0].pk}
+                  }},
+                  {{
+                    "role": "spoke",
+                    "interface": {interfaces[1].pk}
+                  }},
+                  {{
+                    "role": "spoke",
+                    "interface": {interfaces[2].pk}
+                  }}
+                ]
+              }}
+            ]
+        """
+
+        initial_count = self._get_queryset().count()
+        data = {
+            'data': json_data,
+            'format': ImportFormatChoices.JSON,
+        }
+
+        # Assign model-level permission
+        obj_perm = ObjectPermission(
+            name='Test permission',
+            actions=['add']
+        )
+        obj_perm.save()
+        obj_perm.users.add(self.user)
+        obj_perm.object_types.add(ObjectType.objects.get_for_model(self.model))
+
+        # Try GET with model-level permission
+        self.assertHttpStatus(self.client.get(self._get_url('import')), 200)
+
+        # Test POST with permission
+        self.assertHttpStatus(self.client.post(self._get_url('import'), data), 302)
+        self.assertEqual(self._get_queryset().count(), initial_count + 1)
+
+
+class VirtualCircuitTerminationTestCase(ViewTestCases.PrimaryObjectViewTestCase):
+    model = VirtualCircuitTermination
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+
+        devices = (
+            Device(site=site, name='hub', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke1', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke2', device_type=device_type, role=device_role),
+            Device(site=site, name='spoke3', device_type=device_type, role=device_role),
+        )
+        Device.objects.bulk_create(devices)
+
+        physical_interfaces = (
+            Interface(device=devices[0], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[1], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[2], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+            Interface(device=devices[3], name='eth0', type=InterfaceTypeChoices.TYPE_1GE_FIXED),
+        )
+        Interface.objects.bulk_create(physical_interfaces)
+
+        virtual_interfaces = (
+            # Point-to-point VCs
+            Interface(
+                device=devices[0],
+                name='eth0.1',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.2',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[0],
+                name='eth0.3',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.1',
+                parent=physical_interfaces[1],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.1',
+                parent=physical_interfaces[2],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.1',
+                parent=physical_interfaces[3],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+
+            # Hub and spoke VCs
+            Interface(
+                device=devices[0],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[1],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[2],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+            Interface(
+                device=devices[3],
+                name='eth0.9',
+                parent=physical_interfaces[0],
+                type=InterfaceTypeChoices.TYPE_VIRTUAL
+            ),
+        )
+        Interface.objects.bulk_create(virtual_interfaces)
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(provider=provider, name='Provider Network 1')
+        provider_account = ProviderAccount.objects.create(provider=provider, account='Provider Account 1')
+
+        virtual_circuits = (
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 1'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 2'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 3'
+            ),
+            VirtualCircuit(
+                provider_network=provider_network,
+                provider_account=provider_account,
+                cid='Virtual Circuit 4'
+            ),
+        )
+        VirtualCircuit.objects.bulk_create(virtual_circuits)
+
+        virtual_circuit_terminations = (
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[0]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[0],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[3]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[1]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[1],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[4]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[2]
+            ),
+            VirtualCircuitTermination(
+                virtual_circuit=virtual_circuits[2],
+                role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+                interface=virtual_interfaces[5]
+            ),
+        )
+        VirtualCircuitTermination.objects.bulk_create(virtual_circuit_terminations)
+
+        cls.form_data = {
+            'virtual_circuit': virtual_circuits[3].pk,
+            'role': VirtualCircuitTerminationRoleChoices.ROLE_HUB,
+            'interface': virtual_interfaces[6].pk
+        }
+
+        cls.csv_data = (
+            "virtual_circuit,role,interface,description",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_HUB},{virtual_interfaces[6].pk},Hub",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[7].pk},Spoke 1",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[8].pk},Spoke 2",
+            f"Virtual Circuit 4,{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},{virtual_interfaces[9].pk},Spoke 3",
+        )
+
+        cls.csv_update_data = (
+            "id,role,description",
+            f"{virtual_circuit_terminations[0].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
+            f"{virtual_circuit_terminations[1].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
+            f"{virtual_circuit_terminations[2].pk},{VirtualCircuitTerminationRoleChoices.ROLE_SPOKE},New description",
+        )
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }

+ 16 - 0
netbox/circuits/urls.py

@@ -70,4 +70,20 @@ urlpatterns = [
     path('circuit-group-assignments/edit/', views.CircuitGroupAssignmentBulkEditView.as_view(), name='circuitgroupassignment_bulk_edit'),
     path('circuit-group-assignments/delete/', views.CircuitGroupAssignmentBulkDeleteView.as_view(), name='circuitgroupassignment_bulk_delete'),
     path('circuit-group-assignments/<int:pk>/', include(get_model_urls('circuits', 'circuitgroupassignment'))),
+
+    # Virtual circuits
+    path('virtual-circuits/', views.VirtualCircuitListView.as_view(), name='virtualcircuit_list'),
+    path('virtual-circuits/add/', views.VirtualCircuitEditView.as_view(), name='virtualcircuit_add'),
+    path('virtual-circuits/import/', views.VirtualCircuitBulkImportView.as_view(), name='virtualcircuit_import'),
+    path('virtual-circuits/edit/', views.VirtualCircuitBulkEditView.as_view(), name='virtualcircuit_bulk_edit'),
+    path('virtual-circuits/delete/', views.VirtualCircuitBulkDeleteView.as_view(), name='virtualcircuit_bulk_delete'),
+    path('virtual-circuits/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuit'))),
+
+    # Virtual circuit terminations
+    path('virtual-circuit-terminations/', views.VirtualCircuitTerminationListView.as_view(), name='virtualcircuittermination_list'),
+    path('virtual-circuit-terminations/add/', views.VirtualCircuitTerminationEditView.as_view(), name='virtualcircuittermination_add'),
+    path('virtual-circuit-terminations/import/', views.VirtualCircuitTerminationBulkImportView.as_view(), name='virtualcircuittermination_import'),
+    path('virtual-circuit-terminations/edit/', views.VirtualCircuitTerminationBulkEditView.as_view(), name='virtualcircuittermination_bulk_edit'),
+    path('virtual-circuit-terminations/delete/', views.VirtualCircuitTerminationBulkDeleteView.as_view(), name='virtualcircuittermination_bulk_delete'),
+    path('virtual-circuit-terminations/<int:pk>/', include(get_model_urls('circuits', 'virtualcircuittermination'))),
 ]

+ 106 - 0
netbox/circuits/views.py

@@ -537,3 +537,109 @@ class CircuitGroupAssignmentBulkDeleteView(generic.BulkDeleteView):
     queryset = CircuitGroupAssignment.objects.all()
     filterset = filtersets.CircuitGroupAssignmentFilterSet
     table = tables.CircuitGroupAssignmentTable
+
+
+#
+# Virtual circuits
+#
+
+class VirtualCircuitListView(generic.ObjectListView):
+    queryset = VirtualCircuit.objects.annotate(
+        termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
+    )
+    filterset = filtersets.VirtualCircuitFilterSet
+    filterset_form = forms.VirtualCircuitFilterForm
+    table = tables.VirtualCircuitTable
+
+
+@register_model_view(VirtualCircuit)
+class VirtualCircuitView(generic.ObjectView):
+    queryset = VirtualCircuit.objects.all()
+
+
+@register_model_view(VirtualCircuit, 'edit')
+class VirtualCircuitEditView(generic.ObjectEditView):
+    queryset = VirtualCircuit.objects.all()
+    form = forms.VirtualCircuitForm
+
+
+@register_model_view(VirtualCircuit, 'delete')
+class VirtualCircuitDeleteView(generic.ObjectDeleteView):
+    queryset = VirtualCircuit.objects.all()
+
+
+class VirtualCircuitBulkImportView(generic.BulkImportView):
+    queryset = VirtualCircuit.objects.all()
+    model_form = forms.VirtualCircuitImportForm
+    additional_permissions = [
+        'circuits.add_virtualcircuittermination',
+    ]
+    related_object_forms = {
+        'terminations': forms.VirtualCircuitTerminationImportRelatedForm,
+    }
+
+    def prep_related_object_data(self, parent, data):
+        data.update({'virtual_circuit': parent})
+        return data
+
+
+class VirtualCircuitBulkEditView(generic.BulkEditView):
+    queryset = VirtualCircuit.objects.annotate(
+        termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
+    )
+    filterset = filtersets.VirtualCircuitFilterSet
+    table = tables.VirtualCircuitTable
+    form = forms.VirtualCircuitBulkEditForm
+
+
+class VirtualCircuitBulkDeleteView(generic.BulkDeleteView):
+    queryset = VirtualCircuit.objects.annotate(
+        termination_count=count_related(VirtualCircuitTermination, 'virtual_circuit')
+    )
+    filterset = filtersets.VirtualCircuitFilterSet
+    table = tables.VirtualCircuitTable
+
+
+#
+# Virtual circuit terminations
+#
+
+class VirtualCircuitTerminationListView(generic.ObjectListView):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = filtersets.VirtualCircuitTerminationFilterSet
+    filterset_form = forms.VirtualCircuitTerminationFilterForm
+    table = tables.VirtualCircuitTerminationTable
+
+
+@register_model_view(VirtualCircuitTermination)
+class VirtualCircuitTerminationView(generic.ObjectView):
+    queryset = VirtualCircuitTermination.objects.all()
+
+
+@register_model_view(VirtualCircuitTermination, 'edit')
+class VirtualCircuitTerminationEditView(generic.ObjectEditView):
+    queryset = VirtualCircuitTermination.objects.all()
+    form = forms.VirtualCircuitTerminationForm
+
+
+@register_model_view(VirtualCircuitTermination, 'delete')
+class VirtualCircuitTerminationDeleteView(generic.ObjectDeleteView):
+    queryset = VirtualCircuitTermination.objects.all()
+
+
+class VirtualCircuitTerminationBulkImportView(generic.BulkImportView):
+    queryset = VirtualCircuitTermination.objects.all()
+    model_form = forms.VirtualCircuitTerminationImportForm
+
+
+class VirtualCircuitTerminationBulkEditView(generic.BulkEditView):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = filtersets.VirtualCircuitTerminationFilterSet
+    table = tables.VirtualCircuitTerminationTable
+    form = forms.VirtualCircuitTerminationBulkEditForm
+
+
+class VirtualCircuitTerminationBulkDeleteView(generic.BulkDeleteView):
+    queryset = VirtualCircuitTermination.objects.all()
+    filterset = filtersets.VirtualCircuitTerminationFilterSet
+    table = tables.VirtualCircuitTerminationTable

+ 11 - 1
netbox/dcim/filtersets.py

@@ -4,7 +4,7 @@ from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 
-from circuits.models import CircuitTermination
+from circuits.models import CircuitTermination, VirtualCircuit, VirtualCircuitTermination
 from extras.filtersets import LocalConfigContextFilterSet
 from extras.models import ConfigTemplate
 from ipam.filtersets import PrimaryIPFilterSet
@@ -1842,6 +1842,16 @@ class InterfaceFilterSet(
         queryset=WirelessLink.objects.all(),
         label=_('Wireless link')
     )
+    virtual_circuit_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit_termination__virtual_circuit',
+        queryset=VirtualCircuit.objects.all(),
+        label=_('Virtual circuit (ID)'),
+    )
+    virtual_circuit_termination_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='virtual_circuit_termination',
+        queryset=VirtualCircuitTermination.objects.all(),
+        label=_('Virtual circuit termination (ID)'),
+    )
 
     class Meta:
         model = Interface

+ 8 - 0
netbox/dcim/models/device_components.py

@@ -998,6 +998,14 @@ class Interface(ModularComponentModel, BaseInterface, CabledObjectModel, PathEnd
     def l2vpn_termination(self):
         return self.l2vpn_terminations.first()
 
+    @cached_property
+    def connected_endpoints(self):
+        # If this is a virtual interface, return the remote endpoint of the connected
+        # virtual circuit, if any.
+        if self.is_virtual and hasattr(self, 'virtual_circuit_termination'):
+            return self.virtual_circuit_termination.peer_terminations
+        return super().connected_endpoints
+
 
 #
 # Pass-through ports

+ 8 - 0
netbox/dcim/tables/devices.py

@@ -649,6 +649,14 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
         url_name='dcim:interface_list'
     )
 
+    # Override PathEndpointTable.connection to accommodate virtual circuits
+    connection = columns.TemplateColumn(
+        accessor='_path__destinations',
+        template_code=INTERFACE_LINKTERMINATION,
+        verbose_name=_('Connection'),
+        orderable=False
+    )
+
     class Meta(DeviceComponentTable.Meta):
         model = models.Interface
         fields = (

+ 14 - 0
netbox/dcim/tables/template_code.py

@@ -10,6 +10,20 @@ LINKTERMINATION = """
 {% endfor %}
 """
 
+INTERFACE_LINKTERMINATION = """
+{% load i18n %}
+{% if record.is_virtual and record.virtual_circuit_termination %}
+  {% for termination in record.connected_endpoints %}
+    <a href="{{ termination.interface.parent_object.get_absolute_url }}">{{ termination.interface.parent_object }}</a>
+    <i class="mdi mdi-chevron-right"></i>
+    <a href="{{ termination.interface.get_absolute_url }}">{{ termination.interface }}</a>
+    {% trans "via" %}
+    <a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
+    {% if not forloop.last %}<br />{% endif %}
+  {% endfor %}
+{% else %}""" + LINKTERMINATION + """{% endif %}
+"""
+
 CABLE_LENGTH = """
 {% load helpers %}
 {% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}

+ 2 - 0
netbox/extras/tests/test_filtersets.py

@@ -1168,6 +1168,8 @@ class TagTestCase(TestCase, ChangeLoggedFilterSetTests):
         'tunnelgroup',
         'tunneltermination',
         'virtualchassis',
+        'virtualcircuit',
+        'virtualcircuittermination',
         'virtualdevicecontext',
         'virtualdisk',
         'virtualmachine',

+ 7 - 0
netbox/netbox/navigation/menu.py

@@ -284,6 +284,13 @@ CIRCUITS_MENU = Menu(
                 get_model_item('circuits', 'circuittermination', _('Circuit Terminations')),
             ),
         ),
+        MenuGroup(
+            label=_('Virtual Circuits'),
+            items=(
+                get_model_item('circuits', 'virtualcircuit', _('Virtual Circuits')),
+                get_model_item('circuits', 'virtualcircuittermination', _('Virtual Circuit Terminations')),
+            ),
+        ),
         MenuGroup(
             label=_('Providers'),
             items=(

+ 13 - 0
netbox/templates/circuits/providernetwork.html

@@ -50,6 +50,19 @@
         <h2 class="card-header">{% trans "Circuits" %}</h2>
         {% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %}
       </div>
+      <div class="card">
+        <h2 class="card-header">
+          {% trans "Virtual Circuits" %}
+          {% if perms.circuits.add_virtualcircuit %}
+            <div class="card-actions">
+              <a href="{% url 'circuits:virtualcircuit_add' %}?provider_network={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
+                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Virtual Circuit" %}
+              </a>
+            </div>
+          {% endif %}
+        </h2>
+        {% htmx_table 'circuits:virtualcircuit_list' provider_network_id=object.pk %}
+      </div>
       {% plugin_full_width_page object %}
     </div>
   </div>

+ 84 - 0
netbox/templates/circuits/virtualcircuit.html

@@ -0,0 +1,84 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_id={{ object.provider.pk }}">{{ object.provider }}</a>
+  </li>
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.provider_network.pk }}">{{ object.provider_network }}</a>
+  </li>
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Virtual circuit" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Provider" %}</th>
+            <td>{{ object.provider|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider Network" %}</th>
+            <td>{{ object.provider_network|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider account" %}</th>
+            <td>{{ object.provider_account|linkify|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Circuit ID" %}</th>
+            <td>{{ object.cid }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Status" %}</th>
+            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Tenant" %}</th>
+            <td>
+              {% if object.tenant.group %}
+                {{ object.tenant.group|linkify }} /
+              {% endif %}
+              {{ object.tenant|linkify|placeholder }}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      <div class="card">
+        <h2 class="card-header">
+          {% trans "Terminations" %}
+          {% if perms.circuits.add_virtualcircuittermination %}
+            <div class="card-actions">
+              <a href="{% url 'circuits:virtualcircuittermination_add' %}?virtual_circuit={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
+                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Termination" %}
+              </a>
+            </div>
+          {% endif %}
+        </h2>
+        {% htmx_table 'circuits:virtualcircuittermination_list' virtual_circuit_id=object.pk %}
+      </div>
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 81 - 0
netbox/templates/circuits/virtualcircuittermination.html

@@ -0,0 +1,81 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_id={{ object.virtual_circuit.provider.pk }}">{{ object.virtual_circuit.provider }}</a>
+  </li>
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.virtual_circuit.provider_network.pk }}">{{ object.virtual_circuit.provider_network }}</a>
+  </li>
+  <li class="breadcrumb-item">
+    <a href="{% url 'circuits:virtualcircuittermination_list' %}?virtual_circuit_id={{ object.virtual_circuit.pk }}">{{ object.virtual_circuit }}</a>
+  </li>
+{% endblock %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Virtual Circuit Termination" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Provider" %}</th>
+            <td>{{ object.virtual_circuit.provider|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider Network" %}</th>
+            <td>{{ object.virtual_circuit.provider_network|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Provider account" %}</th>
+            <td>{{ object.virtual_circuit.provider_account|linkify|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Virtual circuit" %}</th>
+            <td>{{ object.virtual_circuit|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Role" %}</th>
+            <td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
+          </tr>
+        </table>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h2 class="card-header">{% trans "Interface" %}</h2>
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">{% trans "Device" %}</th>
+            <td>{{ object.interface.device|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Interface" %}</th>
+            <td>{{ object.interface|linkify }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Type" %}</th>
+            <td>{{ object.interface.get_type_display }}</td>
+          </tr>
+          <tr>
+            <th scope="row">{% trans "Description" %}</th>
+            <td>{{ object.interface.description|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

+ 35 - 1
netbox/templates/dcim/interface.html

@@ -152,7 +152,41 @@
           </tr>
         </table>
       </div>
-      {% if not object.is_virtual %}
+      {% if object.is_virtual and object.virtual_circuit_termination %}
+        <div class="card">
+          <h2 class="card-header">{% trans "Virtual Circuit" %}</h2>
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">{% trans "Provider" %}</th>
+              <td>{{ object.virtual_circuit_termination.virtual_circuit.provider|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Provider Network" %}</th>
+              <td>{{ object.virtual_circuit_termination.virtual_circuit.provider_network|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Circuit ID" %}</th>
+              <td>{{ object.virtual_circuit_termination.virtual_circuit|linkify }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Role" %}</th>
+              <td>{{ object.virtual_circuit_termination.get_role_display }}</td>
+            </tr>
+            <tr>
+              <th scope="row">{% trans "Connections" %}</th>
+              <td>
+                {% for termination in object.virtual_circuit_termination.peer_terminations %}
+                  <a href="{{ termination.interface.parent_object.get_absolute_url }}">{{ termination.interface.parent_object }}</a>
+                  <i class="mdi mdi-chevron-right"></i>
+                  <a href="{{ termination.interface.get_absolute_url }}">{{ termination.interface }}</a>
+                  ({{ termination.get_role_display }})
+                  {% if not forloop.last %}<br />{% endif %}
+                {% endfor %}
+              </td>
+            </tr>
+          </table>
+        </div>
+      {% elif not object.is_virtual %}
         <div class="card">
           <h2 class="card-header">{% trans "Connection" %}</h2>
           {% if object.mark_connected %}