first commit
This commit is contained in:
374
netbox_librenms_plugin/views/base/interfaces_view.py
Normal file
374
netbox_librenms_plugin/views/base/interfaces_view.py
Normal file
@@ -0,0 +1,374 @@
|
||||
from django.contrib import messages
|
||||
from django.core.cache import cache
|
||||
from django.shortcuts import get_object_or_404, redirect, render
|
||||
from django.utils import timezone
|
||||
from django.views import View
|
||||
|
||||
from netbox_librenms_plugin.utils import (
|
||||
get_interface_name_field,
|
||||
get_virtual_chassis_member,
|
||||
)
|
||||
from netbox_librenms_plugin.views.mixins import (
|
||||
CacheMixin,
|
||||
LibreNMSAPIMixin,
|
||||
LibreNMSPermissionMixin,
|
||||
VlanAssignmentMixin,
|
||||
)
|
||||
|
||||
|
||||
class BaseInterfaceTableView(VlanAssignmentMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin, CacheMixin, View):
|
||||
"""
|
||||
Base view for fetching interface data from LibreNMS and generating table data.
|
||||
Includes VLAN enrichment for interface VLAN sync functionality.
|
||||
"""
|
||||
|
||||
model = None # To be defined in subclasses
|
||||
partial_template_name = "netbox_librenms_plugin/_interface_sync_content.html"
|
||||
interface_name_field = None
|
||||
|
||||
def get_object(self, pk):
|
||||
"""Retrieve the object (Device or VirtualMachine)."""
|
||||
return get_object_or_404(self.model, pk=pk)
|
||||
|
||||
def get_ip_address(self, obj):
|
||||
"""Get the primary IP address for the object."""
|
||||
if obj.primary_ip:
|
||||
return str(obj.primary_ip.address.ip)
|
||||
return None
|
||||
|
||||
def get_interfaces(self, obj):
|
||||
"""
|
||||
Get interfaces related to the object.
|
||||
Should be implemented in subclasses.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_redirect_url(self, obj):
|
||||
"""
|
||||
Get the redirect URL for the object.
|
||||
Should be implemented in subclasses.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def get_select_related_field(self, obj):
|
||||
"""Determine the appropriate select_related field based on object type"""
|
||||
if self.model.__name__.lower() == "virtualmachine":
|
||||
return "virtual_machine"
|
||||
return "device"
|
||||
|
||||
def get_table(self, data, obj, interface_name_field, vlan_groups=None):
|
||||
"""
|
||||
Returns the table class to use for rendering interface data.
|
||||
Can be overridden by subclasses to use different tables.
|
||||
|
||||
Args:
|
||||
data: List of port data dicts
|
||||
obj: Device or VirtualMachine object
|
||||
interface_name_field: Field to use for interface name ('ifName' or 'ifDescr')
|
||||
vlan_groups: List of VLANGroup objects for VLAN group dropdowns
|
||||
"""
|
||||
raise NotImplementedError("Subclasses must implement get_table()")
|
||||
|
||||
def post(self, request, pk):
|
||||
"""Handle POST request to fetch and cache LibreNMS interface data for an object."""
|
||||
obj = self.get_object(pk)
|
||||
|
||||
interface_name_field = get_interface_name_field(request)
|
||||
|
||||
# Get librenms_id at the start
|
||||
self.librenms_id = self.librenms_api.get_librenms_id(obj)
|
||||
|
||||
if not self.librenms_id:
|
||||
messages.error(request, "Device not found in LibreNMS.")
|
||||
return redirect(self.get_redirect_url(obj))
|
||||
|
||||
success, librenms_data = self.librenms_api.get_ports(self.librenms_id)
|
||||
|
||||
if not success:
|
||||
messages.error(request, librenms_data)
|
||||
return redirect(self.get_redirect_url(obj))
|
||||
|
||||
# Enrich ports with VLAN data for trunk ports
|
||||
ports = librenms_data.get("ports", [])
|
||||
enriched_ports = self._enrich_ports_with_vlan_data(ports, interface_name_field)
|
||||
librenms_data["ports"] = enriched_ports
|
||||
|
||||
_server_key = self.librenms_api.server_key
|
||||
# Store data in cache (keyed by server to avoid cross-server collisions)
|
||||
cache.set(
|
||||
self.get_cache_key(obj, "ports", _server_key),
|
||||
librenms_data,
|
||||
timeout=self.librenms_api.cache_timeout,
|
||||
)
|
||||
last_fetched = timezone.now()
|
||||
cache.set(
|
||||
self.get_last_fetched_key(obj, "ports", _server_key),
|
||||
last_fetched,
|
||||
timeout=self.librenms_api.cache_timeout,
|
||||
)
|
||||
|
||||
messages.success(request, "Interface data refreshed successfully.")
|
||||
|
||||
context = self.get_context_data(request, obj, interface_name_field, _server_key)
|
||||
context = {"interface_sync": context}
|
||||
context["interface_name_field"] = interface_name_field
|
||||
|
||||
return render(request, self.partial_template_name, context)
|
||||
|
||||
def _enrich_ports_with_vlan_data(self, ports, interface_name_field):
|
||||
"""
|
||||
Enrich port data with VLAN information from LibreNMS.
|
||||
|
||||
With LibreNMS 24.2.0+, the get_ports() call with with_vlans=True returns
|
||||
detailed VLAN associations (tagged/untagged) for all ports. The
|
||||
parse_port_vlan_data() method handles both the new vlans array format
|
||||
and falls back to ifVlan for older LibreNMS versions.
|
||||
|
||||
Args:
|
||||
ports: List of port dicts from get_ports(with_vlans=True)
|
||||
interface_name_field: Field to use for interface name
|
||||
|
||||
Returns:
|
||||
List of enriched port dicts with VLAN data
|
||||
"""
|
||||
enriched = []
|
||||
for port in ports:
|
||||
# Parse VLAN data - handles both vlans array (new) and ifVlan fallback (old)
|
||||
parsed = self.librenms_api.parse_port_vlan_data(port, interface_name_field)
|
||||
port.update(parsed)
|
||||
enriched.append(port)
|
||||
return enriched
|
||||
|
||||
def get_context_data(self, request, obj, interface_name_field, server_key=None):
|
||||
"""Get the context data for the interface sync view."""
|
||||
ports_data = []
|
||||
table = None
|
||||
netbox_only_interfaces = []
|
||||
|
||||
if interface_name_field is None:
|
||||
interface_name_field = get_interface_name_field(request)
|
||||
|
||||
if server_key is None:
|
||||
server_key = getattr(self.librenms_api, "server_key", None)
|
||||
|
||||
cached_data = cache.get(self.get_cache_key(obj, "ports", server_key))
|
||||
last_fetched = cache.get(self.get_last_fetched_key(obj, "ports", server_key))
|
||||
|
||||
# Get VLAN groups for dropdown
|
||||
vlan_groups = self.get_vlan_groups_for_device(obj)
|
||||
lookup_maps = self._build_vlan_lookup_maps(vlan_groups)
|
||||
|
||||
# Load any user VLAN group overrides from cache (set by "apply to all")
|
||||
vlan_group_overrides = cache.get(self.get_vlan_overrides_key(obj, server_key)) or {}
|
||||
|
||||
if cached_data:
|
||||
ports_data = cached_data.get("ports", [])
|
||||
|
||||
# Pre-fetch all interfaces for all potential chassis members
|
||||
interfaces_by_device = {}
|
||||
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
|
||||
for member in obj.virtual_chassis.members.all():
|
||||
interfaces_by_device[member.id] = {
|
||||
interface.name: interface
|
||||
for interface in self.get_interfaces(member).select_related(self.get_select_related_field(obj))
|
||||
}
|
||||
else:
|
||||
interfaces_by_device[obj.id] = {
|
||||
interface.name: interface
|
||||
for interface in self.get_interfaces(obj).select_related(self.get_select_related_field(obj))
|
||||
}
|
||||
|
||||
for port in ports_data:
|
||||
port["enabled"] = (
|
||||
True
|
||||
if port.get("ifAdminStatus") is None
|
||||
else (
|
||||
port["ifAdminStatus"].lower() == "up"
|
||||
if isinstance(port["ifAdminStatus"], str)
|
||||
else bool(port["ifAdminStatus"])
|
||||
)
|
||||
)
|
||||
|
||||
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
|
||||
chassis_member = get_virtual_chassis_member(obj, port.get(interface_name_field))
|
||||
device_interfaces = interfaces_by_device.get(chassis_member.id, {})
|
||||
else:
|
||||
device_interfaces = interfaces_by_device[obj.id]
|
||||
|
||||
netbox_interface = device_interfaces.get(port.get(interface_name_field))
|
||||
port["exists_in_netbox"] = bool(netbox_interface)
|
||||
port["netbox_interface"] = netbox_interface
|
||||
|
||||
if port.get("ifAlias") in (port.get("ifDescr"), port.get("ifName")):
|
||||
port["ifAlias"] = ""
|
||||
|
||||
# Add VLAN group auto-selection data to port, applying any user overrides
|
||||
self._add_vlan_group_selection(port, lookup_maps, obj, vlan_group_overrides)
|
||||
|
||||
# Add missing VLANs info for warning display
|
||||
self._add_missing_vlans_info(port, lookup_maps)
|
||||
|
||||
table = self.get_table(ports_data, obj, interface_name_field, vlan_groups=vlan_groups)
|
||||
table.configure(request)
|
||||
|
||||
# Identify NetBox-only interfaces (interfaces in NetBox but not in LibreNMS)
|
||||
librenms_interface_names = {
|
||||
port.get(interface_name_field) for port in ports_data if port.get(interface_name_field)
|
||||
}
|
||||
|
||||
netbox_only_interfaces = []
|
||||
for device_id, device_interfaces in interfaces_by_device.items():
|
||||
for interface_name, interface in device_interfaces.items():
|
||||
if interface_name not in librenms_interface_names:
|
||||
# Get device name for the interface
|
||||
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
|
||||
device = obj.virtual_chassis.members.get(id=device_id)
|
||||
device_name = device.name
|
||||
else:
|
||||
device_name = obj.name
|
||||
|
||||
netbox_only_interfaces.append(
|
||||
{
|
||||
"id": interface.id,
|
||||
"name": interface.name,
|
||||
"device_name": device_name,
|
||||
"device_id": device_id,
|
||||
"type": str(interface.type)
|
||||
if hasattr(interface, "type") and interface.type
|
||||
else "Virtual"
|
||||
if hasattr(interface, "virtual_machine")
|
||||
else "Unknown",
|
||||
"enabled": interface.enabled,
|
||||
"description": interface.description or "",
|
||||
"url": interface.get_absolute_url(),
|
||||
}
|
||||
)
|
||||
|
||||
virtual_chassis_members = []
|
||||
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
|
||||
virtual_chassis_members = obj.virtual_chassis.members.all()
|
||||
|
||||
cache_ttl = cache.ttl(self.get_cache_key(obj, "ports", server_key))
|
||||
cache_expiry = (
|
||||
timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl is not None and cache_ttl > 0 else None
|
||||
)
|
||||
|
||||
return {
|
||||
"object": obj,
|
||||
"table": table,
|
||||
"vlan_groups": vlan_groups,
|
||||
"last_fetched": last_fetched,
|
||||
"cache_expiry": cache_expiry,
|
||||
"virtual_chassis_members": virtual_chassis_members,
|
||||
"interface_name_field": interface_name_field,
|
||||
"netbox_only_interfaces": netbox_only_interfaces,
|
||||
"server_key": server_key,
|
||||
}
|
||||
|
||||
def _add_vlan_group_selection(self, port, lookup_maps, device, vlan_group_overrides=None):
|
||||
"""
|
||||
Add per-VLAN group auto-selection data to port record.
|
||||
|
||||
Sets:
|
||||
- vlan_group_map: {vid: {"group_id": str, "group_name": str, "is_ambiguous": bool}}
|
||||
Maps each VID to its auto-selected VLAN group based on scope hierarchy.
|
||||
If vlan_group_overrides contains a user selection for a VID, that takes
|
||||
precedence over auto-selection.
|
||||
"""
|
||||
vid_to_groups = lookup_maps.get("vid_to_groups", {})
|
||||
untagged_vid = port.get("untagged_vlan")
|
||||
tagged_vids = port.get("tagged_vlans", [])
|
||||
|
||||
all_vids = []
|
||||
if untagged_vid:
|
||||
all_vids.append(untagged_vid)
|
||||
all_vids.extend(tagged_vids)
|
||||
|
||||
vlan_group_map = {}
|
||||
for vid in all_vids:
|
||||
groups = vid_to_groups.get(vid, [])
|
||||
if len(groups) == 1:
|
||||
vlan_group_map[vid] = {
|
||||
"group_id": str(groups[0].pk),
|
||||
"group_name": groups[0].name,
|
||||
"is_ambiguous": False,
|
||||
}
|
||||
elif len(groups) > 1:
|
||||
most_specific = self._select_most_specific_group(groups, device)
|
||||
if most_specific:
|
||||
vlan_group_map[vid] = {
|
||||
"group_id": str(most_specific.pk),
|
||||
"group_name": most_specific.name,
|
||||
"is_ambiguous": False,
|
||||
}
|
||||
else:
|
||||
vlan_group_map[vid] = {
|
||||
"group_id": "",
|
||||
"group_name": "Ambiguous",
|
||||
"is_ambiguous": True,
|
||||
}
|
||||
else:
|
||||
vlan_group_map[vid] = {
|
||||
"group_id": "",
|
||||
"group_name": "Global",
|
||||
"is_ambiguous": False,
|
||||
}
|
||||
|
||||
# Apply user overrides from "apply to all" selections (persisted in cache)
|
||||
if vlan_group_overrides:
|
||||
from ipam.models import VLANGroup
|
||||
|
||||
# Batch-fetch all referenced override group IDs to avoid N+1 queries
|
||||
override_group_ids = {
|
||||
vlan_group_overrides[str(vid)]
|
||||
for vid in all_vids
|
||||
if str(vid) in vlan_group_overrides and vlan_group_overrides[str(vid)]
|
||||
}
|
||||
override_groups_by_id = {}
|
||||
if override_group_ids:
|
||||
override_groups_by_id = VLANGroup.objects.in_bulk(list(override_group_ids))
|
||||
|
||||
for vid in all_vids:
|
||||
vid_str = str(vid)
|
||||
if vid_str in vlan_group_overrides:
|
||||
override_group_id = vlan_group_overrides[vid_str]
|
||||
if override_group_id:
|
||||
group = override_groups_by_id.get(int(override_group_id))
|
||||
if group:
|
||||
vlan_group_map[vid] = {
|
||||
"group_id": str(group.pk),
|
||||
"group_name": group.name,
|
||||
"is_ambiguous": False,
|
||||
}
|
||||
# else: Override references deleted group; keep auto-selection
|
||||
else:
|
||||
# User explicitly chose "No Group (Global)"
|
||||
vlan_group_map[vid] = {
|
||||
"group_id": "",
|
||||
"group_name": "Global",
|
||||
"is_ambiguous": False,
|
||||
}
|
||||
|
||||
port["vlan_group_map"] = vlan_group_map
|
||||
|
||||
def _add_missing_vlans_info(self, port, lookup_maps):
|
||||
"""
|
||||
Add missing VLANs info to port record for warning display.
|
||||
|
||||
Sets:
|
||||
- missing_vlans: List of VIDs not found in any NetBox VLAN group
|
||||
"""
|
||||
vid_to_vlans = lookup_maps.get("vid_to_vlans", {})
|
||||
missing_vlans = []
|
||||
|
||||
untagged_vid = port.get("untagged_vlan")
|
||||
tagged_vids = port.get("tagged_vlans", [])
|
||||
|
||||
if untagged_vid and untagged_vid not in vid_to_vlans:
|
||||
missing_vlans.append(untagged_vid)
|
||||
|
||||
for vid in tagged_vids:
|
||||
if vid not in vid_to_vlans:
|
||||
missing_vlans.append(vid)
|
||||
|
||||
port["missing_vlans"] = missing_vlans
|
||||
Reference in New Issue
Block a user