Closes #6071: Cable traces now traverse circuits

This commit is contained in:
Jeremy Stretch 2021-04-01 14:31:10 -04:00
parent d57222328b
commit 96759af86f
17 changed files with 302 additions and 79 deletions

View File

@ -112,6 +112,7 @@ A new provider network model has been introduced to represent the boundary of a
* [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields * [#5990](https://github.com/netbox-community/netbox/issues/5990) - Deprecated `display_field` parameter for custom script ObjectVar and MultiObjectVar fields
* [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead) * [#5995](https://github.com/netbox-community/netbox/issues/5995) - Dropped backward compatibility for `queryset` parameter on ObjectVar and MultiObjectVar (use `model` instead)
* [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view * [#6014](https://github.com/netbox-community/netbox/issues/6014) - Moved the virtual machine interfaces list to a separate view
* [#6071](https://github.com/netbox-community/netbox/issues/6071) - Cable traces now traverse circuits
### REST API Changes ### REST API Changes
@ -131,6 +132,7 @@ A new provider network model has been introduced to represent the boundary of a
* The `/dcim/rack-groups/` endpoint is now `/dcim/locations/` * The `/dcim/rack-groups/` endpoint is now `/dcim/locations/`
* circuits.CircuitTermination * circuits.CircuitTermination
* Added the `provider_network` field * Added the `provider_network` field
* Removed the `connected_endpoint`, `connected_endpoint_type`, and `connected_endpoint_reachable` fields
* circuits.ProviderNetwork * circuits.ProviderNetwork
* Added the `/api/circuits/provider-networks/` endpoint * Added the `/api/circuits/provider-networks/` endpoint
* dcim.Device * dcim.Device

View File

@ -60,7 +60,7 @@ class CircuitTypeSerializer(OrganizationalModelSerializer):
] ]
class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEndpointSerializer): class CircuitCircuitTerminationSerializer(WritableNestedSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
site = NestedSiteSerializer() site = NestedSiteSerializer()
provider_network = NestedProviderNetworkSerializer() provider_network = NestedProviderNetworkSerializer()
@ -69,7 +69,6 @@ class CircuitCircuitTerminationSerializer(WritableNestedSerializer, ConnectedEnd
model = CircuitTermination model = CircuitTermination
fields = [ fields = [
'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id', 'id', 'url', 'display', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'xconnect_id',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable',
] ]
@ -91,7 +90,7 @@ class CircuitSerializer(PrimaryModelSerializer):
] ]
class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer): class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail') url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer() circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False) site = NestedSiteSerializer(required=False)
@ -103,5 +102,5 @@ class CircuitTerminationSerializer(BaseModelSerializer, CableTerminationSerializ
fields = [ fields = [
'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed', 'id', 'url', 'display', 'circuit', 'term_side', 'site', 'provider_network', 'port_speed', 'upstream_speed',
'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'xconnect_id', 'pp_info', 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', '_occupied', '_occupied',
] ]

View File

@ -1,4 +1,3 @@
from django.db.models import Prefetch
from rest_framework.routers import APIRootView from rest_framework.routers import APIRootView
from circuits import filters from circuits import filters
@ -60,7 +59,7 @@ class CircuitViewSet(CustomFieldModelViewSet):
class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet): class CircuitTerminationViewSet(PathEndpointMixin, ModelViewSet):
queryset = CircuitTermination.objects.prefetch_related( queryset = CircuitTermination.objects.prefetch_related(
'circuit', 'site', '_path__destination', 'cable' 'circuit', 'site', 'provider_network', 'cable'
) )
serializer_class = serializers.CircuitTerminationSerializer serializer_class = serializers.CircuitTerminationSerializer
filterset_class = filters.CircuitTerminationFilterSet filterset_class = filters.CircuitTerminationFilterSet

View File

@ -207,7 +207,7 @@ class CircuitFilterSet(BaseFilterSet, CustomFieldModelFilterSet, TenancyFilterSe
).distinct() ).distinct()
class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet, PathEndpointFilterSet): class CircuitTerminationFilterSet(BaseFilterSet, CableTerminationFilterSet):
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method='search',
label='Search', label='Search',

