first commit
Some checks failed
ci / deploy (push) Has been cancelled
CodeQL Advanced / Analyze (actions) (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled

This commit is contained in:
Vlastislav Svatek
2026-06-05 10:39:05 +02:00
commit 673e67106e
217 changed files with 76612 additions and 0 deletions

View File

@@ -0,0 +1,13 @@
from .cables_view import BaseCableTableView
from .interfaces_view import BaseInterfaceTableView
from .ip_addresses_view import BaseIPAddressTableView
from .librenms_sync_view import BaseLibreNMSSyncView
from .vlan_table_view import BaseVLANTableView
__all__ = [
"BaseCableTableView",
"BaseInterfaceTableView",
"BaseIPAddressTableView",
"BaseLibreNMSSyncView",
"BaseVLANTableView",
]

View File

@@ -0,0 +1,576 @@
import json
from dcim.models import Device, Interface
from django.contrib import messages
from django.core.cache import cache
from django.core.exceptions import MultipleObjectsReturned
from django.db.models import Q
from django.http import JsonResponse
from django.middleware.csrf import get_token
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
from django.utils import timezone
from django.utils.html import escape
from django.views import View
from netbox_librenms_plugin.utils import (
get_interface_name_field,
get_librenms_sync_device,
get_virtual_chassis_member,
)
from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin
def _librenms_id_q(server_key: str, value) -> Q:
"""
Return a combined Q matching JSON-field and legacy bare-int librenms_id.
Matches both integer and string representations to handle any stored format.
"""
if isinstance(value, bool):
return Q(pk__isnull=True) & Q(pk__isnull=False) # match nothing
q = Q(**{f"custom_field_data__librenms_id__{server_key}": value}) | Q(custom_field_data__librenms_id=value)
try:
int_val = int(value)
str_val = str(int_val)
if int_val != value: # value was a string; also add the integer variant
q |= Q(**{f"custom_field_data__librenms_id__{server_key}": int_val})
q |= Q(custom_field_data__librenms_id=int_val)
if str_val != value: # value was an integer; also add the string variant
q |= Q(**{f"custom_field_data__librenms_id__{server_key}": str_val})
q |= Q(custom_field_data__librenms_id=str_val)
except (TypeError, ValueError):
pass
return q
class BaseCableTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""
Base view for synchronizing cable information from LibreNMS.
"""
model = None # To be defined in subclasses
partial_template_name = "netbox_librenms_plugin/_cable_sync_content.html"
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_ports_data(self, obj):
"""Get ports data without affecting cache"""
server_key = self.librenms_api.server_key
cached_data = cache.get(self.get_cache_key(obj, "ports", server_key))
if cached_data:
return cached_data
success, data = self.librenms_api.get_ports(self.librenms_id)
if not success:
return {"ports": []}
return data
def get_links_data(self, obj):
"""Fetch links data from LibreNMS for the device and add local port names."""
self.librenms_id = self.librenms_api.get_librenms_id(obj)
success, data = self.librenms_api.get_device_links(self.librenms_id)
if not success or "error" in data:
return None
interface_name_field = get_interface_name_field(getattr(self, "request", None))
ports_data = self.get_ports_data(obj)
local_ports_map = {}
for port in ports_data.get("ports", []):
raw_port_id = port.get("port_id")
if raw_port_id is None:
continue
port_id = str(raw_port_id)
port_name = port.get(interface_name_field)
if port_name is None:
continue
local_ports_map[port_id] = port_name
links = data.get("links", [])
links_data = []
for link in links:
local_port_name = local_ports_map.get(str(link.get("local_port_id")))
links_data.append(
{
"local_port": local_port_name,
"local_port_id": link.get("local_port_id"),
"remote_port": link.get("remote_port"),
"remote_device": link.get("remote_hostname"),
"remote_port_id": link.get("remote_port_id"),
"remote_device_id": link.get("remote_device_id"),
}
)
return links_data
def get_device_by_id_or_name(self, remote_device_id, hostname, server_key=None):
"""Try to find device in NetBox first by librenms_id custom field, then by name"""
if server_key is None:
server_key = self.librenms_api.server_key
# First try matching by LibreNMS ID
if remote_device_id is not None:
try:
device = Device.objects.get(_librenms_id_q(server_key, remote_device_id))
return device, True, None
except Device.DoesNotExist:
pass
except MultipleObjectsReturned:
return (
None,
False,
f"Multiple devices found with the same LibreNMS ID: {remote_device_id}.",
)
# Fall back to name matching if no device found by ID
try:
device = Device.objects.get(name=hostname)
return device, True, None
except Device.DoesNotExist:
# Try without domain name
simple_hostname = hostname.split(".")[0]
try:
device = Device.objects.get(name=simple_hostname)
return device, True, None
except Device.DoesNotExist:
return None, False, None
except MultipleObjectsReturned:
return (
None,
False,
f"Multiple devices found with the same name: {hostname}.",
)
except MultipleObjectsReturned:
return (
None,
False,
f"Multiple devices found with the same name: {hostname}.",
)
def enrich_local_port(self, link, obj, server_key=None):
"""Add local port URL if interface exists in NetBox"""
if local_port := link.get("local_port"):
interface = None
local_port_id = link.get("local_port_id")
if server_key is None:
server_key = self.librenms_api.server_key
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
chassis_member = get_virtual_chassis_member(obj, local_port)
if chassis_member:
# First try to find interface by librenms_id
if local_port_id:
interface = chassis_member.interfaces.filter(_librenms_id_q(server_key, local_port_id)).first()
# Only if librenms_id match fails, try matching by name
if not interface:
interface = chassis_member.interfaces.filter(name=local_port).first()
else:
# First try to find interface by librenms_id
if local_port_id:
interface = obj.interfaces.filter(_librenms_id_q(server_key, local_port_id)).first()
# Only if librenms_id match fails, try matching by name
if not interface:
interface = obj.interfaces.filter(name=local_port).first()
if interface:
link["local_port_url"] = reverse("dcim:interface", args=[interface.pk])
link["netbox_local_interface_id"] = interface.pk
def enrich_remote_port(self, link, device, server_key=None):
"""Add remote port URL if device and interface exist in NetBox"""
if remote_port := link.get("remote_port"):
netbox_remote_interface = None
librenms_remote_port_id = link.get("remote_port_id")
if server_key is None:
server_key = self.librenms_api.server_key
# Handle virtual chassis case
if hasattr(device, "virtual_chassis") and device.virtual_chassis:
# Get the appropriate chassis member based on the port name
chassis_member = get_virtual_chassis_member(device, remote_port)
if chassis_member:
# First try to find interface by librenms_id
if librenms_remote_port_id:
netbox_remote_interface = chassis_member.interfaces.filter(
_librenms_id_q(server_key, librenms_remote_port_id)
).first()
# If not found by librenms_id, fall back to name matching on the correct chassis member
if not netbox_remote_interface:
netbox_remote_interface = chassis_member.interfaces.filter(name=remote_port).first()
else:
# Non-virtual chassis case
# First try to find interface by librenms_id
if librenms_remote_port_id:
netbox_remote_interface = device.interfaces.filter(
_librenms_id_q(server_key, librenms_remote_port_id)
).first()
# If not found by librenms_id, fall back to name matching
if not netbox_remote_interface:
netbox_remote_interface = device.interfaces.filter(name=remote_port).first()
if netbox_remote_interface:
link["remote_port_url"] = reverse("dcim:interface", args=[netbox_remote_interface.pk])
link["netbox_remote_interface_id"] = netbox_remote_interface.pk
link["remote_port_name"] = netbox_remote_interface.name
return link
def check_cable_status(self, link):
"""Check cable status and add cable URL if cable exists in NetBox"""
local_interface_id = link.get("netbox_local_interface_id")
remote_interface_id = link.get("netbox_remote_interface_id")
# Default state
link["can_create_cable"] = False
if local_interface_id and remote_interface_id:
local_interface = Interface.objects.get(pk=local_interface_id)
remote_interface = Interface.objects.get(pk=remote_interface_id)
existing_cable = local_interface.cable or remote_interface.cable
if existing_cable:
link.update(
{
"cable_status": "Cable Found",
"cable_url": reverse("dcim:cable", args=[existing_cable.pk]),
}
)
else:
link.update({"cable_status": "No Cable", "can_create_cable": True})
else:
link["cable_status"] = (
"Both Interfaces Not Found in Netbox"
if not (local_interface_id or remote_interface_id)
else "Local Interface Not Found in Netbox"
if not local_interface_id
else "Remote Interface Not Found in Netbox"
)
return link
def process_remote_device(self, link, remote_hostname, remote_device_id, server_key=None):
"""Process remote device data and add remote device URL if device exists in NetBox"""
device, found, error_message = self.get_device_by_id_or_name(
remote_device_id, remote_hostname, server_key=server_key
)
if found:
link.update(
{
"remote_device_url": reverse("dcim:device", args=[device.pk]),
"netbox_remote_device_id": device.pk,
}
)
return self.enrich_remote_port(link, device, server_key=server_key)
link.update(
{
"remote_port_name": link["remote_port"],
"cable_status": error_message if error_message else "Device Not Found in NetBox",
"can_create_cable": False,
}
)
return link
def enrich_links_data(self, links_data, obj, server_key=None):
"""Enrich links data with local and remote port URLs and cable status."""
for link in links_data:
self.enrich_local_port(link, obj, server_key=server_key)
link["device_id"] = obj.id
if remote_hostname := link.get("remote_device"):
link = self.process_remote_device(
link, remote_hostname, link.get("remote_device_id"), server_key=server_key
)
if link.get("netbox_remote_device_id"):
link = self.check_cable_status(link)
return links_data
def get_table(self, data, obj):
"""Get the table instance for the view."""
table = super().get_table(data, obj)
server_key = self.librenms_api.server_key
table.htmx_url = f"{self.request.path}?tab=cables" + (f"&server_key={server_key}" if server_key else "")
return table
def _prepare_context(self, request, obj, fetch_fresh=False):
"""Helper method to prepare the context data for cable sync views."""
table = None
cache_expiry = None
server_key = self.librenms_api.server_key
# For VC devices, cache under the sync device's key so SingleCableVerifyView reads the same entry.
cache_device = get_librenms_sync_device(obj, server_key=server_key) or obj
if fetch_fresh:
# Always fetch new data when requested
links_data = self.get_links_data(obj)
if not links_data:
return None
else:
# Try to use cached data
cached_links_data = cache.get(self.get_cache_key(cache_device, "links", server_key))
if cached_links_data:
links_data = cached_links_data.get("links", [])
else:
return None
if not fetch_fresh:
# Strip derived fields so re-enrichment starts from raw link data;
# without this, stale IDs/URLs persist when NetBox objects are
# deleted and cause DoesNotExist in check_cable_status().
_raw_keys = {
"local_port",
"local_port_id",
"remote_port",
"remote_device",
"remote_port_id",
"remote_device_id",
}
links_data = [{k: v for k, v in link.items() if k in _raw_keys} for link in links_data]
# Enrich data in both cases to ensure current NetBox state
links_data = self.enrich_links_data(links_data, obj, server_key=server_key)
# Cache after enrichment so verify/sync views read current NetBox state
cache_key = self.get_cache_key(cache_device, "links", server_key)
if fetch_fresh:
cache.set(
cache_key,
{"links": links_data},
timeout=self.librenms_api.cache_timeout,
)
else:
# Write enriched data back, preserving original TTL
remaining_ttl = cache.ttl(cache_key)
if remaining_ttl and remaining_ttl > 0:
cache.set(cache_key, {"links": links_data}, timeout=remaining_ttl)
# Calculate cache expiry
cache_ttl = cache.ttl(cache_key)
if cache_ttl is not None and cache_ttl > 0:
cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl)
# Generate the table
table = self.get_table(links_data, obj)
table.configure(request)
# Prepare and return the context
return {
"table": table,
"object": obj,
"cache_expiry": cache_expiry,
"server_key": server_key,
}
def get_context_data(self, request, obj):
"""Get the context data for the cable sync view."""
context = self._prepare_context(request, obj, fetch_fresh=False)
if context is None:
# No data found; return context with empty table
context = {"table": None, "object": obj, "cache_expiry": None, "server_key": self.librenms_api.server_key}
return context
def post(self, request, pk):
"""Handle POST request for cable sync view."""
obj = self.get_object(pk)
context = self._prepare_context(request, obj, fetch_fresh=True)
if context is None:
messages.error(request, "No links found in LibreNMS")
return render(
request,
self.partial_template_name,
{
"cable_sync": {
"object": obj,
"table": None,
"cache_expiry": None,
"server_key": self.librenms_api.server_key,
}
},
)
messages.success(request, "Cable data refreshed successfully.")
return render(
request,
self.partial_template_name,
{"cable_sync": context},
)
class SingleCableVerifyView(BaseCableTableView):
"""
View to verify a single cable link between two devices.
"""
def post(self, request):
data = json.loads(request.body)
selected_device_id = data.get("device_id")
local_port_id = data.get("local_port_id")
# Read server_key from POST so we use the exact server the user was viewing
server_key = data.get("server_key")
if not server_key:
server_key = self.librenms_api.server_key
formatted_row = {
"local_port": "",
"remote_port": "",
"remote_device": "",
"cable_status": "Missing Ports",
"actions": "",
}
if selected_device_id:
selected_device = get_object_or_404(Device, pk=selected_device_id)
# Use the same sync-device resolution as the GET path so the cache
# key matches what _prepare_context wrote. When the VC has no
# resolvable sync device, return an empty row rather than crashing.
if selected_device.virtual_chassis:
primary_device = get_librenms_sync_device(selected_device, server_key=server_key)
if primary_device is None:
return JsonResponse({"status": "success", "formatted_row": formatted_row})
else:
primary_device = selected_device
cached_links = cache.get(self.get_cache_key(primary_device, "links", server_key))
if cached_links:
link_data = next(
(
link
for link in cached_links.get("links", [])
if str(link.get("local_port_id", "")) == str(local_port_id)
),
None,
)
if link_data:
# Strip derived fields from cached data to avoid stale
# IDs/URLs when NetBox objects are deleted after caching.
_raw_keys = {
"local_port",
"local_port_id",
"remote_port",
"remote_device",
"remote_port_id",
"remote_device_id",
}
link_data = {k: v for k, v in link_data.items() if k in _raw_keys}
# Re-enrich remote side from current NetBox state
remote_hostname = link_data.get("remote_device", "")
if remote_hostname:
link_data = self.process_remote_device(
link_data, remote_hostname, link_data.get("remote_device_id"), server_key=server_key
)
local_port = link_data.get("local_port", "")
formatted_row["local_port"] = local_port
# First try to find interface by librenms_id (handle VC members)
_sk = server_key
interface = None
lookup_device = selected_device
if local_port and hasattr(selected_device, "virtual_chassis") and selected_device.virtual_chassis:
chassis_member = get_virtual_chassis_member(selected_device, local_port)
if chassis_member:
lookup_device = chassis_member
if local_port_id:
interface = lookup_device.interfaces.filter(_librenms_id_q(_sk, local_port_id)).first()
# If not found by librenms_id, try matching by name
if not interface and local_port:
interface = lookup_device.interfaces.filter(name=local_port).first()
if interface:
link_data["netbox_local_interface_id"] = interface.pk
# Check cable status if remote side was resolved
if link_data.get("netbox_remote_device_id"):
link_data = self.check_cable_status(link_data)
# Escape LibreNMS-sourced labels to prevent XSS
safe_local_port = escape(local_port)
remote_port_name = link_data.get("remote_port_name", link_data.get("remote_port", ""))
safe_remote_port = escape(remote_port_name)
remote_device_name = link_data.get("remote_device", "")
safe_remote_device = escape(remote_device_name)
safe_cable_status = escape(link_data.get("cable_status", "Missing Ports"))
formatted_row["cable_status"] = safe_cable_status
formatted_row["local_port"] = (
f'<a href="{reverse("dcim:interface", args=[interface.pk])}">{safe_local_port}</a>'
)
formatted_row["remote_port"] = (
f'<a href="{link_data["remote_port_url"]}">{safe_remote_port}</a>'
if link_data.get("remote_port_url")
else safe_remote_port
)
formatted_row["remote_device"] = (
f'<a href="{link_data["remote_device_url"]}">{safe_remote_device}</a>'
if link_data.get("remote_device_url")
else safe_remote_device
)
if link_data.get("cable_url"):
formatted_row["cable_status"] = (
f'<a href="{link_data["cable_url"]}">{safe_cable_status}</a>'
)
if link_data.get("can_create_cable"):
csrf_token = get_token(request)
server_key_input = (
f'<input type="hidden" name="server_key" value="{escape(str(server_key))}">'
if server_key
else ""
)
formatted_row["actions"] = f"""
<form method="post" action="{reverse("plugins:netbox_librenms_plugin:sync_device_cables", args=[selected_device.id])}">
<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">
<input type="hidden" name="select" value="{escape(str(local_port_id))}">
{server_key_input}
<button type="submit" class="btn btn-sm btn-primary">Sync Cable</button>
</form>
"""
else:
formatted_row["local_port"] = escape(local_port)
# Keep remote port name visible, add URL if available
remote_port_name = link_data.get("remote_port_name", link_data.get("remote_port", ""))
safe_remote_port = escape(remote_port_name)
formatted_row["remote_port"] = (
f'<a href="{link_data["remote_port_url"]}">{safe_remote_port}</a>'
if link_data.get("remote_port_url")
else safe_remote_port
)
# Keep remote device name visible, add URL if available
remote_device_name = link_data.get("remote_device", "")
safe_remote_device = escape(remote_device_name)
formatted_row["remote_device"] = (
f'<a href="{link_data["remote_device_url"]}">{safe_remote_device}</a>'
if link_data.get("remote_device_url")
else safe_remote_device
)
# First check if remote device exists in NetBox
if remote_device_name and not link_data.get("remote_device_url"):
formatted_row["cable_status"] = "Device Not Found in NetBox"
# Then check interface status
elif link_data.get("remote_device_url") and link_data.get("remote_port_url"):
formatted_row["cable_status"] = "Local Interface Not Found in NetBox"
else:
formatted_row["cable_status"] = "Missing Interface"
formatted_row["actions"] = ""
return JsonResponse({"status": "success", "formatted_row": formatted_row})

View 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

View File

@@ -0,0 +1,498 @@
import json
from dcim.models import Device
from django.contrib import messages
from django.core.cache import cache
from django.http import Http404, JsonResponse
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.views import View
from ipam.models import VRF, IPAddress
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.tables.ipaddresses import IPAddressTable
from netbox_librenms_plugin.utils import get_interface_name_field, get_librenms_device_id
from netbox_librenms_plugin.views.mixins import CacheMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin
class BaseIPAddressTableView(LibreNMSPermissionMixin, LibreNMSAPIMixin, CacheMixin, View):
"""
Base view for synchronizing IP address information from LibreNMS.
"""
partial_template_name = "netbox_librenms_plugin/_ipaddress_sync_content.html"
interface_name_field = None
def get_object(self, pk):
return get_object_or_404(self.model, pk=pk)
def get_ip_addresses(self, obj):
"""Fetch IP address data from LibreNMS for the given object."""
self.librenms_id = self.librenms_api.get_librenms_id(obj)
return self.librenms_api.get_device_ips(self.librenms_id)
def enrich_ip_data(self, ip_data, obj, interface_name_field):
"""
Enrich IP data with NetBox information in a more efficient manner.
This optimized implementation:
1. Caches port data to reduce API calls
2. Pre-loads all relevant device data
3. Uses dictionary lookups instead of repeated iterations
"""
# Prefetch all necessary data
prefetched_data = self._prefetch_netbox_data(obj)
port_data_cache = {} # Cache for LibreNMS port data to minimize API calls
enriched_data = []
# Process each IP address from LibreNMS
for ip_entry in ip_data:
# Skip invalid entries that are not dictionaries
if not isinstance(ip_entry, dict):
continue
# Skip entries missing required fields
if "port_id" not in ip_entry:
continue
# Get or fetch port data (with caching)
port_info = self._get_port_info(ip_entry["port_id"], port_data_cache, interface_name_field)
# Create enriched IP structure with base data
enriched_ip = self._create_base_ip_entry(ip_entry, obj, prefetched_data["vrfs"])
# Get LibreNMS interface name if available
librenms_interface_name = None
if port_info:
librenms_interface_name = port_info.get(interface_name_field)
enriched_ip["interface_name"] = librenms_interface_name
# IP with mask is already calculated in _create_base_ip_entry
ip_with_mask = enriched_ip["ip_with_mask"]
ip_address = prefetched_data["ip_addresses_map"].get(ip_with_mask)
if ip_address:
# Process existing IP
self._enrich_existing_ip(
enriched_ip,
ip_address,
ip_entry["port_id"],
librenms_interface_name,
prefetched_data,
)
else:
# New IP that doesn't exist in NetBox
enriched_ip["exists"] = False
enriched_ip["status"] = "sync"
# Add interface information (regardless of IP status)
self._add_interface_info_to_ip(
enriched_ip,
ip_entry["port_id"],
librenms_interface_name,
prefetched_data,
)
enriched_data.append(enriched_ip)
return enriched_data
def _prefetch_netbox_data(self, obj):
"""Prefetch all necessary NetBox data to minimize database queries"""
# Get all interfaces for the device
all_interfaces = list(obj.interfaces.all())
# Create maps for efficient lookups
server_key = self.librenms_api.server_key
interfaces_by_librenms_id = {}
for interface in all_interfaces:
lib_id = get_librenms_device_id(interface, server_key, auto_save=False)
if lib_id is not None:
interfaces_by_librenms_id[str(lib_id)] = interface
interfaces_by_name = {interface.name: interface for interface in all_interfaces}
# Get all IP addresses
ip_addresses_map = {
str(ip.address): ip for ip in IPAddress.objects.select_related("assigned_object_type", "vrf")
}
# Get all VRFs
vrfs = list(VRF.objects.all())
return {
"interfaces_by_librenms_id": interfaces_by_librenms_id,
"interfaces_by_name": interfaces_by_name,
"all_interfaces": all_interfaces,
"device": obj,
"ip_addresses_map": ip_addresses_map,
"vrfs": vrfs,
}
def _get_port_info(self, port_id, port_data_cache, interface_name_field):
"""Get port info from LibreNMS with caching to minimize API calls"""
if port_id not in port_data_cache:
success, port_data = self.librenms_api.get_port_by_id(port_id)
if success and "port" in port_data and port_data["port"]:
port_data_cache[port_id] = port_data["port"][0]
else:
port_data_cache[port_id] = None
return port_data_cache[port_id]
def _create_base_ip_entry(self, ip_entry, obj, vrfs):
"""Create the base data structure for an IP entry"""
# Determine if this is an IPv4 or IPv6 address and create unified fields
if "ip_address" in ip_entry and "prefix_length" in ip_entry:
# Use unified format directly if available
ip_address = ip_entry["ip_address"]
prefix_length = ip_entry["prefix_length"]
else:
# Legacy format handling
if "ipv6_compressed" in ip_entry:
ip_address = ip_entry["ipv6_compressed"]
prefix_length = ip_entry["ipv6_prefixlen"]
elif "ipv4_address" in ip_entry:
ip_address = ip_entry["ipv4_address"]
prefix_length = ip_entry["ipv4_prefixlen"]
else:
raise KeyError("No valid IP address format found in LibreNMS data")
ip_with_mask = f"{ip_address}/{prefix_length}"
return {
"ip_address": ip_address,
"prefix_length": prefix_length,
"ip_with_mask": ip_with_mask,
"port_id": ip_entry["port_id"],
"device": obj.name,
"device_url": obj.get_absolute_url(),
"vrf_id": None,
"vrfs": vrfs,
}
def _enrich_existing_ip(self, enriched_ip, ip_address, port_id, librenms_interface_name, prefetched_data):
"""Add information for IP addresses that exist in NetBox"""
enriched_ip["ip_url"] = ip_address.get_absolute_url()
enriched_ip["exists"] = True
# Add VRF info if available
if ip_address.vrf:
enriched_ip["vrf_id"] = ip_address.vrf.pk
enriched_ip["vrf"] = ip_address.vrf.name
# Set initial status to update (will change to matched if criteria met)
enriched_ip["status"] = "update"
# Only proceed if IP is assigned to an object
if not ip_address.assigned_object:
return
assigned_interface = ip_address.assigned_object
# Check if interface matches by LibreNMS ID
if str(port_id) in prefetched_data["interfaces_by_librenms_id"]:
interface = prefetched_data["interfaces_by_librenms_id"][str(port_id)]
if assigned_interface == interface:
enriched_ip["status"] = "matched"
return
# Check if interface matches by name
if librenms_interface_name and assigned_interface.name == librenms_interface_name:
enriched_ip["status"] = "matched"
# Add interface information
enriched_ip["interface_name"] = assigned_interface.name
enriched_ip["interface_url"] = assigned_interface.get_absolute_url()
def _add_interface_info_to_ip(self, enriched_ip, port_id, librenms_interface_name, prefetched_data):
"""Add interface information to the IP entry regardless of IP status"""
# First try to match by LibreNMS ID (highest priority)
if str(port_id) in prefetched_data["interfaces_by_librenms_id"]:
interface = prefetched_data["interfaces_by_librenms_id"][str(port_id)]
enriched_ip["interface_name"] = interface.name
enriched_ip["interface_url"] = interface.get_absolute_url()
return
# Then try to match by interface name
if librenms_interface_name and librenms_interface_name in prefetched_data["interfaces_by_name"]:
interface = prefetched_data["interfaces_by_name"][librenms_interface_name]
# Don't overwrite the interface name from LibreNMS but do add the URL
enriched_ip["interface_url"] = interface.get_absolute_url()
def get_table(self, data, obj, request):
"""Get the table instance for the view."""
table = IPAddressTable(data)
server_key = self.librenms_api.server_key
table.htmx_url = f"{request.path}?tab=ipaddresses" + (f"&server_key={server_key}" if server_key else "")
return table
def _prepare_context(self, request, obj, interface_name_field, fetch_fresh=False):
"""Helper method to prepare the context data for IP address sync views."""
table = None
cache_expiry = None
server_key = self.librenms_api.server_key
if interface_name_field is None:
interface_name_field = get_interface_name_field(request)
if fetch_fresh:
success, ip_data = self.get_ip_addresses(obj)
else:
cached_ip_data = cache.get(self.get_cache_key(obj, "ip_addresses", server_key))
if cached_ip_data:
ip_data = cached_ip_data.get("ip_addresses", [])
else:
return None
# Enrich data in both cases to ensure current NetBox state
ip_data = self.enrich_ip_data(ip_data, obj, interface_name_field)
if fetch_fresh:
# Cache the fresh data after enrichment
cache.set(
self.get_cache_key(obj, "ip_addresses", server_key),
{"ip_addresses": ip_data},
timeout=self.librenms_api.cache_timeout,
)
# Calculate cache expiry
cache_ttl = cache.ttl(self.get_cache_key(obj, "ip_addresses", server_key))
if cache_ttl is not None and cache_ttl > 0:
cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl)
# Generate the table
table = self.get_table(ip_data, obj, request)
table.configure(request)
# Prepare and return the context
return {
"table": table,
"object": obj,
"cache_expiry": cache_expiry,
"server_key": server_key,
}
def get_context_data(self, request, obj):
"""Get the context data for the IP address sync view."""
interface_name_field = get_interface_name_field(request)
context = self._prepare_context(request, obj, interface_name_field, fetch_fresh=False)
if context is None:
# No data found; return context with empty table
context = {"table": None, "object": obj, "cache_expiry": None, "server_key": self.librenms_api.server_key}
return context
def post(self, request, pk):
"""Handle POST request for IP address sync view."""
obj = self.get_object(pk)
interface_name_field = get_interface_name_field(request)
context = self._prepare_context(request, obj, interface_name_field, fetch_fresh=True)
if context is None:
messages.error(request, "No IP addresses found in LibreNMS")
return render(
request,
self.partial_template_name,
{
"ip_sync": {
"object": obj,
"table": None,
"cache_expiry": None,
"server_key": self.librenms_api.server_key,
}
},
)
messages.success(request, "IP address data refreshed successfully.")
return render(
request,
self.partial_template_name,
{"ip_sync": context},
)
class SingleIPAddressVerifyView(LibreNMSPermissionMixin, CacheMixin, View):
"""
View for verifying single IP address data with different VRF.
"""
def _get_object(self, object_id, object_type=None):
"""
Retrieve the object (Device or VirtualMachine) based on ID and optional type.
If type is not provided, tries to determine it by checking both Device and VM models.
"""
if object_type == "device":
return get_object_or_404(Device, pk=object_id)
elif object_type == "virtualmachine":
return get_object_or_404(VirtualMachine, pk=object_id)
else:
# Try to find object without knowing its type
obj = Device.objects.filter(pk=object_id).first()
if obj:
return obj
obj = VirtualMachine.objects.filter(pk=object_id).first()
if obj:
return obj
raise Http404(f"Object with ID {object_id} not found in Device or VirtualMachine models")
def _parse_ip_address(self, ip_address):
"""
Parse IP address string into address and prefix length.
Works with both IPv4 and IPv6 addresses.
"""
ip_address_parts = ip_address.split("/")
address_no_mask = ip_address_parts[0].strip()
if len(ip_address_parts) > 1:
try:
prefix_len = int(ip_address_parts[1])
return address_no_mask, prefix_len
except ValueError:
raise ValueError(f"Invalid prefix length: {ip_address_parts[1]}")
else:
raise ValueError("Prefix length is missing from the IP address")
def _find_in_cache(self, cached_data, address, prefix_len):
"""Find IP address in cache data using unified fields only."""
if not cached_data:
return None, None, None
for ip_entry in cached_data.get("ip_addresses", []):
if ip_entry["ip_address"] == address and str(ip_entry["prefix_length"]) == str(prefix_len):
return (ip_entry, ip_entry.get("vrf_id"), ip_entry.get("port_id"))
return None, None, None
def _find_existing_ip(self, address_no_mask, prefix_len, vrf_id=None):
"""
Find existing IP address in NetBox, optionally with specific VRF.
"""
ip_with_mask = f"{address_no_mask}/{prefix_len}"
# Check if IP exists in any VRF
existing_ip = IPAddress.objects.filter(address=ip_with_mask).first()
if not existing_ip:
return False, False, None
# IP exists in some VRF, check if it exists in the specified VRF
if vrf_id is not None:
existing_in_vrf = IPAddress.objects.filter(address=ip_with_mask, vrf__id=vrf_id).exists()
else:
# Check for global VRF (None)
existing_in_vrf = IPAddress.objects.filter(address=ip_with_mask, vrf__isnull=True).exists()
return True, existing_in_vrf, existing_ip.get_absolute_url()
def _determine_status(self, exists_any_vrf, exists_specific_vrf, original_vrf_id, vrf_id):
"""
Determine the status of an IP address based on existence and VRF.
"""
if exists_any_vrf:
# IP exists in NetBox
if exists_specific_vrf:
return "matched"
else:
return "update"
else:
# IP doesn't exist in NetBox, check if restoring to original VRF
if original_vrf_id is not None and original_vrf_id == vrf_id:
return "matched"
else:
return "sync"
def post(self, request):
"""
POST request to return json response with formatted IP address status.
"""
try:
try:
data = json.loads(request.body)
except json.JSONDecodeError as e:
return JsonResponse({"status": "error", "message": f"Invalid JSON: {e}"}, status=400)
ip_address = data.get("ip_address")
vrf_id = data.get("vrf_id")
object_id = data.get("device_id")
object_type = data.get("object_type")
server_key = data.get("server_key") or "default"
if not ip_address:
return JsonResponse({"status": "error", "message": "No IP address provided"}, status=400)
if not object_id:
return JsonResponse({"status": "error", "message": "No object ID provided"}, status=400)
# Get the object (Device or VirtualMachine)
try:
obj = self._get_object(object_id, object_type)
except Http404 as e:
return JsonResponse({"status": "error", "message": str(e)}, status=404)
# Parse IP address
try:
address_no_mask, prefix_len = self._parse_ip_address(ip_address)
except ValueError as e:
return JsonResponse({"status": "error", "message": str(e)}, status=400)
cache_key = self.get_cache_key(obj, "ip_addresses", server_key)
cached_data = cache.get(cache_key)
# Basic record with default values
updated_record = {
"ip_address": address_no_mask,
"prefix_length": prefix_len,
"ip_with_mask": f"{address_no_mask}/{prefix_len}",
"device": obj.name,
"device_url": obj.get_absolute_url(),
"vrf_id": vrf_id,
"exists": False,
"status": "sync",
}
# Try to find the IP in cache data
cache_entry, original_vrf_id, original_port_id = self._find_in_cache(
cached_data, address_no_mask, prefix_len
)
# Update record with cache data if found
if cache_entry:
# Update with all fields except vrf_id and status
for key, value in cache_entry.items():
if key not in ["vrf_id", "status"]:
updated_record[key] = value
# If no interface found in cache, use first device interface
if original_port_id is None:
interface = obj.interfaces.first()
if interface:
updated_record["interface_name"] = interface.name
updated_record["interface_url"] = interface.get_absolute_url()
# Check if IP exists in NetBox
exists_any_vrf, exists_specific_vrf, ip_url = self._find_existing_ip(address_no_mask, prefix_len, vrf_id)
if exists_any_vrf:
updated_record["exists"] = True
updated_record["ip_url"] = ip_url
# Determine status based on existence and VRF
updated_record["status"] = self._determine_status(
exists_any_vrf, exists_specific_vrf, original_vrf_id, vrf_id
)
# Render status HTML
table = IPAddressTable(data=[])
status_html = table.render_status(updated_record["status"], updated_record)
return JsonResponse(
{
"status": "success",
"ip_address": ip_address,
"formatted_row": {"status": status_html},
}
)
except Exception as e:
return JsonResponse({"status": "error", "message": str(e)}, status=500)

View File

@@ -0,0 +1,547 @@
import re
from django.conf import settings as django_settings
from django.shortcuts import get_object_or_404, render
from netbox.views import generic
from netbox_librenms_plugin.forms import AddToLIbreSNMPV1V2, AddToLIbreSNMPV3
from netbox_librenms_plugin.import_utils import _determine_device_name
from netbox_librenms_plugin.import_utils.virtual_chassis import _generate_vc_member_name
from netbox_librenms_plugin.utils import (
get_interface_name_field,
get_librenms_device_id,
get_librenms_sync_device,
match_librenms_hardware_to_device_type,
resolve_naming_preferences,
)
from netbox_librenms_plugin.views.mixins import LibreNMSAPIMixin, LibreNMSPermissionMixin
class BaseLibreNMSSyncView(LibreNMSPermissionMixin, LibreNMSAPIMixin, generic.ObjectListView):
"""
Base view for LibreNMS sync information.
"""
queryset = None # Will be set in subclasses
model = None # Will be set in subclasses
tab = None # Will be set in subclasses
template_name = "netbox_librenms_plugin/librenms_sync_base.html"
def get(self, request, pk, context=None):
"""Handle GET request for the LibreNMS sync view."""
obj = get_object_or_404(self.model, pk=pk)
# For Virtual Chassis members, always delegate to get_librenms_sync_device() so
# self._librenms_lookup_device and self.librenms_id are consistent with the
# helper-based VC status computed in get_context_data(). A legacy bare-int mapping
# on the viewed member must not shadow an explicit per-server mapping on another
# member — get_librenms_sync_device() applies the full priority order.
librenms_lookup_device = obj
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
sync_device = get_librenms_sync_device(obj, server_key=self.librenms_api.server_key)
if sync_device:
librenms_lookup_device = sync_device
# Store for use in get_context_data (badge generation needs the same object)
self._librenms_lookup_device = librenms_lookup_device
# Get librenms_id using the determined lookup device
self.librenms_id = self.librenms_api.get_librenms_id(librenms_lookup_device)
context = self.get_context_data(request, obj)
return render(request, self.template_name, context)
def get_context_data(self, request, obj):
"""Get the context data for the LibreNMS sync view."""
# Get context from parent classes (including LibreNMSAPIMixin)
context = super().get_context_data()
# Add our specific context
context.update(
{
"object": obj,
"tab": self.tab,
"has_librenms_id": bool(self.librenms_id),
}
)
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
# Use helper function to determine the sync device
librenms_sync_device = get_librenms_sync_device(obj, server_key=self.librenms_api.server_key)
# Determine sync device status
sync_device_has_librenms_id = False
sync_device_has_primary_ip = False
if librenms_sync_device:
sync_device_has_librenms_id = (
get_librenms_device_id(librenms_sync_device, self.librenms_api.server_key, auto_save=False)
is not None
)
sync_device_has_primary_ip = bool(librenms_sync_device.primary_ip)
context.update(
{
"is_vc_member": True,
"sync_device_has_primary_ip": sync_device_has_primary_ip,
"librenms_sync_device": librenms_sync_device,
"sync_device_has_librenms_id": sync_device_has_librenms_id,
}
)
librenms_info = self.get_librenms_device_info(obj, request)
interface_context = self.get_interface_context(request, obj)
cable_context = self.get_cable_context(request, obj)
ip_context = self.get_ip_context(request, obj)
vlan_context = self.get_vlan_context(request, obj)
module_context = self.get_module_context(request, obj)
interface_name_field = get_interface_name_field(request)
# Get platform info for display and sync
platform_info = self._get_platform_info(librenms_info, obj)
# Get manufacturers for platform creation modal
from dcim.models import Manufacturer
manufacturers = Manufacturer.objects.all().order_by("name")
# Detect legacy bare-int librenms_id format for conversion badge
_lookup_device = getattr(self, "_librenms_lookup_device", obj)
_raw_cf = _lookup_device.cf.get("librenms_id") if _lookup_device else None
librenms_id_is_legacy = (isinstance(_raw_cf, int) and not isinstance(_raw_cf, bool)) or (
isinstance(_raw_cf, str) and _raw_cf.isdigit()
)
# Determine if serial match allows legacy ID conversion.
# VMs have no serial field in NetBox; skip the gate so the Convert ID button is enabled.
_librenms_serial = librenms_info["librenms_device_details"].get("librenms_device_serial", "-")
_netbox_serial = getattr(_lookup_device, "serial", "") or ""
_lookup_is_vm = _lookup_device._meta.model_name == "virtualmachine" if _lookup_device else False
librenms_id_serial_confirmed = _lookup_is_vm or bool(
_librenms_serial and _librenms_serial != "-" and _netbox_serial and _librenms_serial == _netbox_serial
)
context.update(
{
"interface_sync": interface_context,
"cable_sync": cable_context,
"ip_sync": ip_context,
"vlan_sync": vlan_context,
"module_sync": module_context,
"v1v2form": AddToLIbreSNMPV1V2(prefix="v1v2"),
"v3form": AddToLIbreSNMPV3(prefix="v3"),
"librenms_device_id": self.librenms_id,
"found_in_librenms": librenms_info.get("found_in_librenms"),
"librenms_device_details": librenms_info.get("librenms_device_details"),
"mismatched_device": librenms_info.get("mismatched_device"),
**librenms_info["librenms_device_details"],
"interface_name_field": interface_name_field,
"platform_info": platform_info,
"vc_inventory_serials": librenms_info["librenms_device_details"].get("vc_inventory_serials", []),
"manufacturers": manufacturers,
"all_server_mappings": self._build_all_server_mappings(_lookup_device, self.librenms_api.server_key),
"librenms_id_is_legacy": librenms_id_is_legacy,
"librenms_id_serial_confirmed": librenms_id_serial_confirmed,
# Lookup device may differ from object (e.g. VC master vs member).
# Used by the Remove server mapping form to post to the correct device.
"lookup_device_pk": _lookup_device.pk if _lookup_device else obj.pk,
"lookup_device_model_name": (
_lookup_device._meta.model_name if _lookup_device else obj._meta.model_name
),
"object_model_name": obj._meta.model_name,
}
)
return context
@staticmethod
def _build_all_server_mappings(obj, active_server_key):
"""
Build a list of all LibreNMS server mappings for the given device.
Each entry describes one server<->ID mapping stored in the ``librenms_id``
custom field:
* ``server_key`` the key as stored in the CF dict.
* ``display_name`` human-readable name from PLUGINS_CONFIG, or the key.
* ``librenms_url`` base URL of that server (``None`` when not configured).
* ``device_id`` the integer device ID on that server.
* ``device_url`` direct URL to the device page on that server (or ``None``).
* ``is_configured`` True when the server key exists in current plugin config.
* ``is_active`` True when this is the currently active server.
Returns ``None`` for legacy bare-int format (no per-server info to show)
and ``None`` when the CF is absent/invalid.
"""
cf_value = obj.custom_field_data.get("librenms_id")
if not isinstance(cf_value, dict) or not cf_value:
return None
plugins_cfg = getattr(django_settings, "PLUGINS_CONFIG", {}).get("netbox_librenms_plugin", {})
servers_config = plugins_cfg.get("servers") or {}
if not isinstance(servers_config, dict):
servers_config = {}
result = []
for sk, did in cf_value.items():
# Validate device ID — accept int or digit-string, skip bool/None/junk.
if isinstance(did, bool) or did is None:
continue
if isinstance(did, str):
if not did.isdigit():
continue
did = int(did)
elif not isinstance(did, int):
continue
srv_cfg = servers_config.get(sk)
# Legacy single-server config: "default" key with no matching servers entry —
# fall back to root-level librenms_url/display_name in plugins_cfg.
if srv_cfg is None and sk == "default":
legacy_url = plugins_cfg.get("librenms_url")
if legacy_url:
srv_cfg = {
"librenms_url": legacy_url,
"display_name": plugins_cfg.get("display_name") or f"Default Server ({legacy_url})",
}
is_configured = srv_cfg is not None
# Treat malformed (non-dict) server config entries as unconfigured
if srv_cfg is not None and not isinstance(srv_cfg, dict):
srv_cfg = None
is_configured = False
librenms_url = srv_cfg.get("librenms_url") if srv_cfg else None
display_name = (srv_cfg.get("display_name") or sk) if srv_cfg else sk
device_url = f"{librenms_url}/device/device={did}/" if librenms_url else None
result.append(
{
"server_key": sk,
"display_name": display_name,
"librenms_url": librenms_url,
"device_id": did,
"device_url": device_url,
"is_configured": is_configured,
"is_active": sk == active_server_key,
}
)
# Sort: active first, then configured, then orphaned
result.sort(key=lambda e: 0 if e["is_active"] else (1 if e["is_configured"] else 2))
return result or None
def get_librenms_device_info(self, obj, request=None):
"""Get the LibreNMS device information for the given object."""
found_in_librenms = False
mismatched_device = False
librenms_device_details = {
"librenms_device_url": None,
"librenms_device_hardware": "-",
"librenms_device_serial": "-",
"librenms_device_os": "-",
"librenms_device_version": "-",
"librenms_device_features": "-",
"librenms_device_location": "-",
"librenms_device_hardware_match": None,
"vc_inventory_serials": [],
}
if self.librenms_id:
success, device_info = self.librenms_api.get_device_info(self.librenms_id)
if success and device_info:
# Get NetBox device details
netbox_ip = str(obj.primary_ip.address.ip).lower() if obj.primary_ip else None
netbox_name = obj.name
# Get LibreNMS device details
librenms_sysname = device_info.get("sysName")
librenms_ip = device_info.get("ip")
# Extract new fields
hardware = device_info.get("hardware", "-")
serial = device_info.get("serial", "-")
os_name = device_info.get("os", "-")
version = device_info.get("version", "-")
features = device_info.get("features", "-")
# Try to match hardware to NetBox DeviceType
hardware_match = match_librenms_hardware_to_device_type(hardware)
# Compute resolved name using naming preferences
resolved_name = None
if request:
use_sysname, strip_domain = resolve_naming_preferences(request)
resolved_name = _determine_device_name(
device_info,
use_sysname=use_sysname,
strip_domain=strip_domain,
device_id=self.librenms_id,
)
# For VC members, generate the expected VC member name
if (
resolved_name
and hasattr(obj, "virtual_chassis")
and obj.virtual_chassis is not None
and obj.vc_position is not None
):
resolved_name = _generate_vc_member_name(
resolved_name,
obj.vc_position,
serial=getattr(obj, "serial", None),
)
# Update device details regardless of match
librenms_device_details.update(
{
"librenms_device_url": f"{self.librenms_api.librenms_url}/device/device={self.librenms_id}/",
"librenms_device_hardware": hardware,
"librenms_device_serial": serial,
"librenms_device_os": os_name,
"librenms_device_version": version,
"librenms_device_features": features,
"librenms_device_location": device_info.get("location", "-"),
"librenms_device_ip": librenms_ip,
"sysName": librenms_sysname,
"resolved_name": resolved_name or librenms_sysname,
"librenms_device_hostname": device_info.get("hostname", "-"),
"librenms_device_hardware_match": hardware_match,
}
)
# For Virtual Chassis, fetch inventory
if hasattr(obj, "virtual_chassis") and obj.virtual_chassis:
vc_serials = self._get_vc_inventory_serials(obj)
librenms_device_details["vc_inventory_serials"] = vc_serials
# Device was retrieved successfully via librenms_id — trust the ID
found_in_librenms = True
# Normalise the NetBox name once for comparisons
netbox_name_norm = netbox_name.lower() if netbox_name else None
if netbox_name_norm:
# Strip VC member suffix like " (1)" before comparing
netbox_name_norm = re.sub(r"\s*\(\d+\)$", "", netbox_name_norm)
# Also strip the VC member naming pattern from settings
# (e.g. "-M2", " (2)", "-SW3") to recover the base device name
netbox_name_vc_stripped = None
if netbox_name_norm:
netbox_name_vc_stripped = self._strip_vc_pattern(netbox_name_norm)
# Collect all NetBox identity values to compare against
netbox_dns_name = (
obj.primary_ip.dns_name.lower() if obj.primary_ip and obj.primary_ip.dns_name else None
)
netbox_identities = {
v
for v in [
netbox_name_norm,
netbox_ip,
netbox_dns_name,
netbox_name_vc_stripped,
]
if v
}
# Collect all LibreNMS identity values, including
# domain-stripped short names (e.g. "sw01.example.net" → "sw01")
librenms_hostname = device_info.get("hostname")
librenms_values = []
for val in [librenms_sysname, librenms_hostname, librenms_ip]:
if val:
lower_val = val.lower()
librenms_values.append(lower_val)
# Add short name (strip domain) if it looks like an FQDN
short = lower_val.split(".")[0]
if short != lower_val:
librenms_values.append(short)
librenms_identities = set(librenms_values)
# A device is considered matched when ANY NetBox identity
# appears in the LibreNMS identities. This covers:
# - NetBox name == sysName or hostname
# - NetBox primary IP == LibreNMS hostname (added by IP)
# - NetBox DNS name == sysName or hostname (FQDN match)
if netbox_identities & librenms_identities:
mismatched_device = False
else:
mismatched_device = True
librenms_device_details["netbox_dns_name"] = netbox_dns_name or "-"
return {
"found_in_librenms": found_in_librenms,
"librenms_device_details": librenms_device_details,
"mismatched_device": mismatched_device,
}
def get_interface_context(self, request, obj):
"""
Get the context data for interface sync.
Subclasses should override this method.
"""
return None
def get_cable_context(self, request, obj):
"""
Get the context data for cable sync.
Subclasses should override this method if applicable.
"""
return None
def get_ip_context(self, request, obj):
"""
Get the context data for IP address sync.
Subclasses should override this method.
"""
return None
def get_vlan_context(self, request, obj):
"""
Get the context data for VLAN sync.
Subclasses should override this method.
"""
return None
def get_module_context(self, request, obj):
"""
Get the context data for module sync.
Subclasses should override this method if applicable (e.g. VMs return None).
"""
return None
@staticmethod
def _strip_vc_pattern(name):
"""
Strip the VC member naming suffix from a device name.
Uses the vc_member_name_pattern from LibreNMSSettings to build a
regex that removes the suffix. For example, with the default
pattern ``-M{position}`` and name ``switch01-m2``, this returns
``switch01``.
Returns the stripped name, or None if it equals the original
(i.e. no suffix was found).
"""
try:
from netbox_librenms_plugin.models import LibreNMSSettings
settings = LibreNMSSettings.objects.first()
pattern = (
settings.vc_member_name_pattern
if settings and isinstance(settings.vc_member_name_pattern, str)
else "-M{position}"
)
if not isinstance(pattern, str):
pattern = "-M{position}"
# Turn the pattern into a regex by replacing placeholders
# {position} → \d+ {serial} → .+
regex_suffix = re.escape(pattern)
regex_suffix = regex_suffix.replace(re.escape("{position}"), r"\d+")
regex_suffix = regex_suffix.replace(re.escape("{serial}"), r".+")
stripped = re.sub(regex_suffix + "$", "", name, flags=re.IGNORECASE)
return stripped if stripped != name else None
except Exception:
return None
def _get_vc_inventory_serials(self, obj):
"""
Fetch inventory serials for Virtual Chassis members.
Args:
obj: NetBox device object (VC member)
Returns:
list: [
{
'description': 'Chassis component description',
'serial': 'serial number',
'model': 'model name',
'assigned_member': Device object or None (if serial matches existing assignment)
}
]
"""
success, inventory = self.librenms_api.get_device_inventory(self.librenms_id)
if not success:
return []
# Filter for chassis components
chassis_components = [item for item in inventory if item.get("entPhysicalClass") == "chassis"]
# Get all VC members
vc_members = obj.virtual_chassis.members.all()
result = []
for component in chassis_components:
serial = component.get("entPhysicalSerialNum", "-")
if not serial or serial == "-":
continue
# Check if this serial is already assigned to a VC member
assigned_member = None
for member in vc_members:
if member.serial and member.serial.strip() == serial.strip():
assigned_member = member
break
result.append(
{
"description": component.get("entPhysicalDescr", "-"),
"serial": serial,
"model": component.get("entPhysicalModelName", "-"),
"assigned_member": assigned_member,
}
)
return result
def _get_platform_info(self, librenms_info, obj):
"""
Get platform information from LibreNMS.
Platform matching is based on OS name only (not version).
Version is displayed separately as informational data.
Args:
librenms_info: Dictionary with LibreNMS device info
obj: NetBox device object
Returns:
dict: {
'netbox_platform': Platform object or None,
'librenms_os': str (OS name),
'librenms_version': str (OS version),
'platform_exists': bool (whether OS platform exists in NetBox),
'platform_name': str (OS name for platform matching),
'matching_platform': Platform object or None
}
"""
from dcim.models import Platform
librenms_os = librenms_info["librenms_device_details"].get("librenms_device_os", "-")
librenms_version = librenms_info["librenms_device_details"].get("librenms_device_version", "-")
# Platform name is just the OS (not OS + version)
platform_name = librenms_os if librenms_os != "-" else None
# Check if platform exists (match by OS name only)
platform_exists = False
matching_platform = None
if platform_name:
try:
matching_platform = Platform.objects.get(name__iexact=platform_name)
platform_exists = True
except Platform.DoesNotExist:
pass
return {
"netbox_platform": obj.platform,
"librenms_os": librenms_os,
"librenms_version": librenms_version,
"platform_exists": platform_exists,
"platform_name": platform_name,
"matching_platform": matching_platform,
}

View File

@@ -0,0 +1,216 @@
from django.contrib import messages
from django.core.cache import cache
from django.shortcuts import get_object_or_404, render
from django.utils import timezone
from django.views import View
from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE
from netbox_librenms_plugin.tables.vlans import LibreNMSVLANTable
from netbox_librenms_plugin.views.mixins import (
CacheMixin,
LibreNMSAPIMixin,
LibreNMSPermissionMixin,
VlanAssignmentMixin,
)
class BaseVLANTableView(VlanAssignmentMixin, LibreNMSAPIMixin, LibreNMSPermissionMixin, CacheMixin, View):
"""
Base view for VLAN synchronization table.
Fetches LibreNMS VLAN data and compares with NetBox.
"""
model = None # To be defined in subclasses
partial_template_name = "netbox_librenms_plugin/_vlan_sync_content.html"
def get_object(self, pk):
"""Retrieve the object (Device or VirtualMachine)."""
return get_object_or_404(self.model, pk=pk)
def post(self, request, pk):
"""Handle POST request to fetch and cache LibreNMS VLAN data."""
obj = self.get_object(pk)
# Get librenms_id
self.librenms_id = self.librenms_api.get_librenms_id(obj)
if not self.librenms_id:
messages.error(request, "Device not found in LibreNMS.")
context = {"vlan_sync": self._get_error_context(obj, "Device not found in LibreNMS")}
return render(request, self.partial_template_name, context)
# Fetch VLAN data from LibreNMS
success, error_msg = self._fetch_and_cache_vlan_data(obj)
if not success:
messages.error(request, error_msg)
context = {"vlan_sync": self._get_error_context(obj, error_msg)}
return render(request, self.partial_template_name, context)
messages.success(request, "VLAN data refreshed successfully.")
context = {"vlan_sync": self.get_vlan_context(request, obj)}
return render(request, self.partial_template_name, context)
def _fetch_and_cache_vlan_data(self, obj):
"""
Fetch VLAN data from LibreNMS and cache it.
Returns:
tuple: (success: bool, error_message: str or None)
"""
# Fetch device VLANs
success, vlans_data = self.librenms_api.get_device_vlans(self.librenms_id)
if not success:
return False, f"Failed to fetch VLANs: {vlans_data}"
# Cache VLANs
server_key = self.librenms_api.server_key
cache.set(
self.get_cache_key(obj, "vlans", server_key),
vlans_data,
timeout=self.librenms_api.cache_timeout,
)
cache.set(
self.get_last_fetched_key(obj, "vlans", server_key),
timezone.now(),
timeout=self.librenms_api.cache_timeout,
)
return True, None
def get_vlan_context(self, request, obj):
"""
Build context for VLAN sync table.
Returns context with:
- vlan_table: LibreNMSVLANTable instance
- vlan_groups: QuerySet of available VLAN groups
"""
vlan_table = None
# Get cached data
server_key = getattr(self.librenms_api, "server_key", None)
cached_vlans = cache.get(self.get_cache_key(obj, "vlans", server_key))
last_fetched = cache.get(self.get_last_fetched_key(obj, "vlans", server_key))
# Get available VLAN groups for this device
vlan_groups = self.get_vlan_groups_for_device(obj)
# Build lookup maps for VLAN matching
lookup_maps = self._build_vlan_lookup_maps(vlan_groups)
if cached_vlans:
# Compare VLANs with NetBox (against all device-available VLANs)
compared_vlans = self.compare_vlans(cached_vlans, lookup_maps, device=obj)
vlan_table = LibreNMSVLANTable(compared_vlans, vlan_groups=vlan_groups)
vlan_table.configure(request)
# Calculate cache TTL
cache_ttl = cache.ttl(self.get_cache_key(obj, "vlans", server_key))
cache_expiry = timezone.now() + timezone.timedelta(seconds=cache_ttl) if cache_ttl and cache_ttl > 0 else None
return {
"object": obj,
"vlan_table": vlan_table,
"vlan_groups": vlan_groups,
"last_fetched": last_fetched,
"cache_expiry": cache_expiry,
"server_key": server_key,
}
def _get_error_context(self, obj, error_message):
"""Build context for error state."""
return {
"object": obj,
"error_message": error_message,
"vlan_table": None,
"vlan_groups": self.get_vlan_groups_for_device(obj),
"server_key": getattr(self.librenms_api, "server_key", None),
}
def compare_vlans(self, librenms_vlans, lookup_maps=None, device=None):
"""
Compare LibreNMS VLANs against NetBox VLANs available to the device.
Args:
librenms_vlans: List of VLAN dicts from LibreNMS
lookup_maps: Dict with vid_to_groups, vid_group_to_vlan, vid_to_vlans
device: NetBox Device object for scope-based prioritization
Adds comparison flags:
- exists_in_netbox: bool
- netbox_vlan: VLAN object or None
- netbox_vlan_group: VLANGroup name or None
- name_matches: bool
- auto_selected_group_id: ID of auto-selected group or None
- auto_selected_group_name: Name of auto-selected group or None
- is_ambiguous: bool - True if VID exists in multiple groups with no clear priority
"""
lookup_maps = lookup_maps or {}
vid_to_groups = lookup_maps.get("vid_to_groups", {})
vid_to_vlans = lookup_maps.get("vid_to_vlans", {})
compared = []
for vlan in librenms_vlans:
vid = vlan.get("vlan_vlan")
name = vlan.get("vlan_name", "")
# Auto-selection logic for VLAN group dropdown
auto_selected_group_id = None
auto_selected_group_name = None
is_ambiguous = False
netbox_vlan = None
# Check if VID exists in groups for auto-selection
if vid in vid_to_groups:
groups = vid_to_groups[vid]
if len(groups) == 1:
auto_selected_group_id = groups[0].pk
auto_selected_group_name = groups[0].name
# Get the VLAN from this single group
vlans_for_vid = vid_to_vlans.get(vid, [])
if vlans_for_vid:
netbox_vlan = vlans_for_vid[0]
elif len(groups) > 1:
# Try to select the most specific group based on device context
most_specific = self._select_most_specific_group(groups, device)
if most_specific:
auto_selected_group_id = most_specific.pk
auto_selected_group_name = most_specific.name
# Get the VLAN from the most specific group
vlans_for_vid = vid_to_vlans.get(vid, [])
for v in vlans_for_vid:
if v.group and v.group.pk == most_specific.pk:
netbox_vlan = v
break
else:
is_ambiguous = True
else:
# Check if it exists as a global VLAN (no group)
vlans_for_vid = vid_to_vlans.get(vid, [])
for v in vlans_for_vid:
if v.group is None:
netbox_vlan = v
break
compared.append(
{
"vlan_id": vid,
"name": name,
"type": vlan.get("vlan_type", "ethernet"),
"state": vlan.get("vlan_state", LIBRENMS_VLAN_STATE_ACTIVE),
"exists_in_netbox": bool(netbox_vlan),
"netbox_vlan_id": netbox_vlan.pk if netbox_vlan else None,
"netbox_vlan_name": netbox_vlan.name if netbox_vlan else None,
"netbox_vlan_group": netbox_vlan.group.name if netbox_vlan and netbox_vlan.group else None,
"netbox_vlan_group_id": netbox_vlan.group.pk if netbox_vlan and netbox_vlan.group else None,
"name_matches": netbox_vlan.name == name if netbox_vlan else False,
# Fields for per-row VLAN group selection
"auto_selected_group_id": auto_selected_group_id,
"auto_selected_group_name": auto_selected_group_name,
"is_ambiguous": is_ambiguous,
}
)
return compared