diff --git a/docs/core-functionality/wireless.md b/docs/core-functionality/wireless.md
new file mode 100644
index 00000000000..57133f756c0
--- /dev/null
+++ b/docs/core-functionality/wireless.md
@@ -0,0 +1,8 @@
+# Wireless Networks
+
+{!models/wireless/wirelesslan.md!}
+{!models/wireless/wirelesslangroup.md!}
+
+---
+
+{!models/wireless/wirelesslink.md!}
diff --git a/docs/models/dcim/interface.md b/docs/models/dcim/interface.md
index bd9975a72f3..585674de1ac 100644
--- a/docs/models/dcim/interface.md
+++ b/docs/models/dcim/interface.md
@@ -11,6 +11,17 @@ Interfaces may be physical or virtual in nature, but only physical interfaces ma
Physical interfaces may be arranged into a link aggregation group (LAG) and associated with a parent LAG (virtual) interface. LAG interfaces can be recursively nested to model bonding of trunk groups. Like all virtual interfaces, LAG interfaces cannot be connected physically.
+### Wireless Interfaces
+
+Wireless interfaces may additionally track the following attributes:
+
+* **Role** - AP or station
+* **Channel** - One of several standard wireless channels
+* **Channel Frequency** - The transmit frequency
+* **Channel Width** - Channel bandwidth
+
+If a predefined channel is selected, the frequency and width attributes will be assigned automatically. If no channel is selected, these attributes may be defined manually.
+
### IP Address Assignment
IP addresses can be assigned to interfaces. VLANs can also be assigned to each interface as either tagged or untagged. (An interface may have only one untagged VLAN.)
diff --git a/docs/models/wireless/wirelesslan.md b/docs/models/wireless/wirelesslan.md
new file mode 100644
index 00000000000..80a3a40b0df
--- /dev/null
+++ b/docs/models/wireless/wirelesslan.md
@@ -0,0 +1,11 @@
+# Wireless LANs
+
+A wireless LAN is a set of interfaces connected via a common wireless channel. Each instance must have an SSID, and may optionally be correlated to a VLAN. Wireless LANs can be arranged into hierarchical groups.
+
+An interface may be attached to multiple wireless LANs, provided they are all operating on the same channel. Only wireless interfaces may be attached to wireless LANs.
+
+Each wireless LAN may have authentication attributes associated with it, including:
+
+* Authentication type
+* Cipher
+* Pre-shared key
diff --git a/docs/models/wireless/wirelesslangroup.md b/docs/models/wireless/wirelesslangroup.md
new file mode 100644
index 00000000000..e477abd0b75
--- /dev/null
+++ b/docs/models/wireless/wirelesslangroup.md
@@ -0,0 +1,3 @@
+# Wireless LAN Groups
+
+Wireless LAN groups can be used to organize and classify wireless LANs. These groups are hierarchical: groups can be nested within parent groups. However, each wireless LAN may assigned only to one group.
diff --git a/docs/models/wireless/wirelesslink.md b/docs/models/wireless/wirelesslink.md
new file mode 100644
index 00000000000..85cdbd6d93d
--- /dev/null
+++ b/docs/models/wireless/wirelesslink.md
@@ -0,0 +1,9 @@
+# Wireless Links
+
+A wireless link represents a connection between exactly two wireless interfaces. It may optionally be assigned an SSID and a description. It may also have a status assigned to it, similar to the cable model.
+
+Each wireless link may have authentication attributes associated with it, including:
+
+* Authentication type
+* Cipher
+* Pre-shared key
diff --git a/mkdocs.yml b/mkdocs.yml
index ce660285fc1..001808f0d0d 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -60,6 +60,7 @@ nav:
- Virtualization: 'core-functionality/virtualization.md'
- Service Mapping: 'core-functionality/services.md'
- Circuits: 'core-functionality/circuits.md'
+ - Wireless: 'core-functionality/wireless.md'
- Power Tracking: 'core-functionality/power.md'
- Tenancy: 'core-functionality/tenancy.md'
- Contacts: 'core-functionality/contacts.md'
diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py
index 0033e1425c8..470a0b03090 100644
--- a/netbox/circuits/api/serializers.py
+++ b/netbox/circuits/api/serializers.py
@@ -3,7 +3,7 @@
from circuits.choices import CircuitStatusChoices
from circuits.models import *
from dcim.api.nested_serializers import NestedCableSerializer, NestedSiteSerializer
-from dcim.api.serializers import CableTerminationSerializer
+from dcim.api.serializers import LinkTerminationSerializer
from netbox.api import ChoiceField
from netbox.api.serializers import PrimaryModelSerializer, ValidatedModelSerializer, WritableNestedSerializer
from tenancy.api.nested_serializers import NestedTenantSerializer
@@ -88,7 +88,7 @@ class Meta:
]
-class CircuitTerminationSerializer(ValidatedModelSerializer, CableTerminationSerializer):
+class CircuitTerminationSerializer(ValidatedModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='circuits-api:circuittermination-detail')
circuit = NestedCircuitSerializer()
site = NestedSiteSerializer(required=False, allow_null=True)
@@ -99,6 +99,6 @@ class Meta:
model = CircuitTermination
fields = [
'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', 'link_peer', 'link_peer_type',
'_occupied',
]
diff --git a/netbox/circuits/migrations/0004_rename_cable_peer.py b/netbox/circuits/migrations/0004_rename_cable_peer.py
new file mode 100644
index 00000000000..81d507eb462
--- /dev/null
+++ b/netbox/circuits/migrations/0004_rename_cable_peer.py
@@ -0,0 +1,21 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('circuits', '0003_extend_tag_support'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='circuittermination',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='circuittermination',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ ]
diff --git a/netbox/circuits/models.py b/netbox/circuits/models.py
index e6e03052dc6..089d6cb2d0a 100644
--- a/netbox/circuits/models.py
+++ b/netbox/circuits/models.py
@@ -4,7 +4,7 @@
from django.urls import reverse
from dcim.fields import ASNField
-from dcim.models import CableTermination, PathEndpoint
+from dcim.models import LinkTermination, PathEndpoint
from extras.models import ObjectChange
from extras.utils import extras_features
from netbox.models import BigIDModel, ChangeLoggedModel, OrganizationalModel, PrimaryModel
@@ -256,7 +256,7 @@ def get_status_class(self):
@extras_features('webhooks')
-class CircuitTermination(ChangeLoggedModel, CableTermination):
+class CircuitTermination(ChangeLoggedModel, LinkTermination):
circuit = models.ForeignKey(
to='circuits.Circuit',
on_delete=models.CASCADE,
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index ef4f49247b3..bc5e9b54eac 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -17,28 +17,29 @@
from users.api.nested_serializers import NestedUserSerializer
from utilities.api import get_serializer_for_model
from virtualization.api.nested_serializers import NestedClusterSerializer
+from wireless.choices import *
from .nested_serializers import *
-class CableTerminationSerializer(serializers.ModelSerializer):
- cable_peer_type = serializers.SerializerMethodField(read_only=True)
- cable_peer = serializers.SerializerMethodField(read_only=True)
+class LinkTerminationSerializer(serializers.ModelSerializer):
+ link_peer_type = serializers.SerializerMethodField(read_only=True)
+ link_peer = serializers.SerializerMethodField(read_only=True)
_occupied = serializers.SerializerMethodField(read_only=True)
- def get_cable_peer_type(self, obj):
- if obj._cable_peer is not None:
- return f'{obj._cable_peer._meta.app_label}.{obj._cable_peer._meta.model_name}'
+ def get_link_peer_type(self, obj):
+ if obj._link_peer is not None:
+ return f'{obj._link_peer._meta.app_label}.{obj._link_peer._meta.model_name}'
return None
@swagger_serializer_method(serializer_or_field=serializers.DictField)
- def get_cable_peer(self, obj):
+ def get_link_peer(self, obj):
"""
- Return the appropriate serializer for the cable termination model.
+ Return the appropriate serializer for the link termination model.
"""
- if obj._cable_peer is not None:
- serializer = get_serializer_for_model(obj._cable_peer, prefix='Nested')
+ if obj._link_peer is not None:
+ serializer = get_serializer_for_model(obj._link_peer, prefix='Nested')
context = {'request': self.context['request']}
- return serializer(obj._cable_peer, context=context).data
+ return serializer(obj._link_peer, context=context).data
return None
@swagger_serializer_method(serializer_or_field=serializers.BooleanField)
@@ -503,7 +504,7 @@ class DeviceNAPALMSerializer(serializers.Serializer):
# Device components
#
-class ConsoleServerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsoleServerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -522,12 +523,12 @@ class Meta:
model = ConsoleServerPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
- 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class ConsolePortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class ConsolePortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -546,12 +547,12 @@ class Meta:
model = ConsolePort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'speed', 'description', 'mark_connected',
- 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class PowerOutletSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerOutletSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -575,12 +576,12 @@ class Meta:
model = PowerOutlet
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
- 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class PowerPortSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(
@@ -594,18 +595,20 @@ class Meta:
model = PowerPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
- 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
+ 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type',
'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', '_occupied',
]
-class InterfaceSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class InterfaceSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=InterfaceTypeChoices)
parent = NestedInterfaceSerializer(required=False, allow_null=True)
lag = NestedInterfaceSerializer(required=False, allow_null=True)
mode = ChoiceField(choices=InterfaceModeChoices, allow_blank=True, required=False)
+ rf_role = ChoiceField(choices=WirelessRoleChoices, required=False, allow_null=True)
+ rf_channel = ChoiceField(choices=WirelessChannelChoices, required=False)
untagged_vlan = NestedVLANSerializer(required=False, allow_null=True)
tagged_vlans = SerializedPKRelatedField(
queryset=VLAN.objects.all(),
@@ -620,10 +623,10 @@ class Meta:
model = Interface
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
- 'wwn', 'mgmt_only', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable',
- 'cable_peer', 'cable_peer_type', 'connected_endpoint', 'connected_endpoint_type',
- 'connected_endpoint_reachable', 'tags', 'custom_fields', 'created', 'last_updated', 'count_ipaddresses',
- '_occupied',
+ 'wwn', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width', 'untagged_vlan', 'tagged_vlans', 'mark_connected', 'cable', 'link_peer',
+ 'link_peer_type', 'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags',
+ 'custom_fields', 'created', 'last_updated', 'count_ipaddresses', '_occupied',
]
def validate(self, data):
@@ -640,7 +643,7 @@ def validate(self, data):
return super().validate(data)
-class RearPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
+class RearPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:rearport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@@ -650,7 +653,7 @@ class Meta:
model = RearPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'positions', 'description',
- 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields', 'created',
+ 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields', 'created',
'last_updated', '_occupied',
]
@@ -666,7 +669,7 @@ class Meta:
fields = ['id', 'url', 'display', 'name', 'label']
-class FrontPortSerializer(PrimaryModelSerializer, CableTerminationSerializer):
+class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:frontport-detail')
device = NestedDeviceSerializer()
type = ChoiceField(choices=PortTypeChoices)
@@ -677,7 +680,7 @@ class Meta:
model = FrontPort
fields = [
'id', 'url', 'display', 'device', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
- 'description', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type', 'tags', 'custom_fields',
+ 'description', 'mark_connected', 'cable', 'link_peer', 'link_peer_type', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
@@ -728,7 +731,7 @@ class CableSerializer(PrimaryModelSerializer):
)
termination_a = serializers.SerializerMethodField(read_only=True)
termination_b = serializers.SerializerMethodField(read_only=True)
- status = ChoiceField(choices=CableStatusChoices, required=False)
+ status = ChoiceField(choices=LinkStatusChoices, required=False)
tenant = NestedTenantSerializer(required=False, allow_null=True)
length_unit = ChoiceField(choices=CableLengthUnitChoices, allow_blank=True, required=False)
@@ -853,7 +856,7 @@ class Meta:
fields = ['id', 'url', 'display', 'site', 'location', 'name', 'tags', 'custom_fields', 'powerfeed_count']
-class PowerFeedSerializer(PrimaryModelSerializer, CableTerminationSerializer, ConnectedEndpointSerializer):
+class PowerFeedSerializer(PrimaryModelSerializer, LinkTerminationSerializer, ConnectedEndpointSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerfeed-detail')
power_panel = NestedPowerPanelSerializer()
rack = NestedRackSerializer(
@@ -883,7 +886,7 @@ class Meta:
model = PowerFeed
fields = [
'id', 'url', 'display', 'power_panel', 'rack', 'name', 'status', 'type', 'supply', 'phase', 'voltage',
- 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'cable_peer', 'cable_peer_type',
+ 'amperage', 'max_utilization', 'comments', 'mark_connected', 'cable', 'link_peer', 'link_peer_type',
'connected_endpoint', 'connected_endpoint_type', 'connected_endpoint_reachable', 'tags', 'custom_fields',
'created', 'last_updated', '_occupied',
]
diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py
index 799a5e7034c..9cbdf7d5dfd 100644
--- a/netbox/dcim/api/views.py
+++ b/netbox/dcim/api/views.py
@@ -513,7 +513,7 @@ def napalm(self, request, pk):
#
class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
- queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
+ queryset = ConsolePort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.ConsolePortSerializer
filterset_class = filtersets.ConsolePortFilterSet
brief_prefetch_fields = ['device']
@@ -521,7 +521,7 @@ class ConsolePortViewSet(PathEndpointMixin, ModelViewSet):
class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
queryset = ConsoleServerPort.objects.prefetch_related(
- 'device', '_path__destination', 'cable', '_cable_peer', 'tags'
+ 'device', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.ConsoleServerPortSerializer
filterset_class = filtersets.ConsoleServerPortFilterSet
@@ -529,14 +529,14 @@ class ConsoleServerPortViewSet(PathEndpointMixin, ModelViewSet):
class PowerPortViewSet(PathEndpointMixin, ModelViewSet):
- queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
+ queryset = PowerPort.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.PowerPortSerializer
filterset_class = filtersets.PowerPortFilterSet
brief_prefetch_fields = ['device']
class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
- queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_cable_peer', 'tags')
+ queryset = PowerOutlet.objects.prefetch_related('device', '_path__destination', 'cable', '_link_peer', 'tags')
serializer_class = serializers.PowerOutletSerializer
filterset_class = filtersets.PowerOutletFilterSet
brief_prefetch_fields = ['device']
@@ -544,7 +544,7 @@ class PowerOutletViewSet(PathEndpointMixin, ModelViewSet):
class InterfaceViewSet(PathEndpointMixin, ModelViewSet):
queryset = Interface.objects.prefetch_related(
- 'device', 'parent', 'lag', '_path__destination', 'cable', '_cable_peer', 'ip_addresses', 'tags'
+ 'device', 'parent', 'lag', '_path__destination', 'cable', '_link_peer', 'ip_addresses', 'tags'
)
serializer_class = serializers.InterfaceSerializer
filterset_class = filtersets.InterfaceFilterSet
@@ -625,7 +625,7 @@ class PowerPanelViewSet(ModelViewSet):
class PowerFeedViewSet(PathEndpointMixin, CustomFieldModelViewSet):
queryset = PowerFeed.objects.prefetch_related(
- 'power_panel', 'rack', '_path__destination', 'cable', '_cable_peer', 'tags'
+ 'power_panel', 'rack', '_path__destination', 'cable', '_link_peer', 'tags'
)
serializer_class = serializers.PowerFeedSerializer
filterset_class = filtersets.PowerFeedFilterSet
diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py
index 2a7ed8b8985..9b5363d4c96 100644
--- a/netbox/dcim/choices.py
+++ b/netbox/dcim/choices.py
@@ -1061,7 +1061,7 @@ class PortTypeChoices(ChoiceSet):
#
-# Cables
+# Cables/links
#
class CableTypeChoices(ChoiceSet):
@@ -1125,7 +1125,7 @@ class CableTypeChoices(ChoiceSet):
)
-class CableStatusChoices(ChoiceSet):
+class LinkStatusChoices(ChoiceSet):
STATUS_CONNECTED = 'connected'
STATUS_PLANNED = 'planned'
diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py
index 2a4d368f4e2..0d64b357b0f 100644
--- a/netbox/dcim/constants.py
+++ b/netbox/dcim/constants.py
@@ -42,6 +42,7 @@
InterfaceTypeChoices.TYPE_80211N,
InterfaceTypeChoices.TYPE_80211AC,
InterfaceTypeChoices.TYPE_80211AD,
+ InterfaceTypeChoices.TYPE_80211AX,
]
NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index c66397029c7..f6d8abb0a08 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -14,6 +14,7 @@
TreeNodeMultipleChoiceFilter,
)
from virtualization.models import Cluster
+from wireless.choices import WirelessRoleChoices, WirelessChannelChoices
from .choices import *
from .constants import *
from .models import *
@@ -987,10 +988,19 @@ class InterfaceFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableT
choices=InterfaceTypeChoices,
null_value=None
)
+ rf_role = django_filters.MultipleChoiceFilter(
+ choices=WirelessRoleChoices
+ )
+ rf_channel = django_filters.MultipleChoiceFilter(
+ choices=WirelessChannelChoices
+ )
class Meta:
model = Interface
- fields = ['id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'description']
+ fields = [
+ 'id', 'name', 'label', 'type', 'enabled', 'mtu', 'mgmt_only', 'mode', 'rf_role', 'rf_channel',
+ 'rf_channel_frequency', 'rf_channel_width', 'description',
+ ]
def filter_device(self, queryset, name, value):
try:
@@ -1202,7 +1212,7 @@ class CableFilterSet(TenancyFilterSet, PrimaryModelFilterSet):
choices=CableTypeChoices
)
status = django_filters.MultipleChoiceFilter(
- choices=CableStatusChoices
+ choices=LinkStatusChoices
)
color = django_filters.MultipleChoiceFilter(
choices=ColorChoices
diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py
index d08692c2697..9abdcb8ff09 100644
--- a/netbox/dcim/forms/bulk_edit.py
+++ b/netbox/dcim/forms/bulk_edit.py
@@ -463,7 +463,7 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE
widget=StaticSelect()
)
status = forms.ChoiceField(
- choices=add_blank_choice(CableStatusChoices),
+ choices=add_blank_choice(LinkStatusChoices),
required=False,
widget=StaticSelect(),
initial=''
@@ -940,7 +940,7 @@ def __init__(self, *args, **kwargs):
class InterfaceBulkEditForm(
form_from_model(Interface, [
'label', 'type', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only', 'mark_connected', 'description',
- 'mode',
+ 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width',
]),
BootstrapMixin,
AddRemoveTagsForm,
@@ -991,8 +991,8 @@ class InterfaceBulkEditForm(
class Meta:
nullable_fields = [
- 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'untagged_vlan',
- 'tagged_vlans',
+ 'label', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'description', 'mode', 'rf_channel',
+ 'rf_channel_frequency', 'rf_channel_width', 'untagged_vlan', 'tagged_vlans',
]
def __init__(self, *args, **kwargs):
diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py
index 720ea8dbd57..f39e3cd7fcd 100644
--- a/netbox/dcim/forms/bulk_import.py
+++ b/netbox/dcim/forms/bulk_import.py
@@ -11,6 +11,7 @@
from tenancy.models import Tenant
from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelChoiceField, CSVTypedChoiceField, SlugField
from virtualization.models import Cluster
+from wireless.choices import WirelessRoleChoices
__all__ = (
'CableCSVForm',
@@ -584,12 +585,18 @@ class InterfaceCSVForm(CustomFieldModelCSVForm):
required=False,
help_text='IEEE 802.1Q operational mode (for L2 interfaces)'
)
+ rf_role = CSVChoiceField(
+ choices=WirelessRoleChoices,
+ required=False,
+ help_text='Wireless role (AP/station)'
+ )
class Meta:
model = Interface
fields = (
'device', 'name', 'label', 'parent', 'lag', 'type', 'enabled', 'mark_connected', 'mac_address', 'wwn',
- 'mtu', 'mgmt_only', 'description', 'mode',
+ 'mtu', 'mgmt_only', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width',
)
def __init__(self, *args, **kwargs):
@@ -812,7 +819,7 @@ class CableCSVForm(CustomFieldModelCSVForm):
# Cable attributes
status = CSVChoiceField(
- choices=CableStatusChoices,
+ choices=LinkStatusChoices,
required=False,
help_text='Connection status'
)
diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py
index 0ee08bc77d0..5c776386a7f 100644
--- a/netbox/dcim/forms/filtersets.py
+++ b/netbox/dcim/forms/filtersets.py
@@ -11,6 +11,7 @@
APISelectMultiple, add_blank_choice, BootstrapMixin, ColorField, DynamicModelMultipleChoiceField, StaticSelect,
StaticSelectMultiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES,
)
+from wireless.choices import *
__all__ = (
'CableFilterForm',
@@ -735,7 +736,7 @@ class CableFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilterF
)
status = forms.ChoiceField(
required=False,
- choices=add_blank_choice(CableStatusChoices),
+ choices=add_blank_choice(LinkStatusChoices),
widget=StaticSelect()
)
color = ColorField(
@@ -966,6 +967,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
field_groups = [
['q', 'tag'],
['name', 'label', 'kind', 'type', 'enabled', 'mgmt_only', 'mac_address', 'wwn'],
+ ['rf_role', 'rf_channel', 'rf_channel_width'],
['region_id', 'site_group_id', 'site_id', 'location_id', 'device_id'],
]
kind = forms.MultipleChoiceField(
@@ -998,6 +1000,26 @@ class InterfaceFilterForm(DeviceComponentFilterForm):
required=False,
label='WWN'
)
+ rf_role = forms.MultipleChoiceField(
+ choices=WirelessRoleChoices,
+ required=False,
+ widget=StaticSelectMultiple(),
+ label='Wireless role'
+ )
+ rf_channel = forms.MultipleChoiceField(
+ choices=WirelessChannelChoices,
+ required=False,
+ widget=StaticSelectMultiple(),
+ label='Wireless channel'
+ )
+ rf_channel_frequency = forms.IntegerField(
+ required=False,
+ label='Channel frequency (MHz)'
+ )
+ rf_channel_width = forms.IntegerField(
+ required=False,
+ label='Channel width (MHz)'
+ )
tag = TagFilterField(model)
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index a3dac09dd28..e395c67d278 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -16,6 +16,7 @@
SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup
+from wireless.models import WirelessLAN, WirelessLANGroup
from .common import InterfaceCommonForm
__all__ = (
@@ -1100,6 +1101,19 @@ class InterfaceForm(BootstrapMixin, InterfaceCommonForm, CustomFieldModelForm):
'type': 'lag',
}
)
+ wireless_lan_group = DynamicModelChoiceField(
+ queryset=WirelessLANGroup.objects.all(),
+ required=False,
+ label='Wireless LAN group'
+ )
+ wireless_lans = DynamicModelMultipleChoiceField(
+ queryset=WirelessLAN.objects.all(),
+ required=False,
+ label='Wireless LANs',
+ query_params={
+ 'group_id': '$wireless_lan_group',
+ }
+ )
vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(),
required=False,
@@ -1130,18 +1144,23 @@ class Meta:
model = Interface
fields = [
'device', 'name', 'label', 'type', 'enabled', 'parent', 'lag', 'mac_address', 'wwn', 'mtu', 'mgmt_only',
- 'mark_connected', 'description', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags',
+ 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags',
]
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
'mode': StaticSelect(),
+ 'rf_role': StaticSelect(),
+ 'rf_channel': StaticSelect(),
}
labels = {
'mode': '802.1Q Mode',
}
help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT,
+ 'rf_channel_frequency': "Populated by selected channel (if set)",
+ 'rf_channel_width': "Populated by selected channel (if set)",
}
def __init__(self, *args, **kwargs):
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 7577ad3553f..547fe7e68f5 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -10,6 +10,7 @@
add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
ExpandableNameField, StaticSelect,
)
+from wireless.choices import *
from .common import InterfaceCommonForm
__all__ = (
@@ -465,7 +466,27 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices),
required=False,
+ widget=StaticSelect()
+ )
+ rf_role = forms.ChoiceField(
+ choices=add_blank_choice(WirelessRoleChoices),
+ required=False,
+ widget=StaticSelect(),
+ label='Wireless role'
+ )
+ rf_channel = forms.ChoiceField(
+ choices=add_blank_choice(WirelessChannelChoices),
+ required=False,
widget=StaticSelect(),
+ label='Wireless channel'
+ )
+ rf_channel_frequency = forms.DecimalField(
+ required=False,
+ label='Channel frequency (MHz)'
+ )
+ rf_channel_width = forms.DecimalField(
+ required=False,
+ label='Channel width (MHz)'
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
@@ -477,7 +498,8 @@ class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
)
field_order = (
'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'lag', 'mtu', 'mac_address',
- 'description', 'mgmt_only', 'mark_connected', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
+ 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
+ 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
)
def __init__(self, *args, **kwargs):
diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py
index 80c32e66d3c..39f08ffdc60 100644
--- a/netbox/dcim/graphql/types.py
+++ b/netbox/dcim/graphql/types.py
@@ -212,6 +212,12 @@ class Meta:
def resolve_mode(self, info):
return self.mode or None
+ def resolve_rf_role(self, info):
+ return self.rf_role or None
+
+ def resolve_rf_channel(self, info):
+ return self.rf_channel or None
+
class InterfaceTemplateType(ComponentTemplateObjectType):
diff --git a/netbox/dcim/management/commands/trace_paths.py b/netbox/dcim/management/commands/trace_paths.py
index fd5f9cfab4f..d0cd644868f 100644
--- a/netbox/dcim/management/commands/trace_paths.py
+++ b/netbox/dcim/management/commands/trace_paths.py
@@ -1,6 +1,7 @@
from django.core.management.base import BaseCommand
from django.core.management.color import no_style
from django.db import connection
+from django.db.models import Q
from dcim.models import CablePath, ConsolePort, ConsoleServerPort, Interface, PowerFeed, PowerOutlet, PowerPort
from dcim.signals import create_cablepath
@@ -67,7 +68,10 @@ def handle(self, *model_names, **options):
# Retrace paths
for model in ENDPOINT_MODELS:
- origins = model.objects.filter(cable__isnull=False)
+ params = Q(cable__isnull=False)
+ if hasattr(model, 'wireless_link'):
+ params |= Q(wireless_link__isnull=False)
+ origins = model.objects.filter(params)
if not options['force']:
origins = origins.filter(_path__isnull=True)
origins_count = origins.count()
diff --git a/netbox/dcim/migrations/0139_rename_cable_peer.py b/netbox/dcim/migrations/0139_rename_cable_peer.py
new file mode 100644
index 00000000000..59dc04e2ad8
--- /dev/null
+++ b/netbox/dcim/migrations/0139_rename_cable_peer.py
@@ -0,0 +1,91 @@
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0138_extend_tag_support'),
+ ]
+
+ operations = [
+ migrations.RenameField(
+ model_name='consoleport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='consoleport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='consoleserverport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='consoleserverport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='frontport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='frontport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='interface',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='interface',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='powerfeed',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='powerfeed',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='poweroutlet',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='poweroutlet',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='powerport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='powerport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ migrations.RenameField(
+ model_name='rearport',
+ old_name='_cable_peer_id',
+ new_name='_link_peer_id',
+ ),
+ migrations.RenameField(
+ model_name='rearport',
+ old_name='_cable_peer_type',
+ new_name='_link_peer_type',
+ ),
+ ]
diff --git a/netbox/dcim/migrations/0140_wireless.py b/netbox/dcim/migrations/0140_wireless.py
new file mode 100644
index 00000000000..012b78dd462
--- /dev/null
+++ b/netbox/dcim/migrations/0140_wireless.py
@@ -0,0 +1,43 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('dcim', '0139_rename_cable_peer'),
+ ('wireless', '0001_wireless'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='interface',
+ name='rf_role',
+ field=models.CharField(blank=True, max_length=30),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='rf_channel',
+ field=models.CharField(blank=True, max_length=50),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='rf_channel_frequency',
+ field=models.DecimalField(blank=True, decimal_places=2, max_digits=7, null=True),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='rf_channel_width',
+ field=models.DecimalField(blank=True, decimal_places=3, max_digits=7, null=True),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='wireless_lans',
+ field=models.ManyToManyField(blank=True, related_name='interfaces', to='wireless.WirelessLAN'),
+ ),
+ migrations.AddField(
+ model_name='interface',
+ name='wireless_link',
+ field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='wireless.wirelesslink'),
+ ),
+ ]
diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py
index 0375a9fb466..58a3e1de5de 100644
--- a/netbox/dcim/models/__init__.py
+++ b/netbox/dcim/models/__init__.py
@@ -10,7 +10,7 @@
'BaseInterface',
'Cable',
'CablePath',
- 'CableTermination',
+ 'LinkTermination',
'ConsolePort',
'ConsolePortTemplate',
'ConsoleServerPort',
diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py
index bddce93b910..54012f0e94e 100644
--- a/netbox/dcim/models/cables.py
+++ b/netbox/dcim/models/cables.py
@@ -64,8 +64,8 @@ class Cable(PrimaryModel):
)
status = models.CharField(
max_length=50,
- choices=CableStatusChoices,
- default=CableStatusChoices.STATUS_CONNECTED
+ choices=LinkStatusChoices,
+ default=LinkStatusChoices.STATUS_CONNECTED
)
tenant = models.ForeignKey(
to='tenancy.Tenant',
@@ -292,7 +292,7 @@ def save(self, *args, **kwargs):
self._pk = self.pk
def get_status_class(self):
- return CableStatusChoices.CSS_CLASSES.get(self.status)
+ return LinkStatusChoices.CSS_CLASSES.get(self.status)
def get_compatible_types(self):
"""
@@ -386,7 +386,7 @@ def from_origin(cls, origin):
"""
from circuits.models import CircuitTermination
- if origin is None or origin.cable is None:
+ if origin is None or origin.link is None:
return None
destination = None
@@ -396,13 +396,13 @@ def from_origin(cls, origin):
is_split = False
node = origin
- while node.cable is not None:
- if node.cable.status != CableStatusChoices.STATUS_CONNECTED:
+ while node.link is not None:
+ if hasattr(node.link, 'status') and node.link.status != LinkStatusChoices.STATUS_CONNECTED:
is_active = False
- # Follow the cable to its far-end termination
- path.append(object_to_path_node(node.cable))
- peer_termination = node.get_cable_peer()
+ # Follow the link to its far-end termination
+ path.append(object_to_path_node(node.link))
+ peer_termination = node.get_link_peer()
# Follow a FrontPort to its corresponding RearPort
if isinstance(peer_termination, FrontPort):
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index 386776b4195..c2a37fcae4f 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -18,11 +18,13 @@
from utilities.ordering import naturalize_interface
from utilities.querysets import RestrictedQuerySet
from utilities.query_functions import CollateAsChar
+from wireless.choices import *
+from wireless.utils import get_channel_attr
__all__ = (
'BaseInterface',
- 'CableTermination',
+ 'LinkTermination',
'ConsolePort',
'ConsoleServerPort',
'DeviceBay',
@@ -87,14 +89,14 @@ def parent_object(self):
return self.device
-class CableTermination(models.Model):
+class LinkTermination(models.Model):
"""
- An abstract model inherited by all models to which a Cable can terminate (certain device components, PowerFeed, and
- CircuitTermination instances). The `cable` field indicates the Cable instance which is terminated to this instance.
+ An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
+ include most device components, CircuitTerminations, and PowerFeeds. The `cable` and `wireless_link` fields
+ reference the attached Cable or WirelessLink instance, respectively.
- `_cable_peer` is a GenericForeignKey used to cache the far-end CableTermination on the local instance; this is a
- shortcut to referencing `cable.termination_b`, for example. `_cable_peer` is set or cleared by the receivers in
- dcim.signals when a Cable instance is created or deleted, respectively.
+ `_link_peer` is a GenericForeignKey used to cache the far-end LinkTermination on the local instance; this is a
+ shortcut to referencing `instance.link.termination_b`, for example.
"""
cable = models.ForeignKey(
to='dcim.Cable',
@@ -103,20 +105,20 @@ class CableTermination(models.Model):
blank=True,
null=True
)
- _cable_peer_type = models.ForeignKey(
+ _link_peer_type = models.ForeignKey(
to=ContentType,
on_delete=models.SET_NULL,
related_name='+',
blank=True,
null=True
)
- _cable_peer_id = models.PositiveIntegerField(
+ _link_peer_id = models.PositiveIntegerField(
blank=True,
null=True
)
- _cable_peer = GenericForeignKey(
- ct_field='_cable_peer_type',
- fk_field='_cable_peer_id'
+ _link_peer = GenericForeignKey(
+ ct_field='_link_peer_type',
+ fk_field='_link_peer_id'
)
mark_connected = models.BooleanField(
default=False,
@@ -146,8 +148,8 @@ def clean(self):
"mark_connected": "Cannot mark as connected with a cable attached."
})
- def get_cable_peer(self):
- return self._cable_peer
+ def get_link_peer(self):
+ return self._link_peer
@property
def _occupied(self):
@@ -157,6 +159,13 @@ def _occupied(self):
def parent_object(self):
raise NotImplementedError("CableTermination models must implement parent_object()")
+ @property
+ def link(self):
+ """
+ Generic wrapper for a Cable, WirelessLink, or some other relation to a connected termination.
+ """
+ return self.cable
+
class PathEndpoint(models.Model):
"""
@@ -219,7 +228,7 @@ def connected_endpoint(self):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class ConsolePort(ComponentModel, CableTermination, PathEndpoint):
+class ConsolePort(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
"""
@@ -251,7 +260,7 @@ def get_absolute_url(self):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class ConsoleServerPort(ComponentModel, CableTermination, PathEndpoint):
+class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
"""
@@ -283,7 +292,7 @@ def get_absolute_url(self):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerPort(ComponentModel, CableTermination, PathEndpoint):
+class PowerPort(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
"""
@@ -333,8 +342,8 @@ def get_power_draw(self):
poweroutlet_ct = ContentType.objects.get_for_model(PowerOutlet)
outlet_ids = PowerOutlet.objects.filter(power_port=self).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
- _cable_peer_type=poweroutlet_ct,
- _cable_peer_id__in=outlet_ids
+ _link_peer_type=poweroutlet_ct,
+ _link_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
@@ -347,12 +356,12 @@ def get_power_draw(self):
}
# Calculate per-leg aggregates for three-phase feeds
- if getattr(self._cable_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
+ if getattr(self._link_peer, 'phase', None) == PowerFeedPhaseChoices.PHASE_3PHASE:
for leg, leg_name in PowerOutletFeedLegChoices:
outlet_ids = PowerOutlet.objects.filter(power_port=self, feed_leg=leg).values_list('pk', flat=True)
utilization = PowerPort.objects.filter(
- _cable_peer_type=poweroutlet_ct,
- _cable_peer_id__in=outlet_ids
+ _link_peer_type=poweroutlet_ct,
+ _link_peer_id__in=outlet_ids
).aggregate(
maximum_draw_total=Sum('maximum_draw'),
allocated_draw_total=Sum('allocated_draw'),
@@ -380,7 +389,7 @@ def get_power_draw(self):
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerOutlet(ComponentModel, CableTermination, PathEndpoint):
+class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint):
"""
A physical power outlet (output) within a Device which provides power to a PowerPort.
"""
@@ -475,7 +484,7 @@ def count_ipaddresses(self):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
+class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint):
"""
A network interface within a Device. A physical Interface can connect to exactly one other Interface.
"""
@@ -517,6 +526,45 @@ class Interface(ComponentModel, BaseInterface, CableTermination, PathEndpoint):
verbose_name='WWN',
help_text='64-bit World Wide Name'
)
+ rf_role = models.CharField(
+ max_length=30,
+ choices=WirelessRoleChoices,
+ blank=True,
+ verbose_name='Wireless role'
+ )
+ rf_channel = models.CharField(
+ max_length=50,
+ choices=WirelessChannelChoices,
+ blank=True,
+ verbose_name='Wireless channel'
+ )
+ rf_channel_frequency = models.DecimalField(
+ max_digits=7,
+ decimal_places=2,
+ blank=True,
+ null=True,
+ verbose_name='Channel frequency (MHz)'
+ )
+ rf_channel_width = models.DecimalField(
+ max_digits=7,
+ decimal_places=3,
+ blank=True,
+ null=True,
+ verbose_name='Channel width (MHz)'
+ )
+ wireless_link = models.ForeignKey(
+ to='wireless.WirelessLink',
+ on_delete=models.SET_NULL,
+ related_name='+',
+ blank=True,
+ null=True
+ )
+ wireless_lans = models.ManyToManyField(
+ to='wireless.WirelessLAN',
+ related_name='interfaces',
+ blank=True,
+ verbose_name='Wireless LANs'
+ )
untagged_vlan = models.ForeignKey(
to='ipam.VLAN',
on_delete=models.SET_NULL,
@@ -550,14 +598,14 @@ def get_absolute_url(self):
def clean(self):
super().clean()
- # Virtual interfaces cannot be connected
- if not self.is_connectable and self.cable:
+ # Virtual Interfaces cannot have a Cable attached
+ if self.is_virtual and self.cable:
raise ValidationError({
'type': f"{self.get_type_display()} interfaces cannot have a cable attached."
})
- # Non-connectable interfaces cannot be marked as connected
- if not self.is_connectable and self.mark_connected:
+ # Virtual Interfaces cannot be marked as connected
+ if self.is_virtual and self.mark_connected:
raise ValidationError({
'mark_connected': f"{self.get_type_display()} interfaces cannot be marked as connected."
})
@@ -603,6 +651,34 @@ def clean(self):
if self.pk and self.lag_id == self.pk:
raise ValidationError({'lag': "A LAG interface cannot be its own parent."})
+ # RF role & channel may only be set for wireless interfaces
+ if self.rf_role and not self.is_wireless:
+ raise ValidationError({'rf_role': "Wireless role may be set only on wireless interfaces."})
+ if self.rf_channel and not self.is_wireless:
+ raise ValidationError({'rf_channel': "Channel may be set only on wireless interfaces."})
+
+ # Validate channel frequency against interface type and selected channel (if any)
+ if self.rf_channel_frequency:
+ if not self.is_wireless:
+ raise ValidationError({
+ 'rf_channel_frequency': "Channel frequency may be set only on wireless interfaces.",
+ })
+ if self.rf_channel and self.rf_channel_frequency != get_channel_attr(self.rf_channel, 'frequency'):
+ raise ValidationError({
+ 'rf_channel_frequency': "Cannot specify custom frequency with channel selected.",
+ })
+ elif self.rf_channel:
+ self.rf_channel_frequency = get_channel_attr(self.rf_channel, 'frequency')
+
+ # Validate channel width against interface type and selected channel (if any)
+ if self.rf_channel_width:
+ if not self.is_wireless:
+ raise ValidationError({'rf_channel_width': "Channel width may be set only on wireless interfaces."})
+ if self.rf_channel and self.rf_channel_width != get_channel_attr(self.rf_channel, 'width'):
+ raise ValidationError({'rf_channel_width': "Cannot specify custom width with channel selected."})
+ elif self.rf_channel:
+ self.rf_channel_width = get_channel_attr(self.rf_channel, 'width')
+
# Validate untagged VLAN
if self.untagged_vlan and self.untagged_vlan.site not in [self.device.site, None]:
raise ValidationError({
@@ -611,8 +687,12 @@ def clean(self):
})
@property
- def is_connectable(self):
- return self.type not in NONCONNECTABLE_IFACE_TYPES
+ def _occupied(self):
+ return super()._occupied or bool(self.wireless_link_id)
+
+ @property
+ def is_wired(self):
+ return not self.is_virtual and not self.is_wireless
@property
def is_virtual(self):
@@ -626,13 +706,17 @@ def is_wireless(self):
def is_lag(self):
return self.type == InterfaceTypeChoices.TYPE_LAG
+ @property
+ def link(self):
+ return self.cable or self.wireless_link
+
#
# Pass-through ports
#
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class FrontPort(ComponentModel, CableTermination):
+class FrontPort(ComponentModel, LinkTermination):
"""
A pass-through port on the front of a Device.
"""
@@ -686,7 +770,7 @@ def clean(self):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class RearPort(ComponentModel, CableTermination):
+class RearPort(ComponentModel, LinkTermination):
"""
A pass-through port on the rear of a Device.
"""
diff --git a/netbox/dcim/models/power.py b/netbox/dcim/models/power.py
index f4d0ce8df60..30e11b342eb 100644
--- a/netbox/dcim/models/power.py
+++ b/netbox/dcim/models/power.py
@@ -10,7 +10,7 @@
from netbox.models import PrimaryModel
from utilities.querysets import RestrictedQuerySet
from utilities.validators import ExclusionValidator
-from .device_components import CableTermination, PathEndpoint
+from .device_components import LinkTermination, PathEndpoint
__all__ = (
'PowerFeed',
@@ -72,7 +72,7 @@ def clean(self):
@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerFeed(PrimaryModel, PathEndpoint, CableTermination):
+class PowerFeed(PrimaryModel, PathEndpoint, LinkTermination):
"""
An electrical circuit delivered from a PowerPanel.
"""
diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py
index a6be069b66e..a6d7f33af17 100644
--- a/netbox/dcim/models/racks.py
+++ b/netbox/dcim/models/racks.py
@@ -427,13 +427,13 @@ def get_power_utilization(self):
return 0
pf_powerports = PowerPort.objects.filter(
- _cable_peer_type=ContentType.objects.get_for_model(PowerFeed),
- _cable_peer_id__in=powerfeeds.values_list('id', flat=True)
+ _link_peer_type=ContentType.objects.get_for_model(PowerFeed),
+ _link_peer_id__in=powerfeeds.values_list('id', flat=True)
)
poweroutlets = PowerOutlet.objects.filter(power_port_id__in=pf_powerports)
allocated_draw_total = PowerPort.objects.filter(
- _cable_peer_type=ContentType.objects.get_for_model(PowerOutlet),
- _cable_peer_id__in=poweroutlets.values_list('id', flat=True)
+ _link_peer_type=ContentType.objects.get_for_model(PowerOutlet),
+ _link_peer_id__in=poweroutlets.values_list('id', flat=True)
).aggregate(Sum('allocated_draw'))['allocated_draw__sum'] or 0
return int(allocated_draw_total / available_power_total * 100)
diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py
index 9fc68ee70f5..79e9c6687ef 100644
--- a/netbox/dcim/signals.py
+++ b/netbox/dcim/signals.py
@@ -2,37 +2,11 @@
from django.contrib.contenttypes.models import ContentType
from django.db.models.signals import post_save, post_delete, pre_delete
-from django.db import transaction
from django.dispatch import receiver
-from .choices import CableStatusChoices
+from .choices import LinkStatusChoices
from .models import Cable, CablePath, Device, PathEndpoint, PowerPanel, Rack, Location, VirtualChassis
-
-
-def create_cablepath(node):
- """
- Create CablePaths for all paths originating from the specified node.
- """
- cp = CablePath.from_origin(node)
- if cp:
- try:
- cp.save()
- except Exception as e:
- print(node, node.pk)
- raise e
-
-
-def rebuild_paths(obj):
- """
- Rebuild all CablePaths which traverse the specified node
- """
- cable_paths = CablePath.objects.filter(path__contains=obj)
-
- with transaction.atomic():
- for cp in cable_paths:
- cp.delete()
- if cp.origin:
- create_cablepath(cp.origin)
+from .utils import create_cablepath, rebuild_paths
#
@@ -109,12 +83,12 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
if instance.termination_a.cable != instance:
logger.debug(f"Updating termination A for cable {instance}")
instance.termination_a.cable = instance
- instance.termination_a._cable_peer = instance.termination_b
+ instance.termination_a._link_peer = instance.termination_b
instance.termination_a.save()
if instance.termination_b.cable != instance:
logger.debug(f"Updating termination B for cable {instance}")
instance.termination_b.cable = instance
- instance.termination_b._cable_peer = instance.termination_a
+ instance.termination_b._link_peer = instance.termination_a
instance.termination_b.save()
# Create/update cable paths
@@ -128,7 +102,7 @@ def update_connected_endpoints(instance, created, raw=False, **kwargs):
# We currently don't support modifying either termination of an existing Cable. (This
# may change in the future.) However, we do need to capture status changes and update
# any CablePaths accordingly.
- if instance.status != CableStatusChoices.STATUS_CONNECTED:
+ if instance.status != LinkStatusChoices.STATUS_CONNECTED:
CablePath.objects.filter(path__contains=instance).update(is_active=False)
else:
rebuild_paths(instance)
@@ -145,11 +119,11 @@ def nullify_connected_endpoints(instance, **kwargs):
if instance.termination_a is not None:
logger.debug(f"Nullifying termination A for cable {instance}")
model = instance.termination_a._meta.model
- model.objects.filter(pk=instance.termination_a.pk).update(_cable_peer_type=None, _cable_peer_id=None)
+ model.objects.filter(pk=instance.termination_a.pk).update(_link_peer_type=None, _link_peer_id=None)
if instance.termination_b is not None:
logger.debug(f"Nullifying termination B for cable {instance}")
model = instance.termination_b._meta.model
- model.objects.filter(pk=instance.termination_b.pk).update(_cable_peer_type=None, _cable_peer_id=None)
+ model.objects.filter(pk=instance.termination_b.pk).update(_link_peer_type=None, _link_peer_id=None)
# Delete and retrace any dependent cable paths
for cablepath in CablePath.objects.filter(path__contains=instance):
diff --git a/netbox/dcim/svg.py b/netbox/dcim/svg.py
index 5601bc5917e..b7f1576eed9 100644
--- a/netbox/dcim/svg.py
+++ b/netbox/dcim/svg.py
@@ -398,6 +398,39 @@ def _draw_cable(self, color, url, labels):
return group
+ def _draw_wirelesslink(self, url, labels):
+ """
+ Draw a line with labels representing a WirelessLink.
+
+ :param url: Hyperlink URL
+ :param labels: Iterable of text labels
+ """
+ group = Group(class_='connector')
+
+ # Draw the wireless link
+ start = (OFFSET + self.center, self.cursor)
+ height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
+ end = (start[0], start[1] + height)
+ line = Line(start=start, end=end, class_='wireless-link')
+ group.add(line)
+
+ self.cursor += PADDING * 2
+
+ # Add link
+ link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+
+ # Add text label(s)
+ for i, label in enumerate(labels):
+ self.cursor += LINE_HEIGHT
+ text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
+ text = Text(label, insert=text_coords, class_='bold' if not i else [])
+ link.add(text)
+
+ group.add(link)
+ self.cursor += PADDING * 2
+
+ return group
+
def _draw_attachment(self):
"""
Return an SVG group containing a line element and "Attachment" label.
@@ -418,6 +451,9 @@ def render(self):
"""
Return an SVG document representing a cable trace.
"""
+ from dcim.models import Cable
+ from wireless.models import WirelessLink
+
traced_path = self.origin.trace()
# Prep elements list
@@ -452,24 +488,39 @@ def render(self):
)
terminations.append(termination)
- # Connector (either a Cable or attachment to a ProviderNetwork)
+ # Connector (a Cable or WirelessLink)
if connector is not None:
# Cable
- cable_labels = [
- f'Cable {connector}',
- connector.get_status_display()
- ]
- if connector.type:
- cable_labels.append(connector.get_type_display())
- if connector.length and connector.length_unit:
- cable_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
- cable = self._draw_cable(
- color=connector.color or '000000',
- url=connector.get_absolute_url(),
- labels=cable_labels
- )
- connectors.append(cable)
+ if type(connector) is Cable:
+ connector_labels = [
+ f'Cable {connector}',
+ connector.get_status_display()
+ ]
+ if connector.type:
+ connector_labels.append(connector.get_type_display())
+ if connector.length and connector.length_unit:
+ connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
+ cable = self._draw_cable(
+ color=connector.color or '000000',
+ url=connector.get_absolute_url(),
+ labels=connector_labels
+ )
+ connectors.append(cable)
+
+ # WirelessLink
+ elif type(connector) is WirelessLink:
+ connector_labels = [
+ f'Wireless link {connector}',
+ connector.get_status_display()
+ ]
+ if connector.ssid:
+ connector_labels.append(connector.ssid)
+ wirelesslink = self._draw_wirelesslink(
+ url=connector.get_absolute_url(),
+ labels=connector_labels
+ )
+ connectors.append(wirelesslink)
# Far end termination
termination = self._draw_box(
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index f47073848a2..06c594f6b3b 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -11,11 +11,7 @@
BaseTable, BooleanColumn, ButtonsColumn, ChoiceFieldColumn, ColorColumn, ColoredLabelColumn, LinkedCountColumn,
MarkdownColumn, TagColumn, TemplateColumn, ToggleColumn,
)
-from .template_code import (
- CABLETERMINATION, CONSOLEPORT_BUTTONS, CONSOLESERVERPORT_BUTTONS, DEVICE_LINK, DEVICEBAY_BUTTONS, DEVICEBAY_STATUS,
- FRONTPORT_BUTTONS, INTERFACE_BUTTONS, INTERFACE_IPADDRESSES, INTERFACE_TAGGED_VLANS, POWEROUTLET_BUTTONS,
- POWERPORT_BUTTONS, REARPORT_BUTTONS,
-)
+from .template_code import *
__all__ = (
'BaseInterfaceTable',
@@ -266,11 +262,11 @@ class CableTerminationTable(BaseTable):
orderable=False,
verbose_name='Cable Color'
)
- cable_peer = TemplateColumn(
- accessor='_cable_peer',
- template_code=CABLETERMINATION,
+ link_peer = TemplateColumn(
+ accessor='_link_peer',
+ template_code=LINKTERMINATION,
orderable=False,
- verbose_name='Cable Peer'
+ verbose_name='Link Peer'
)
mark_connected = BooleanColumn()
@@ -278,7 +274,7 @@ class CableTerminationTable(BaseTable):
class PathEndpointTable(CableTerminationTable):
connection = TemplateColumn(
accessor='_path.last_node',
- template_code=CABLETERMINATION,
+ template_code=LINKTERMINATION,
verbose_name='Connection',
orderable=False
)
@@ -299,7 +295,7 @@ class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
- 'cable_peer', 'connection', 'tags',
+ 'link_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -320,7 +316,7 @@ class Meta(DeviceComponentTable.Meta):
model = ConsolePort
fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
- 'cable_peer', 'connection', 'tags', 'actions'
+ 'link_peer', 'connection', 'tags', 'actions'
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = {
@@ -343,7 +339,7 @@ class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
- 'cable_peer', 'connection', 'tags',
+ 'link_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'speed', 'description')
@@ -365,7 +361,7 @@ class Meta(DeviceComponentTable.Meta):
model = ConsoleServerPort
fields = (
'pk', 'name', 'label', 'type', 'speed', 'description', 'mark_connected', 'cable', 'cable_color',
- 'cable_peer', 'connection', 'tags', 'actions',
+ 'link_peer', 'connection', 'tags', 'actions',
)
default_columns = ('pk', 'name', 'label', 'type', 'speed', 'description', 'cable', 'connection', 'actions')
row_attrs = {
@@ -388,7 +384,7 @@ class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'mark_connected', 'maximum_draw', 'allocated_draw',
- 'cable', 'cable_color', 'cable_peer', 'connection', 'tags',
+ 'cable', 'cable_color', 'link_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description')
@@ -410,7 +406,7 @@ class Meta(DeviceComponentTable.Meta):
model = PowerPort
fields = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'mark_connected', 'cable',
- 'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
+ 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description', 'cable', 'connection',
@@ -439,7 +435,7 @@ class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'name', 'device', 'label', 'type', 'description', 'power_port', 'feed_leg', 'mark_connected', 'cable',
- 'cable_color', 'cable_peer', 'connection', 'tags',
+ 'cable_color', 'link_peer', 'connection', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'power_port', 'feed_leg', 'description')
@@ -460,7 +456,7 @@ class Meta(DeviceComponentTable.Meta):
model = PowerOutlet
fields = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'mark_connected', 'cable',
- 'cable_color', 'cable_peer', 'connection', 'tags', 'actions',
+ 'cable_color', 'link_peer', 'connection', 'tags', 'actions',
)
default_columns = (
'pk', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', 'cable', 'connection', 'actions',
@@ -493,6 +489,14 @@ class InterfaceTable(DeviceComponentTable, BaseInterfaceTable, PathEndpointTable
}
)
mgmt_only = BooleanColumn()
+ wireless_link = tables.Column(
+ linkify=True
+ )
+ wireless_lans = TemplateColumn(
+ template_code=INTERFACE_WIRELESS_LANS,
+ orderable=False,
+ verbose_name='Wireless LANs'
+ )
tags = TagColumn(
url_name='dcim:interface_list'
)
@@ -501,7 +505,8 @@ class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'device', 'label', 'enabled', 'type', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
- 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
+ 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'description', 'mark_connected',
+ 'cable', 'cable_color', 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses',
'untagged_vlan', 'tagged_vlans',
)
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
@@ -509,8 +514,8 @@ class Meta(DeviceComponentTable.Meta):
class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn(
- template_code=' {{ value }}',
order_by=Accessor('_name'),
attrs={'td': {'class': 'text-nowrap'}}
@@ -533,8 +538,9 @@ class Meta(DeviceComponentTable.Meta):
model = Interface
fields = (
'pk', 'name', 'label', 'enabled', 'type', 'parent', 'lag', 'mgmt_only', 'mtu', 'mode', 'mac_address', 'wwn',
- 'description', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'tags', 'ip_addresses',
- 'untagged_vlan', 'tagged_vlans', 'actions',
+ 'rf_role', 'rf_channel', 'rf_channel_width', 'description', 'mark_connected', 'cable', 'cable_color',
+ 'wireless_link', 'wireless_lans', 'link_peer', 'connection', 'tags', 'ip_addresses', 'untagged_vlan',
+ 'tagged_vlans', 'actions',
)
order_by = ('name',)
default_columns = (
@@ -570,7 +576,7 @@ class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
- 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'tags',
+ 'mark_connected', 'cable', 'cable_color', 'link_peer', 'tags',
)
default_columns = (
'pk', 'name', 'device', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
@@ -594,10 +600,10 @@ class Meta(DeviceComponentTable.Meta):
model = FrontPort
fields = (
'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'mark_connected', 'cable',
- 'cable_color', 'cable_peer', 'tags', 'actions',
+ 'cable_color', 'link_peer', 'tags', 'actions',
)
default_columns = (
- 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'cable_peer',
+ 'pk', 'name', 'label', 'type', 'rear_port', 'rear_port_position', 'description', 'cable', 'link_peer',
'actions',
)
row_attrs = {
@@ -621,7 +627,7 @@ class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'name', 'device', 'label', 'type', 'color', 'positions', 'description', 'mark_connected', 'cable',
- 'cable_color', 'cable_peer', 'tags',
+ 'cable_color', 'link_peer', 'tags',
)
default_columns = ('pk', 'name', 'device', 'label', 'type', 'color', 'description')
@@ -643,10 +649,10 @@ class Meta(DeviceComponentTable.Meta):
model = RearPort
fields = (
'pk', 'name', 'label', 'type', 'positions', 'description', 'mark_connected', 'cable', 'cable_color',
- 'cable_peer', 'tags', 'actions',
+ 'link_peer', 'tags', 'actions',
)
default_columns = (
- 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'cable_peer', 'actions',
+ 'pk', 'name', 'label', 'type', 'positions', 'description', 'cable', 'link_peer', 'actions',
)
row_attrs = {
'class': get_cabletermination_row_class
diff --git a/netbox/dcim/tables/power.py b/netbox/dcim/tables/power.py
index b8e032e7ff0..95628291128 100644
--- a/netbox/dcim/tables/power.py
+++ b/netbox/dcim/tables/power.py
@@ -71,10 +71,10 @@ class Meta(BaseTable.Meta):
model = PowerFeed
fields = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase',
- 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'cable_peer', 'connection', 'available_power',
+ 'max_utilization', 'mark_connected', 'cable', 'cable_color', 'link_peer', 'connection', 'available_power',
'comments', 'tags',
)
default_columns = (
'pk', 'name', 'power_panel', 'rack', 'status', 'type', 'supply', 'voltage', 'amperage', 'phase', 'cable',
- 'cable_peer',
+ 'link_peer',
)
diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py
index 2f359e1b95e..f6938807a7c 100644
--- a/netbox/dcim/tables/template_code.py
+++ b/netbox/dcim/tables/template_code.py
@@ -1,4 +1,4 @@
-CABLETERMINATION = """
+LINKTERMINATION = """
{% if value %}
{% if value.parent_object %}
{{ value.parent_object }}
@@ -64,6 +64,12 @@
{% endif %}
"""
+INTERFACE_WIRELESS_LANS = """
+{% for wlan in record.wireless_lans.all %}
+ {{ wlan }}
+{% endfor %}
+"""
+
POWERFEED_CABLE = """
{{ value }}
@@ -195,15 +201,23 @@
{% endif %}
-{% if record.cable %}
+{% if record.link %}
+{% endif %}
+{% if record.cable %}
{% include 'dcim/inc/cable_toggle_buttons.html' with cable=record.cable %}
{% if perms.dcim.delete_cable %}
{% endif %}
-{% elif record.is_connectable and perms.dcim.add_cable %}
+{% elif record.wireless_link %}
+ {% if perms.wireless.delete_wirelesslink %}
+
+
+
+ {% endif %}
+{% elif record.is_wired and perms.dcim.add_cable %}
{% if not record.mark_connected %}
@@ -221,6 +235,10 @@
{% else %}
{% endif %}
+{% elif record.is_wireless and perms.wireless.add_wirelesslink %}
+
+
+
{% endif %}
"""
diff --git a/netbox/dcim/tests/test_cablepaths.py b/netbox/dcim/tests/test_cablepaths.py
index c0fc89f8379..6849df012fe 100644
--- a/netbox/dcim/tests/test_cablepaths.py
+++ b/netbox/dcim/tests/test_cablepaths.py
@@ -2,7 +2,7 @@
from django.test import TestCase
from circuits.models import *
-from dcim.choices import CableStatusChoices
+from dcim.choices import LinkStatusChoices
from dcim.models import *
from dcim.utils import object_to_path_node
@@ -1142,7 +1142,7 @@ def test_302_update_path_on_cable_status_change(self):
self.assertEqual(CablePath.objects.count(), 2)
# Change cable 2's status to "planned"
- cable2.status = CableStatusChoices.STATUS_PLANNED
+ cable2.status = LinkStatusChoices.STATUS_PLANNED
cable2.save()
self.assertPathExists(
origin=interface1,
@@ -1160,7 +1160,7 @@ def test_302_update_path_on_cable_status_change(self):
# Change cable 2's status to "connected"
cable2 = Cable.objects.get(pk=cable2.pk)
- cable2.status = CableStatusChoices.STATUS_CONNECTED
+ cable2.status = LinkStatusChoices.STATUS_CONNECTED
cable2.save()
self.assertPathExists(
origin=interface1,
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index ce78e047074..f66ceb855ea 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -9,6 +9,7 @@
from utilities.choices import ColorChoices
from utilities.testing import ChangeLoggedFilterSetTests
from virtualization.models import Cluster, ClusterType
+from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
class RegionTestCase(TestCase, ChangeLoggedFilterSetTests):
@@ -2063,6 +2064,8 @@ def setUpTestData(cls):
Interface(device=devices[3], name='Interface 4', label='D', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
Interface(device=devices[3], name='Interface 5', label='E', type=InterfaceTypeChoices.TYPE_OTHER, enabled=True, mgmt_only=True),
Interface(device=devices[3], name='Interface 6', label='F', type=InterfaceTypeChoices.TYPE_OTHER, enabled=False, mgmt_only=False),
+ Interface(device=devices[3], name='Interface 7', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_AP, rf_channel=WirelessChannelChoices.CHANNEL_24G_1, rf_channel_frequency=2412, rf_channel_width=22),
+ Interface(device=devices[3], name='Interface 8', type=InterfaceTypeChoices.TYPE_80211AC, rf_role=WirelessRoleChoices.ROLE_STATION, rf_channel=WirelessChannelChoices.CHANNEL_5G_32, rf_channel_frequency=5160, rf_channel_width=20),
)
Interface.objects.bulk_create(interfaces)
@@ -2083,11 +2086,11 @@ def test_connected(self):
params = {'connected': True}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'connected': False}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_enabled(self):
params = {'enabled': 'true'}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
params = {'enabled': 'false'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2099,7 +2102,7 @@ def test_mgmt_only(self):
params = {'mgmt_only': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'mgmt_only': 'false'}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_mode(self):
params = {'mode': InterfaceModeChoices.MODE_ACCESS}
@@ -2176,7 +2179,7 @@ def test_cabled(self):
params = {'cabled': 'true'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
params = {'cabled': 'false'}
- self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_kind(self):
params = {'kind': 'physical'}
@@ -2192,6 +2195,22 @@ def test_type(self):
params = {'type': [InterfaceTypeChoices.TYPE_1GE_FIXED, InterfaceTypeChoices.TYPE_1GE_GBIC]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+ def test_rf_role(self):
+ params = {'rf_role': [WirelessRoleChoices.ROLE_AP, WirelessRoleChoices.ROLE_STATION]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_rf_channel(self):
+ params = {'rf_channel': [WirelessChannelChoices.CHANNEL_24G_1, WirelessChannelChoices.CHANNEL_5G_32]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_rf_channel_frequency(self):
+ params = {'rf_channel_frequency': [2412, 5160]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+ def test_rf_channel_width(self):
+ params = {'rf_channel_width': [22, 20]}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = FrontPort.objects.all()
@@ -2864,12 +2883,12 @@ def setUpTestData(cls):
console_server_port = ConsoleServerPort.objects.create(device=devices[0], name='Console Server Port 1')
# Cables
- Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=CableStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=CableStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
- Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
- Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=CableStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+ Cable(termination_a=interfaces[1], termination_b=interfaces[2], label='Cable 1', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=10, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[3], termination_b=interfaces[4], label='Cable 2', type=CableTypeChoices.TYPE_CAT3, tenant=tenants[0], status=LinkStatusChoices.STATUS_CONNECTED, color='aa1409', length=20, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[5], termination_b=interfaces[6], label='Cable 3', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_CONNECTED, color='f44336', length=30, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[7], termination_b=interfaces[8], label='Cable 4', type=CableTypeChoices.TYPE_CAT5E, tenant=tenants[1], status=LinkStatusChoices.STATUS_PLANNED, color='f44336', length=40, length_unit=CableLengthUnitChoices.UNIT_FOOT).save()
+ Cable(termination_a=interfaces[9], termination_b=interfaces[10], label='Cable 5', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=10, length_unit=CableLengthUnitChoices.UNIT_METER).save()
+ Cable(termination_a=interfaces[11], termination_b=interfaces[0], label='Cable 6', type=CableTypeChoices.TYPE_CAT6, tenant=tenants[2], status=LinkStatusChoices.STATUS_PLANNED, color='e91e63', length=20, length_unit=CableLengthUnitChoices.UNIT_METER).save()
Cable(termination_a=console_port, termination_b=console_server_port, label='Cable 7').save()
def test_label(self):
@@ -2889,9 +2908,9 @@ def test_type(self):
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
def test_status(self):
- params = {'status': [CableStatusChoices.STATUS_CONNECTED]}
+ params = {'status': [LinkStatusChoices.STATUS_CONNECTED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
- params = {'status': [CableStatusChoices.STATUS_PLANNED]}
+ params = {'status': [LinkStatusChoices.STATUS_PLANNED]}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3)
def test_color(self):
diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py
index ae280365eac..1042057de53 100644
--- a/netbox/dcim/tests/test_models.py
+++ b/netbox/dcim/tests/test_models.py
@@ -494,9 +494,9 @@ def test_cable_creation(self):
interface1 = Interface.objects.get(pk=self.interface1.pk)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertEqual(self.cable.termination_a, interface1)
- self.assertEqual(interface1._cable_peer, interface2)
+ self.assertEqual(interface1._link_peer, interface2)
self.assertEqual(self.cable.termination_b, interface2)
- self.assertEqual(interface2._cable_peer, interface1)
+ self.assertEqual(interface2._link_peer, interface1)
def test_cable_deletion(self):
"""
@@ -508,10 +508,10 @@ def test_cable_deletion(self):
self.assertNotEqual(str(self.cable), '#None')
interface1 = Interface.objects.get(pk=self.interface1.pk)
self.assertIsNone(interface1.cable)
- self.assertIsNone(interface1._cable_peer)
+ self.assertIsNone(interface1._link_peer)
interface2 = Interface.objects.get(pk=self.interface2.pk)
self.assertIsNone(interface2.cable)
- self.assertIsNone(interface2._cable_peer)
+ self.assertIsNone(interface2._link_peer)
def test_cabletermination_deletion(self):
"""
diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py
index 4565c898b06..c08eb6e8a5d 100644
--- a/netbox/dcim/tests/test_views.py
+++ b/netbox/dcim/tests/test_views.py
@@ -1944,7 +1944,7 @@ def setUpTestData(cls):
'termination_b_type': interface_ct.pk,
'termination_b_id': interfaces[3].pk,
'type': CableTypeChoices.TYPE_CAT6,
- 'status': CableStatusChoices.STATUS_PLANNED,
+ 'status': LinkStatusChoices.STATUS_PLANNED,
'label': 'Label',
'color': 'c0c0c0',
'length': 100,
@@ -1961,7 +1961,7 @@ def setUpTestData(cls):
cls.bulk_edit_data = {
'type': CableTypeChoices.TYPE_CAT5E,
- 'status': CableStatusChoices.STATUS_CONNECTED,
+ 'status': LinkStatusChoices.STATUS_CONNECTED,
'label': 'New label',
'color': '00ff00',
'length': 50,
diff --git a/netbox/dcim/utils.py b/netbox/dcim/utils.py
index 91c5c7c772d..ec3a4460370 100644
--- a/netbox/dcim/utils.py
+++ b/netbox/dcim/utils.py
@@ -1,4 +1,5 @@
from django.contrib.contenttypes.models import ContentType
+from django.db import transaction
def compile_path_node(ct_id, object_id):
@@ -26,3 +27,29 @@ def path_node_to_object(repr):
ct_id, object_id = decompile_path_node(repr)
ct = ContentType.objects.get_for_id(ct_id)
return ct.model_class().objects.get(pk=object_id)
+
+
+def create_cablepath(node):
+ """
+ Create CablePaths for all paths originating from the specified node.
+ """
+ from dcim.models import CablePath
+
+ cp = CablePath.from_origin(node)
+ if cp:
+ cp.save()
+
+
+def rebuild_paths(obj):
+ """
+ Rebuild all CablePaths which traverse the specified node
+ """
+ from dcim.models import CablePath
+
+ cable_paths = CablePath.objects.filter(path__contains=obj)
+
+ with transaction.atomic():
+ for cp in cable_paths:
+ cp.delete()
+ if cp.origin:
+ create_cablepath(cp.origin)
diff --git a/netbox/netbox/api/views.py b/netbox/netbox/api/views.py
index 74000e97800..7ad64aeae2e 100644
--- a/netbox/netbox/api/views.py
+++ b/netbox/netbox/api/views.py
@@ -308,6 +308,7 @@ def get(self, request, format=None):
('tenancy', reverse('tenancy-api:api-root', request=request, format=format)),
('users', reverse('users-api:api-root', request=request, format=format)),
('virtualization', reverse('virtualization-api:api-root', request=request, format=format)),
+ ('wireless', reverse('wireless-api:api-root', request=request, format=format)),
)))
diff --git a/netbox/netbox/graphql/schema.py b/netbox/netbox/graphql/schema.py
index bb752b8c44d..812c1656d07 100644
--- a/netbox/netbox/graphql/schema.py
+++ b/netbox/netbox/graphql/schema.py
@@ -7,6 +7,7 @@
from tenancy.graphql.schema import TenancyQuery
from users.graphql.schema import UsersQuery
from virtualization.graphql.schema import VirtualizationQuery
+from wireless.graphql.schema import WirelessQuery
class Query(
@@ -17,6 +18,7 @@ class Query(
TenancyQuery,
UsersQuery,
VirtualizationQuery,
+ WirelessQuery,
graphene.ObjectType
):
pass
diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py
index de2c170a373..993c5e1715e 100644
--- a/netbox/netbox/navigation_menu.py
+++ b/netbox/netbox/navigation_menu.py
@@ -176,6 +176,7 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')):
label='Connections',
items=(
get_model_item('dcim', 'cable', 'Cables', actions=['import']),
+ get_model_item('wireless', 'wirelesslink', 'Wirelesss Links', actions=['import']),
MenuItem(
link='dcim:interface_connections_list',
link_text='Interface Connections',
@@ -196,6 +197,20 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')):
),
)
+WIRELESS_MENU = Menu(
+ label='Wireless',
+ icon_class='mdi mdi-wifi',
+ groups=(
+ MenuGroup(
+ label='Wireless',
+ items=(
+ get_model_item('wireless', 'wirelesslan', 'Wireless LANs'),
+ get_model_item('wireless', 'wirelesslangroup', 'Wireless LAN Groups'),
+ ),
+ ),
+ ),
+)
+
IPAM_MENU = Menu(
label='IPAM',
icon_class='mdi mdi-counter',
@@ -351,6 +366,7 @@ def get_model_buttons(app_label, model_name, actions=('add', 'import')):
ORGANIZATION_MENU,
DEVICES_MENU,
CONNECTIONS_MENU,
+ WIRELESS_MENU,
IPAM_MENU,
VIRTUALIZATION_MENU,
CIRCUITS_MENU,
diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py
index 35e0c6714cb..6381435f272 100644
--- a/netbox/netbox/settings.py
+++ b/netbox/netbox/settings.py
@@ -326,6 +326,7 @@ def _setting(name, default=None):
'users',
'utilities',
'virtualization',
+ 'wireless',
'django_rq', # Must come after extras to allow overriding management commands
'drf_yasg',
]
diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py
index 3d4c60c93bb..4e0a2e2c662 100644
--- a/netbox/netbox/urls.py
+++ b/netbox/netbox/urls.py
@@ -48,6 +48,7 @@
path('tenancy/', include('tenancy.urls')),
path('user/', include('users.urls')),
path('virtualization/', include('virtualization.urls')),
+ path('wireless/', include('wireless.urls')),
# API
path('api/', APIRootView.as_view(), name='api-root'),
@@ -58,6 +59,7 @@
path('api/tenancy/', include('tenancy.api.urls')),
path('api/users/', include('users.api.urls')),
path('api/virtualization/', include('virtualization.api.urls')),
+ path('api/wireless/', include('wireless.api.urls')),
path('api/status/', StatusView.as_view(), name='api-status'),
path('api/docs/', schema_view.with_ui('swagger', cache_timeout=86400), name='api_docs'),
path('api/redoc/', schema_view.with_ui('redoc', cache_timeout=86400), name='api_redocs'),
diff --git a/netbox/netbox/views/__init__.py b/netbox/netbox/views/__init__.py
index 2c033e76019..b361352d086 100644
--- a/netbox/netbox/views/__init__.py
+++ b/netbox/netbox/views/__init__.py
@@ -27,6 +27,7 @@
from netbox.forms import SearchForm
from tenancy.models import Tenant
from virtualization.models import Cluster, VirtualMachine
+from wireless.models import WirelessLAN, WirelessLink
class HomeView(View):
@@ -92,14 +93,19 @@ def build_stats():
("dcim.view_powerpanel", "Power Panels", PowerPanel.objects.restrict(request.user, 'view').count),
("dcim.view_powerfeed", "Power Feeds", PowerFeed.objects.restrict(request.user, 'view').count),
)
+ wireless = (
+ ("wireless.view_wirelesslan", "Wireless LANs", WirelessLAN.objects.restrict(request.user, 'view').count),
+ ("wireless.view_wirelesslink", "Wireless Links", WirelessLink.objects.restrict(request.user, 'view').count),
+ )
sections = (
("Organization", org, "domain"),
("IPAM", ipam, "counter"),
("Virtualization", virtualization, "monitor"),
("Inventory", dcim, "server"),
- ("Connections", connections, "cable-data"),
("Circuits", circuits, "transit-connection-variant"),
+ ("Connections", connections, "cable-data"),
("Power", power, "flash"),
+ ("Wireless", wireless, "wifi"),
)
stats = []
diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css
index 633ccd57232..50622f1284d 100644
--- a/netbox/project-static/dist/cable_trace.css
+++ b/netbox/project-static/dist/cable_trace.css
@@ -1 +1 @@
-:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px,5px}
+:root{--nbx-trace-color: #000;--nbx-trace-node-bg: #e9ecef;--nbx-trace-termination-bg: #f8f9fa;--nbx-trace-cable-shadow: #343a40;--nbx-trace-attachment: #ced4da}:root[data-netbox-color-mode=dark]{--nbx-trace-color: #fff;--nbx-trace-node-bg: #212529;--nbx-trace-termination-bg: #343a40;--nbx-trace-cable-shadow: #e9ecef;--nbx-trace-attachment: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}text{text-anchor:middle;dominant-baseline:middle}text:not([fill]){fill:var(--nbx-trace-color)}text.bold{font-weight:700}svg rect{fill:var(--nbx-trace-node-bg);stroke:#606060;stroke-width:1}svg rect .termination{fill:var(--nbx-trace-termination-bg)}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:var(--nbx-trace-cable-shadow);stroke-width:7px}svg line.wireless-link{stroke:var(--nbx-trace-attachment);stroke-dasharray:4px 12px;stroke-linecap:round}svg line.attachment{stroke:var(--nbx-trace-attachment);stroke-dasharray:5px}
diff --git a/netbox/project-static/styles/cable-trace.scss b/netbox/project-static/styles/cable-trace.scss
index 85deafe96fe..51d94d38a7e 100644
--- a/netbox/project-static/styles/cable-trace.scss
+++ b/netbox/project-static/styles/cable-trace.scss
@@ -59,8 +59,13 @@ svg {
stroke: var(--nbx-trace-cable-shadow);
stroke-width: 7px;
}
+ line.wireless-link {
+ stroke: var(--nbx-trace-attachment);
+ stroke-dasharray: 4px 12px;
+ stroke-linecap: round;
+ }
line.attachment {
stroke: var(--nbx-trace-attachment);
- stroke-dasharray: 5px, 5px;
+ stroke-dasharray: 5px;
}
}
diff --git a/netbox/templates/circuits/inc/circuit_termination.html b/netbox/templates/circuits/inc/circuit_termination.html
index e2fe6af29a0..5c224f7c01f 100644
--- a/netbox/templates/circuits/inc/circuit_termination.html
+++ b/netbox/templates/circuits/inc/circuit_termination.html
@@ -45,7 +45,7 @@
Marked as connected
{% elif termination.cable %}
{{ termination.cable }}
- {% with peer=termination.get_cable_peer %}
+ {% with peer=termination.get_link_peer %}
to
{% if peer.device %}
{{ peer.device }}
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html
index af038326d28..730720b42f2 100644
--- a/netbox/templates/dcim/interface.html
+++ b/netbox/templates/dcim/interface.html
@@ -107,7 +107,7 @@