View File

@ -0,0 +1,32 @@
from django.db import migrations
from django.db.models import Q
def delete_obsolete_cablepaths(apps, schema_editor):
"""
Delete all CablePath instances which originate or terminate at a CircuitTermination.
"""
ContentType = apps.get_model('contenttypes', 'ContentType')
CircuitTermination = apps.get_model('circuits', 'CircuitTermination')
CablePath = apps.get_model('dcim', 'CablePath')
ct = ContentType.objects.get_for_model(CircuitTermination)
CablePath.objects.filter(Q(origin_type=ct) | Q(destination_type=ct)).delete()
class Migration(migrations.Migration):
dependencies = [
('circuits', '0028_cache_circuit_terminations'),
]
operations = [
migrations.RemoveField(
model_name='circuittermination',
name='_path',
),
migrations.RunPython(
code=delete_obsolete_cablepaths,
reverse_code=migrations.RunPython.noop
),
]

View File

@ -294,7 +294,7 @@ class Circuit(PrimaryModel):
@extras_features('webhooks') @extras_features('webhooks')
class CircuitTermination(ChangeLoggedModel, PathEndpoint, CableTermination): class CircuitTermination(ChangeLoggedModel, CableTermination):
circuit = models.ForeignKey( circuit = models.ForeignKey(
to='circuits.Circuit', to='circuits.Circuit',
on_delete=models.CASCADE, on_delete=models.CASCADE,

View File

@ -381,12 +381,6 @@ class CircuitTerminationTestCase(TestCase):
params = {'cabled': True} params = {'cabled': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
params = {'connected': False}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7)
class ProviderNetworkTestCase(TestCase): class ProviderNetworkTestCase(TestCase):
queryset = ProviderNetwork.objects.all() queryset = ProviderNetwork.objects.all()

View File

@ -219,8 +219,6 @@ class CircuitView(generic.ObjectView):
).filter( ).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_A
).first() ).first()
if termination_a and termination_a.connected_endpoint and hasattr(termination_a.connected_endpoint, 'ip_addresses'):
termination_a.ip_addresses = termination_a.connected_endpoint.ip_addresses.restrict(request.user, 'view')
# Z-side termination # Z-side termination
termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related( termination_z = CircuitTermination.objects.restrict(request.user, 'view').prefetch_related(
@ -228,8 +226,6 @@ class CircuitView(generic.ObjectView):
).filter( ).filter(
circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z circuit=instance, term_side=CircuitTerminationSideChoices.SIDE_Z
).first() ).first()
if termination_z and termination_z.connected_endpoint and hasattr(termination_z.connected_endpoint, 'ip_addresses'):
termination_z.ip_addresses = termination_z.connected_endpoint.ip_addresses.restrict(request.user, 'view')
return { return {
'termination_a': termination_a, 'termination_a': termination_a,

View File

@ -2,12 +2,10 @@ from django.core.management.base import BaseCommand
from django.core.management.color import no_style from django.core.management.color import no_style
from django.db import connection from django.db import connection
from circuits.models import CircuitTermination
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath from dcim.signals import create_cablepath
ENDPOINT_MODELS = ( ENDPOINT_MODELS = (
CircuitTermination,
ConsolePort, ConsolePort,
ConsoleServerPort, ConsoleServerPort,
Interface, Interface,

View File

@ -394,6 +394,8 @@ class CablePath(BigIDModel):
""" """
Create a new CablePath instance as traced from the given path origin. Create a new CablePath instance as traced from the given path origin.
""" """
from circuits.models import CircuitTermination
if origin is None or origin.cable is None: if origin is None or origin.cable is None:
return None return None
@ -441,6 +443,23 @@ class CablePath(BigIDModel):
# No corresponding FrontPort found for the RearPort # No corresponding FrontPort found for the RearPort
break break
# Follow a CircuitTermination to its corresponding CircuitTermination (A to Z or vice versa)
elif isinstance(peer_termination, CircuitTermination):
path.append(object_to_path_node(peer_termination))
# Get peer CircuitTermination
node = peer_termination.get_peer_termination()
if node:
path.append(object_to_path_node(node))
if node.provider_network:
destination = node.provider_network
break
elif node.site and not node.cable:
destination = node.site
break
else:
# No peer CircuitTermination exists; halt the trace
break
# Anything else marks the end of the path # Anything else marks the end of the path
else: else:
destination = peer_termination destination = peer_termination
@ -486,15 +505,26 @@ class CablePath(BigIDModel):
return path return path
def get_cable_ids(self):
"""
Return all Cable IDs within the path.
"""
cable_ct = ContentType.objects.get_for_model(Cable).pk
cable_ids = []
for node in self.path:
ct, id = decompile_path_node(node)
if ct == cable_ct:
cable_ids.append(id)
return cable_ids
def get_total_length(self): def get_total_length(self):
""" """
Return a tuple containing the sum of the length of each cable in the path Return a tuple containing the sum of the length of each cable in the path
and a flag indicating whether the length is definitive. and a flag indicating whether the length is definitive.
""" """
cable_ids = [ cable_ids = self.get_cable_ids()
# Starting from the first element, every third element in the path should be a Cable
decompile_path_node(self.path[i])[1] for i in range(0, len(self.path), 3)
]
cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False) cables = Cable.objects.filter(id__in=cable_ids, _abs_length__isnull=False)
total_length = cables.aggregate(total=Sum('_abs_length'))['total'] total_length = cables.aggregate(total=Sum('_abs_length'))['total']
is_definitive = len(cables) == len(cable_ids) is_definitive = len(cables) == len(cable_ids)

View File

@ -160,7 +160,7 @@ class CableTermination(models.Model):
class PathEndpoint(models.Model): class PathEndpoint(models.Model):
""" """
An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically, An abstract model inherited by any CableTermination subclass which represents the end of a CablePath; specifically,
these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, PowerFeed, and CircuitTermination. these include ConsolePort, ConsoleServerPort, PowerPort, PowerOutlet, Interface, and PowerFeed.
`_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in `_path` references the CablePath originating from this instance, if any. It is set or cleared by the receivers in
dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the dcim.signals in response to changes in the cable path, and complements the `origin` GenericForeignKey field on the
@ -184,10 +184,11 @@ class PathEndpoint(models.Model):
# Construct the complete path # Construct the complete path
path = [self, *self._path.get_path()] path = [self, *self._path.get_path()]
while (len(path) + 1) % 3: if self._path.destination:
path.append(self._path.destination)
while len(path) % 3:
# Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort) # Pad to ensure we have complete three-tuples (e.g. for paths that end at a RearPort)
path.append(None) path.insert(-1, None)
path.append(self._path.destination)
# Return the path as a list of three-tuples (A termination, cable, B termination) # Return the path as a list of three-tuples (A termination, cable, B termination)
return list(zip(*[iter(path)] * 3)) return list(zip(*[iter(path)] * 3))

View File

@ -1,10 +1,12 @@
CABLETERMINATION = """ CABLETERMINATION = """
{% if value %} {% if value %}
{% if value.parent_object %}
<a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a> <a href="{{ value.parent_object.get_absolute_url }}">{{ value.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i> <i class="mdi mdi-chevron-right"></i>
<a href="{{ value.get_absolute_url }}">{{ value }}</a> {% endif %}
<a href="{{ value.get_absolute_url }}">{{ value }}</a>
{% else %} {% else %}
&mdash; &mdash;
{% endif %} {% endif %}
""" """

View File

@ -229,40 +229,6 @@ class CablePathTestCase(TestCase):
# Check that all CablePaths have been deleted # Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0) self.assertEqual(CablePath.objects.count(), 0)
def test_105_interface_to_circuittermination(self):
"""
[IF1] --C1-- [CT1A]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
path1 = self.assertPathExists(
origin=interface1,
destination=circuittermination1,
path=(cable1,),
is_active=True
)
path2 = self.assertPathExists(
origin=circuittermination1,
destination=interface1,
path=(cable1,),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
interface1.refresh_from_db()
circuittermination1.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsSet(circuittermination1, path2)
# Delete cable 1
cable1.delete()
# Check that all CablePaths have been deleted
self.assertEqual(CablePath.objects.count(), 0)
def test_201_single_path_via_pass_through(self): def test_201_single_path_via_pass_through(self):
""" """
[IF1] --C1-- [FP1] [RP1] --C2-- [IF2] [IF1] --C1-- [FP1] [RP1] --C2-- [IF2]
@ -820,6 +786,213 @@ class CablePathTestCase(TestCase):
) )
self.assertEqual(CablePath.objects.count(), 1) self.assertEqual(CablePath.objects.count(), 1)
def test_208_circuittermination(self):
"""
[IF1] --C1-- [CT1]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
# Check for incomplete path
self.assertPathExists(
origin=interface1,
destination=None,
path=(cable1, circuittermination1),
is_active=False
)
self.assertEqual(CablePath.objects.count(), 1)
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
interface1.refresh_from_db()
self.assertPathIsNotSet(interface1)
def test_209_circuit_to_interface(self):
"""
[IF1] --C1-- [CT1] [CT2] --C2-- [IF2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
# Create cables
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
cable2 = Cable(termination_a=circuittermination2, termination_b=interface2)
cable2.save()
# Check for paths
self.assertPathExists(
origin=interface1,
destination=interface2,
path=(cable1, circuittermination1, circuittermination2, cable2),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface1,
path=(cable2, circuittermination2, circuittermination1, cable1),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 2)
# Delete cable 2
cable2.delete()
path1 = self.assertPathExists(
origin=interface1,
destination=self.site,
path=(cable1, circuittermination1, circuittermination2),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
interface1.refresh_from_db()
interface2.refresh_from_db()
self.assertPathIsSet(interface1, path1)
self.assertPathIsNotSet(interface2)
def test_210_circuit_to_site(self):
"""
[IF1] --C1-- [CT1] [CT2] --> [Site2]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
site2 = Site.objects.create(name='Site 2', slug='site-2')
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=site2, term_side='Z')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=site2,
path=(cable1, circuittermination1, circuittermination2),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
interface1.refresh_from_db()
self.assertPathIsNotSet(interface1)
def test_211_circuit_to_providernetwork(self):
"""
[IF1] --C1-- [CT1] [CT2] --> [PN1]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
providernetwork = ProviderNetwork.objects.create(name='Provider Network 1', provider=self.circuit.provider)
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, provider_network=providernetwork, term_side='Z')
# Create cable 1
cable1 = Cable(termination_a=interface1, termination_b=circuittermination1)
cable1.save()
self.assertPathExists(
origin=interface1,
destination=providernetwork,
path=(cable1, circuittermination1, circuittermination2),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 1)
# Delete cable 1
cable1.delete()
self.assertEqual(CablePath.objects.count(), 0)
interface1.refresh_from_db()
self.assertPathIsNotSet(interface1)
def test_212_multiple_paths_via_circuit(self):
"""
[IF1] --C1-- [FP1:1] [RP1] --C3-- [CT1] [CT2] --C4-- [RP2] [FP2:1] --C5-- [IF3]
[IF2] --C2-- [FP1:2] [FP2:2] --C6-- [IF4]
"""
interface1 = Interface.objects.create(device=self.device, name='Interface 1')
interface2 = Interface.objects.create(device=self.device, name='Interface 2')
interface3 = Interface.objects.create(device=self.device, name='Interface 3')
interface4 = Interface.objects.create(device=self.device, name='Interface 4')
rearport1 = RearPort.objects.create(device=self.device, name='Rear Port 1', positions=4)
rearport2 = RearPort.objects.create(device=self.device, name='Rear Port 2', positions=4)
frontport1_1 = FrontPort.objects.create(
device=self.device, name='Front Port 1:1', rear_port=rearport1, rear_port_position=1
)
frontport1_2 = FrontPort.objects.create(
device=self.device, name='Front Port 1:2', rear_port=rearport1, rear_port_position=2
)
frontport2_1 = FrontPort.objects.create(
device=self.device, name='Front Port 2:1', rear_port=rearport2, rear_port_position=1
)
frontport2_2 = FrontPort.objects.create(
device=self.device, name='Front Port 2:2', rear_port=rearport2, rear_port_position=2
)
circuittermination1 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='A')
circuittermination2 = CircuitTermination.objects.create(circuit=self.circuit, site=self.site, term_side='Z')
# Create cables
cable1 = Cable(termination_a=interface1, termination_b=frontport1_1) # IF1 -> FP1:1
cable1.save()
cable2 = Cable(termination_a=interface2, termination_b=frontport1_2) # IF2 -> FP1:2
cable2.save()
cable3 = Cable(termination_a=rearport1, termination_b=circuittermination1) # RP1 -> CT1
cable3.save()
cable4 = Cable(termination_a=rearport2, termination_b=circuittermination2) # RP2 -> CT2
cable4.save()
cable5 = Cable(termination_a=interface3, termination_b=frontport2_1) # IF3 -> FP2:1
cable5.save()
cable6 = Cable(termination_a=interface4, termination_b=frontport2_2) # IF4 -> FP2:2
cable6.save()
self.assertPathExists(
origin=interface1,
destination=interface3,
path=(
cable1, frontport1_1, rearport1, cable3, circuittermination1, circuittermination2,
cable4, rearport2, frontport2_1, cable5
),
is_active=True
)
self.assertPathExists(
origin=interface2,
destination=interface4,
path=(
cable2, frontport1_2, rearport1, cable3, circuittermination1, circuittermination2,
cable4, rearport2, frontport2_2, cable6
),
is_active=True
)
self.assertPathExists(
origin=interface3,
destination=interface1,
path=(
cable5, frontport2_1, rearport2, cable4, circuittermination2, circuittermination1,
cable3, rearport1, frontport1_1, cable1
),
is_active=True
)
self.assertPathExists(
origin=interface4,
destination=interface2,
path=(
cable6, frontport2_2, rearport2, cable4, circuittermination2, circuittermination1,
cable3, rearport1, frontport1_2, cable2
),
is_active=True
)
self.assertEqual(CablePath.objects.count(), 4)
# Delete cables 3-4
cable3.delete()
cable4.delete()
# Check for four partial paths; one from each interface
self.assertEqual(CablePath.objects.filter(destination_id__isnull=True).count(), 4)
self.assertEqual(CablePath.objects.filter(destination_id__isnull=False).count(), 0)
def test_301_create_path_via_existing_cable(self): def test_301_create_path_via_existing_cable(self):
""" """
[IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2] [IF1] --C1-- [FP1] [RP2] --C2-- [RP2] [FP2] --C3-- [IF2]

View File

@ -104,21 +104,6 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<td>IP Addressing</td>
<td>
{% if termination.connected_endpoint %}
{% for ip in termination.ip_addresses %}
{% if not forloop.first %}<br />{% endif %}
<a href="{% url 'ipam:ipaddress' pk=ip.pk %}">{{ ip }}</a> ({{ ip.vrf|default:"Global" }})
{% empty %}
<span class="text-muted">None</span>
{% endfor %}
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<td>Cross-Connect</td> <td>Cross-Connect</td>
<td>{{ termination.xconnect_id|placeholder }}</td> <td>{{ termination.xconnect_id|placeholder }}</td>

View File

@ -27,6 +27,8 @@
{# Cable #} {# Cable #}
{% if cable %} {% if cable %}
{% include 'dcim/trace/cable.html' %} {% include 'dcim/trace/cable.html' %}
{% elif far_end %}
{% include 'dcim/trace/attachment.html' %}
{% endif %} {% endif %}
{# Far end #} {# Far end #}
@ -43,6 +45,8 @@
{% if forloop.last %} {% if forloop.last %}
{% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %} {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %}
{% endif %} {% endif %}
{% elif far_end %}
{% include 'dcim/trace/object.html' with object=far_end %}
{% endif %} {% endif %}
{% if forloop.last %} {% if forloop.last %}

View File

@ -0,0 +1,5 @@
{% load helpers %}
<div class="cable" style="border-left-color: #c0c0c0; border-left-style: dashed">
<strong>Attachment</strong>
</div>

View File

@ -0,0 +1,3 @@
<div class="node">
<strong><a href="{{ object.get_absolute_url }}">{{ object }}</a></strong>
</div>