Compare commits

..

3 Commits

Author SHA1 Message Date
Martin Hauser
a45b6b170d feat(dcim): Show peer connections for LAG members
Add `InterfaceLAGMemberTable` for the LAG Members panel on
LAG interface detail views. The table includes the parent device,
member interface/type, and a peer column which renders
connected endpoints (including the peer LAG when present).

Fixes #19869
2026-01-22 20:41:40 +01:00
bctiemann
4b4c542dce Add truncate_middle filter for middle-ellipsis on long filenames (#21253)
Some checks failed
CI / build (20.x, 3.12) (push) Has been cancelled
CI / build (20.x, 3.13) (push) Has been cancelled
CI / build (20.x, 3.14) (push) Has been cancelled
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-22 09:40:48 -08:00
github-actions
077d9b1129 Update source translation strings
Some checks failed
CodeQL / Analyze (actions) (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
2026-01-22 05:07:49 +00:00
10 changed files with 90 additions and 91 deletions

View File

@@ -27,6 +27,7 @@ __all__ = (
'DeviceTable',
'FrontPortTable',
'InterfaceTable',
'InterfaceLAGMemberTable',
'InventoryItemRoleTable',
'InventoryItemTable',
'MACAddressTable',
@@ -689,6 +690,33 @@ class InterfaceTable(BaseInterfaceTable, ModularDeviceComponentTable, PathEndpoi
default_columns = ('pk', 'name', 'device', 'label', 'enabled', 'type', 'description')
class InterfaceLAGMemberTable(PathEndpointTable, NetBoxTable):
parent = tables.Column(
verbose_name=_('Parent'),
accessor=Accessor('device'),
linkify=True,
)
name = tables.Column(
verbose_name=_('Name'),
linkify=True,
order_by=('_name',),
)
connection = columns.TemplateColumn(
accessor='connected_endpoints',
template_code=INTERFACE_LAG_MEMBERS_LINKTERMINATION,
verbose_name=_('Peer'),
orderable=False,
)
tags = columns.TagColumn(
url_name='dcim:interface_list'
)
class Meta(NetBoxTable.Meta):
model = models.Interface
fields = ('pk', 'parent', 'name', 'type', 'connection')
default_columns = ('pk', 'parent', 'name', 'type', 'connection')
class DeviceInterfaceTable(InterfaceTable):
name = tables.TemplateColumn(
verbose_name=_('Name'),

View File

@@ -24,6 +24,24 @@ INTERFACE_LINKTERMINATION = """
{% else %}""" + LINKTERMINATION + """{% endif %}
"""
INTERFACE_LAG_MEMBERS_LINKTERMINATION = """
{% for termination in value %}
{% if termination.parent_object %}
<a href="{{ termination.parent_object.get_absolute_url }}">{{ termination.parent_object }}</a>
<i class="mdi mdi-chevron-right"></i>
{% endif %}
<a href="{{ termination.get_absolute_url }}">{{ termination }}</a>
{% if termination.lag %}
<i class="mdi mdi-chevron-right"></i>
<a href="{{ termination.lag.get_absolute_url }}">{{ termination.lag }}</a>
<span class="text-muted">(LAG)</span>
{% endif %}
{% if not forloop.last %}<br />{% endif %}
{% empty %}
{{ ''|placeholder }}
{% endfor %}
"""
CABLE_LENGTH = """
{% load helpers %}
{% if record.length %}{{ record.length|floatformat:"-2" }} {{ record.length_unit }}{% endif %}

View File

@@ -3135,6 +3135,14 @@ class InterfaceView(generic.ObjectView):
)
child_interfaces_table.configure(request)
# Get LAG interfaces
lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
lag_interfaces_table = tables.InterfaceLAGMemberTable(
lag_interfaces,
orderable=False
)
lag_interfaces_table.configure(request)
# Get assigned VLANs and annotate whether each is tagged or untagged
vlans = []
if instance.untagged_vlan is not None:
@@ -3164,6 +3172,7 @@ class InterfaceView(generic.ObjectView):
'bridge_interfaces': bridge_interfaces,
'bridge_interfaces_table': bridge_interfaces_table,
'child_interfaces_table': child_interfaces_table,
'lag_interfaces_table': lag_interfaces_table,
'vlan_table': vlan_table,
'vlan_translation_table': vlan_translation_table,
}

View File

@@ -43,7 +43,7 @@ IMAGEATTACHMENT_IMAGE = """
<a href="{{ record.image.url }}" target="_blank" class="image-preview" data-bs-placement="top">
<i class="mdi mdi-image"></i></a>
{% endif %}
<a href="{{ record.get_absolute_url }}">{{ record }}</a>
<a href="{{ record.get_absolute_url }}">{{ record.filename|truncate_middle:16 }}</a>
"""
NOTIFICATION_ICON = """

View File

@@ -370,11 +370,6 @@ class AnnotatedIPAddressTable(IPAddressTable):
verbose_name=_('IP Address')
)
def render_pk(self, value, record, bound_column):
if type(record) is not self._meta.model:
return ''
return bound_column.column.render(value, bound_column, record)
class Meta(IPAddressTable.Meta):
pass

View File

@@ -1,41 +0,0 @@
from django.test import RequestFactory, TestCase
from netaddr import IPNetwork
from ipam.models import IPAddress, IPRange, Prefix
from ipam.tables import AnnotatedIPAddressTable
from ipam.utils import annotate_ip_space
class AnnotatedIPAddressTableTest(TestCase):
@classmethod
def setUpTestData(cls):
cls.prefix = Prefix.objects.create(
prefix=IPNetwork('10.1.1.0/24'),
status='active'
)
cls.ip_address = IPAddress.objects.create(
address='10.1.1.1/24',
status='active'
)
cls.ip_range = IPRange.objects.create(
start_address=IPNetwork('10.1.1.2/24'),
end_address=IPNetwork('10.1.1.10/24'),
status='active'
)
def test_ipaddress_has_checkbox_iprange_does_not(self):
data = annotate_ip_space(self.prefix)
table = AnnotatedIPAddressTable(data, orderable=False)
table.columns.show('pk')
request = RequestFactory().get('/')
html = table.as_html(request)
ipaddress_checkbox_count = html.count(f'name="pk" value="{self.ip_address.pk}"')
self.assertEqual(ipaddress_checkbox_count, 1)
iprange_checkbox_count = html.count(f'name="pk" value="{self.ip_range.pk}"')
self.assertEqual(iprange_checkbox_count, 0)

View File

@@ -49,9 +49,6 @@ def add_requested_prefixes(parent, prefix_list, show_available=True, show_assign
if prefix_list and show_available:
# Find all unallocated space, add fake Prefix objects to child_prefixes.
# IMPORTANT: These are unsaved Prefix instances (pk=None). If this is ever changed to use
# saved Prefix instances with real pks, bulk delete will fail for mixed-type selections
# due to single-model form validation. See: https://github.com/netbox-community/netbox/issues/21176
available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
child_prefixes = child_prefixes + available_prefixes

View File

@@ -370,33 +370,6 @@
</table>
</div>
{% endif %}
{% if object.is_lag %}
<div class="card">
<h2 class="card-header">{% trans "LAG Members" %}</h2>
<table class="table table-hover">
<thead>
<tr>
<th>{% trans "Parent" %}</th>
<th>{% trans "Interface" %}</th>
<th>{% trans "Type" %}</th>
</tr>
</thead>
<tbody>
{% for member in object.member_interfaces.all %}
<tr>
<td>{{ member.device|linkify }}</td>
<td>{{ member|linkify }}</td>
<td>{{ member.get_type_display }}</td>
</tr>
{% empty %}
<tr>
<td colspan="3" class="text-muted">{% trans "No member interfaces" %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% include 'ipam/inc/panels/fhrp_groups.html' %}
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
@@ -441,6 +414,13 @@
{% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
</div>
</div>
{% if object.is_lag %}
<div class="row mb-3">
<div class="col col-md-12">
{% include 'inc/panel_table.html' with table=lag_interfaces_table heading="LAG Members" %}
</div>
</div>
{% endif %}
{% if object.vlan_translation_policy %}
<div class="row mb-3">
<div class="col col-md-12">

View File

@@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-21 05:07+0000\n"
"POT-Creation-Date: 2026-01-22 05:07+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@@ -12037,7 +12037,7 @@ msgstr ""
msgid "date synced"
msgstr ""
#: netbox/netbox/models/features.py:623
#: netbox/netbox/models/features.py:621
#, python-brace-format
msgid "{class_name} must implement a sync_data() method."
msgstr ""
@@ -13935,8 +13935,8 @@ msgid "No VLANs Assigned"
msgstr ""
#: netbox/templates/dcim/inc/interface_vlans_table.html:44
#: netbox/templates/ipam/prefix_list.html:16
#: netbox/templates/ipam/prefix_list.html:33
#: netbox/templates/ipam/inc/max_depth.html:11
#: netbox/templates/ipam/inc/max_length.html:11
msgid "Clear"
msgstr ""
@@ -15053,8 +15053,8 @@ msgstr ""
msgid "Date Added"
msgstr ""
#: netbox/templates/ipam/aggregate/prefixes.html:8
#: netbox/templates/ipam/prefix/prefixes.html:8
#: netbox/templates/ipam/aggregate/prefixes.html:10
#: netbox/templates/ipam/prefix/prefixes.html:10
#: netbox/templates/ipam/role.html:10
msgid "Add Prefix"
msgstr ""
@@ -15083,6 +15083,14 @@ msgstr ""
msgid "Bulk Create"
msgstr ""
#: netbox/templates/ipam/inc/max_depth.html:6
msgid "Max Depth"
msgstr ""
#: netbox/templates/ipam/inc/max_length.html:6
msgid "Max Length"
msgstr ""
#: netbox/templates/ipam/inc/panels/fhrp_groups.html:10
msgid "Create Group"
msgstr ""
@@ -15184,14 +15192,6 @@ msgstr ""
msgid "Hide Depth Indicators"
msgstr ""
#: netbox/templates/ipam/prefix_list.html:11
msgid "Max Depth"
msgstr ""
#: netbox/templates/ipam/prefix_list.html:28
msgid "Max Length"
msgstr ""
#: netbox/templates/ipam/rir.html:10
msgid "Add Aggregate"
msgstr ""

View File

@@ -252,3 +252,16 @@ def isodatetime(value, spec='seconds'):
else:
return ''
return mark_safe(f'<span title="{naturaltime(value)}">{text}</span>')
@register.filter
def truncate_middle(value, length):
if len(value) <= length:
return value
# Calculate split points for the two parts
half_len = (length - 1) // 2 # 1 for the ellipsis
first_part = value[:half_len]
second_part = value[len(value) - (length - 1 - half_len):]
return mark_safe(f"{first_part}&hellip;{second_part}")