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,56 @@
from django.urls import reverse
from django.utils.safestring import mark_safe
from django_tables2 import Column
from virtualization.models import VirtualMachine
from virtualization.tables import VirtualMachineTable
class VMStatusTable(VirtualMachineTable):
"""
Table for displaying virtual machine LibreNMS status.
"""
librenms_status = Column(
verbose_name="LibreNMS Status",
empty_values=(),
accessor="librenms_status",
orderable=False,
)
def render_librenms_status(self, value, record):
"""Render the LibreNMS status with styles based on sync status."""
sync_url = reverse(
"plugins:netbox_librenms_plugin:vm_librenms_sync",
kwargs={"pk": record.pk},
)
if value:
status = '<span class="text-success"><i class="mdi mdi-check-circle"></i> Synced</span>'
elif value is False:
status = '<span class="text-danger"><i class="mdi mdi-close-circle"></i> Not Found</span>'
else:
status = '<span class="text-secondary"><i class="mdi mdi-help-circle"></i> Unknown</span>'
return mark_safe(f'<a href="{sync_url}">{status}</a>')
class Meta(VirtualMachineTable.Meta):
"""Meta options for VMStatusTable."""
model = VirtualMachine
fields = (
"pk",
"name",
"status",
"cluster",
"cluster_type",
"cluster_group",
"librenms_status",
)
default_columns = (
"name",
"status",
"cluster",
"cluster_type",
"cluster_group",
"librenms_status",
)

View File

@@ -0,0 +1,21 @@
from .cables import LibreNMSCableTable
from .device_status import DeviceStatusTable
from .interfaces import LibreNMSInterfaceTable, LibreNMSVMInterfaceTable, VCInterfaceTable
from .ipaddresses import IPAddressTable
from .locations import SiteLocationSyncTable
from .mappings import InterfaceTypeMappingTable
from .vlans import LibreNMSVLANTable
from .VM_status import VMStatusTable
__all__ = [
"DeviceStatusTable",
"InterfaceTypeMappingTable",
"IPAddressTable",
"LibreNMSCableTable",
"LibreNMSInterfaceTable",
"LibreNMSVLANTable",
"LibreNMSVMInterfaceTable",
"SiteLocationSyncTable",
"VCInterfaceTable",
"VMStatusTable",
]

View File

@@ -0,0 +1,161 @@
import django_tables2 as tables
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from netbox.tables.columns import ToggleColumn
from utilities.paginator import EnhancedPaginator
from netbox_librenms_plugin.utils import (
get_table_paginate_count,
get_virtual_chassis_member,
)
class LibreNMSCableTable(tables.Table):
"""
Table for displaying LibreNMS cable data.
"""
selection = ToggleColumn(
accessor="local_port_id",
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
)
local_port = tables.Column(verbose_name="Local Port", attrs={"td": {"data-col": "local_port"}})
remote_port = tables.Column(
accessor="remote_port_name",
verbose_name="Remote Port",
attrs={"td": {"data-col": "remote_port"}},
)
remote_device = tables.Column(verbose_name="Remote Device", attrs={"td": {"data-col": "remote_device"}})
cable_status = tables.Column(verbose_name="Cable Status", attrs={"td": {"data-col": "cable_status"}})
actions = tables.TemplateColumn(
template_code="""
{% if record.can_create_cable %}
<button type="submit"
class="btn btn-sm btn-primary"
onclick="document.getElementById('selected_port').value='{{ record.local_port_id }}'">
Sync Cable
</button>
{% endif %}
""",
verbose_name="",
orderable=False,
attrs={"td": {"data-col": "actions"}},
)
def __init__(self, *args, device=None, **kwargs):
"""Initialize table with optional device context."""
self.device = device
super().__init__(*args, **kwargs)
self.tab = "cables"
self.htmx_url = None
self.prefix = "cables_"
def render_remote_device(self, value, record):
"""Render remote device name as a link if URL is available."""
if url := record.get("remote_device_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_local_port(self, value, record):
"""Render local port name as a link if URL is available."""
if url := record.get("local_port_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_remote_port(self, value, record):
"""Render remote port name as a link if URL is available."""
if url := record.get("remote_port_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_cable_status(self, value, record):
"""Render cable status as a link if cable URL is available."""
if url := record.get("cable_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def configure(self, request):
"""Configure pagination for the table using the current request."""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)
class Meta:
"""Define column sequence, row attributes, and table styling."""
sequence = [
"selection",
"local_port",
"remote_port",
"remote_device",
"cable_status",
"actions",
]
row_attrs = {
"data-interface": lambda record: record["local_port_id"],
"data-device": lambda record: record["device_id"],
"data-name": lambda record: record["local_port"],
}
attrs = {"class": "table table-hover object-list", "id": "librenms-cable-table"}
class VCCableTable(LibreNMSCableTable):
"""
Table for displaying LibreNMS cable data for Virtual Chassis devices.
"""
device_selection = tables.Column(
verbose_name="Virtual Chassis Member",
accessor="local_port_id",
attrs={"td": {"class": "device-selection-col", "data-col": "device_selection"}},
)
def __init__(self, *args, device=None, **kwargs):
"""Initialize the VC cable table with device context."""
super().__init__(*args, device=device, **kwargs)
def render_device_selection(self, value, record):
"""Render a dropdown to select the virtual chassis member for a port."""
members = self.device.virtual_chassis.members.all()
chassis_member = get_virtual_chassis_member(self.device, record["local_port"])
selected_member_id = chassis_member.id if chassis_member else self.device.id
port_id = record["local_port_id"]
options = [
f'<option value="{member.id}"{" selected" if member.id == selected_member_id else ""}>{escape(member.name)}</option>'
for member in members
]
return format_html(
'<select name="device_selection_{0}" id="device_selection_{0}" class="form-select" data-interface="{0}" data-row-id="{0}">{1}</select>',
port_id,
mark_safe("".join(options)),
)
class Meta(LibreNMSCableTable.Meta):
"""Define column sequence and attributes for the VC cable table."""
sequence = [
"selection",
"device_selection",
"local_port",
"remote_port",
"remote_device",
"cable_status",
"actions",
]
row_attrs = {
"data-interface": lambda record: record["local_port_id"],
"data-device": lambda record: record["device_id"],
"data-name": lambda record: record["local_port"],
"id": lambda record: record["local_port_id"],
}
attrs = {
"class": "table table-hover object-list",
"id": "librenms-cable-table-vc",
}

View File

@@ -0,0 +1,722 @@
import json
import django_tables2 as tables
from dcim.models import Device
from dcim.tables import DeviceTable
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django_tables2 import Column
from virtualization.models import VirtualMachine
from netbox_librenms_plugin.utils import get_librenms_sync_device
class DeviceStatusTable(DeviceTable):
"""
Table for displaying device LibreNMS status.
"""
librenms_status = Column(
verbose_name="LibreNMS Status",
empty_values=(),
accessor="librenms_status",
orderable=False,
)
def render_librenms_status(self, value, record):
"""Render LibreNMS sync status with link to sync page."""
sync_url = reverse(
"plugins:netbox_librenms_plugin:device_librenms_sync",
kwargs={"pk": record.pk},
)
# Check if device is VC member and redirect to sync device if different
if hasattr(record, "virtual_chassis") and record.virtual_chassis:
sync_device = get_librenms_sync_device(record)
if sync_device and record.pk != sync_device.pk:
sync_device_url = reverse(
"plugins:netbox_librenms_plugin:device_librenms_sync",
kwargs={"pk": sync_device.pk},
)
return mark_safe(
f'<a href="{sync_device_url}"><span class="text-info">'
f'<i class="mdi mdi-server-network"></i> See {sync_device.name}</span></a>'
)
if value:
status = '<span class="text-success"><i class="mdi mdi-check-circle"></i> Found</span>'
elif value is False:
status = '<span class="text-danger"><i class="mdi mdi-close-circle"></i> Not Found</span>'
else:
status = '<span class="text-secondary"><i class="mdi mdi-help-circle"></i> Unknown</span>'
return mark_safe(f'<a href="{sync_url}">{status}</a>')
class Meta(DeviceTable.Meta):
"""Meta options for DeviceStatusTable."""
model = Device
fields = (
"pk",
"name",
"status",
"tenant",
"site",
"location",
"rack",
"role",
"manufacturer",
"device_type",
"device_role",
"librenms_status",
)
default_columns = (
"name",
"status",
"site",
"location",
"rack",
"device_type",
"role",
"librenms_status",
)
class DeviceImportTable(tables.Table):
"""
Table for displaying LibreNMS devices available for import.
Shows validation status and provides import actions.
Uses plain django_tables2.Table since we're working with dictionaries, not model instances.
"""
name = "DeviceImportTable" # Required by NetBox table utilities
def __init__(self, *args, **kwargs):
"""Initialize table with cached querysets and apply sorting."""
super().__init__(*args, **kwargs)
# Cache querysets to avoid N queries per render
from dcim.models import DeviceRole
from virtualization.models import Cluster
self._cached_clusters = list(Cluster.objects.all().order_by("name"))
self._cached_roles = list(DeviceRole.objects.all().order_by("name"))
# Apply sorting if order_by is specified
# Since we're working with dictionaries, not QuerySets, we handle sorting manually
if self.order_by:
self._sort_data()
def _sort_data(self):
"""Sort table data based on order_by parameter."""
if not self.data:
return
# Get the ordering field and direction
order_by = self.order_by[0] if isinstance(self.order_by, (list, tuple)) else self.order_by
reverse = order_by.startswith("-")
field = order_by.lstrip("-")
# Map column names to data keys
field_map = {
"hostname": "hostname",
"sysname": "sysName",
"location": "location",
"hardware": "hardware",
}
data_key = field_map.get(field)
if not data_key:
return # Unknown field, skip sorting
# Sort the data list in place
# Handle None values by treating them as empty strings for sorting
def sort_key(item):
"""Return lowercase sort value for a data field."""
value = item.get(data_key, "")
return (value or "").lower() if isinstance(value, str) else str(value or "")
try:
self.data.data.sort(key=sort_key, reverse=reverse)
except (AttributeError, TypeError):
# If data is a plain list, sort it directly
if isinstance(self.data, list):
self.data.sort(key=sort_key, reverse=reverse)
# Selection checkbox
selection = Column(
verbose_name="",
empty_values=(),
orderable=False,
accessor="device_id",
)
# LibreNMS device fields
hostname = Column(verbose_name="Hostname", accessor="hostname", orderable=True)
sysname = Column(verbose_name="System Name", accessor="sysName", orderable=True)
location = Column(verbose_name="Location", accessor="location", orderable=True)
hardware = Column(verbose_name="Hardware", accessor="hardware", orderable=True)
# Cluster selection - if selected, import as VM; otherwise import as Device
netbox_cluster = Column(
verbose_name="NetBox Cluster",
empty_values=(),
orderable=False,
accessor="device_id",
)
# NetBox role selection (for devices only)
netbox_role = Column(
verbose_name="NetBox Role",
empty_values=(),
orderable=False,
accessor="device_id",
)
# NetBox rack selection (for devices only, optional)
netbox_rack = Column(
verbose_name="NetBox Rack",
empty_values=(),
orderable=False,
accessor="device_id",
)
# Virtual Chassis detection column
virtual_chassis = Column(
verbose_name="Virtual Chassis",
empty_values=(),
orderable=False,
accessor="device_id",
)
# Actions column
actions = Column(
verbose_name="Actions",
empty_values=(),
orderable=False,
accessor="device_id",
)
def render_selection(self, value, record):
"""
Render selection checkbox.
Disabled if device can't be imported.
"""
validation = record.get("_validation", {})
can_import = validation.get("can_import", False)
device_id = record.get("device_id")
hostname = record.get("hostname", "")
sysname = record.get("sysName", "")
if can_import:
return mark_safe(
f'<input type="checkbox" name="select" value="{device_id}" '
f'class="form-check-input device-select" data-device-id="{device_id}" '
f'data-hostname="{hostname}" data-sysname="{sysname}">'
)
else:
return mark_safe(
'<input type="checkbox" disabled class="form-check-input" title="Cannot import this device">'
)
def render_hostname(self, value, record):
"""Render hostname with link to LibreNMS if available."""
return mark_safe(f"<strong>{value}</strong>")
def render_netbox_cluster(self, value, record):
"""
Render cluster selection dropdown.
Default is "-- Device (not VM) --" (empty value).
If a cluster is selected, the device will be imported as a VM.
If no cluster is selected, the device will be imported as a Device.
"""
device_id = record.get("device_id")
validation = record.get("_validation", {})
existing = validation.get("existing_device")
# Check if existing object is a VM
if existing and isinstance(existing, VirtualMachine):
# VM already exists - show its cluster (cluster is required for VMs)
cluster = existing.cluster
return mark_safe(f'<span class="badge bg-info text-white">{cluster.name}</span>')
# If Device already exists (not VM), show it's not a VM
if existing:
return mark_safe('<span class="text-muted small">Device (not VM)</span>')
# Use cached clusters to avoid N queries
clusters = self._cached_clusters
# Check if a cluster has been selected (from validation)
selected_cluster_id = None
if validation.get("cluster", {}).get("found") and validation.get("cluster", {}).get("cluster"):
selected_cluster_id = validation["cluster"]["cluster"].pk
# Build dropdown with HTMX attributes to update the row
options = ['<option value="">-- Device (not VM) --</option>']
for cluster in clusters:
selected = " selected" if cluster.pk == selected_cluster_id else ""
options.append(f'<option value="{cluster.pk}"{selected}>{cluster.name}</option>')
# Add HTMX attributes to update the entire row when cluster is selected
from django.urls import reverse
update_url = reverse(
"plugins:netbox_librenms_plugin:device_cluster_update",
kwargs={"device_id": device_id},
)
# Include VC detection flag in URL if present in validation (from initial load)
vc_detection_flag = ""
if validation.get("_vc_detection_enabled"):
vc_detection_flag = "?enable_vc_detection=true"
select_html = (
f'<select class="form-select form-select-sm cluster-select" '
f'name="cluster_{device_id}" '
f'data-device-id="{device_id}" '
f'hx-post="{update_url}{vc_detection_flag}" '
f'hx-trigger="change" '
f'hx-swap="none" '
f'hx-include="[name=role_{device_id}], [name=rack_{device_id}]" '
f'style="width: 180px;">'
f"{''.join(options)}"
f"</select>"
)
return mark_safe(select_html)
def render_netbox_role(self, value, record):
"""
Render role selection dropdown.
For Devices: Role is required
For VMs: Role is optional
"""
device_id = record.get("device_id")
validation = record.get("_validation", {})
is_vm = validation.get("import_as_vm", False)
existing = validation.get("existing_device")
# If device/VM already exists, show its role with NetBox's defined color
if existing and hasattr(existing, "role") and existing.role:
role = existing.role
# Use the role's color if available, otherwise fallback to info
color = role.color if hasattr(role, "color") and role.color else "6c757d"
return mark_safe(
f'<span class="badge" style="background-color: #{color}; color: white;">{role.name}</span>'
)
# Use cached roles to avoid N queries
roles = self._cached_roles
# Check if a role has been selected (from validation)
selected_role_id = None
if validation.get("device_role", {}).get("found") and validation.get("device_role", {}).get("role"):
selected_role_id = validation["device_role"]["role"].pk
# Build dropdown with different text based on import type
if is_vm:
placeholder = "-- Select Role (Optional) --"
else:
placeholder = "-- Select Role --"
options = [f'<option value="">{placeholder}</option>']
for role in roles:
selected = " selected" if role.pk == selected_role_id else ""
options.append(f'<option value="{role.pk}"{selected}>{role.name}</option>')
# Add HTMX attributes to update the entire row when role is selected
from django.urls import reverse
update_url = reverse(
"plugins:netbox_librenms_plugin:device_role_update",
kwargs={"device_id": device_id},
)
# Include VC detection flag in URL if present in validation (from initial load)
vc_detection_flag = ""
if validation.get("_vc_detection_enabled"):
vc_detection_flag = "?enable_vc_detection=true"
select_html = (
f'<select class="form-select form-select-sm device-role-select" '
f'name="role_{device_id}" '
f'data-device-id="{device_id}" '
f'hx-post="{update_url}{vc_detection_flag}" '
f'hx-trigger="change" '
f'hx-swap="none" '
f'hx-include="[name=cluster_{device_id}], [name=rack_{device_id}]" '
f'style="width: 150px;">'
f"{''.join(options)}"
f"</select>"
)
return mark_safe(select_html)
def render_netbox_rack(self, value, record):
"""
Render rack selection dropdown (optional).
Shows racks for the matched site in "Location - Rack" format.
Only shown for devices (not VMs) and when site is matched.
"""
device_id = record.get("device_id")
validation = record.get("_validation", {})
is_vm = validation.get("import_as_vm", False)
existing = validation.get("existing_device")
# Don't show rack dropdown for VMs
if is_vm:
return mark_safe('<span class="text-muted small">N/A (VM)</span>')
# If device already exists, show its rack
if existing and hasattr(existing, "rack") and existing.rack:
rack = existing.rack
location_name = rack.location.name if rack.location else "No Location"
return mark_safe(f'<span class="badge bg-info text-white">{location_name} - {rack.name}</span>')
# If device exists but no rack assigned
if existing:
return mark_safe('<span class="text-muted small">No rack</span>')
# Check if site is matched - rack selection only available when site is known
site_found = validation.get("site", {}).get("found", False)
if not site_found:
return mark_safe('<span class="text-muted small">--</span>')
# Get available racks from validation (cached)
available_racks = validation.get("rack", {}).get("available_racks", [])
# Check if a rack has been selected
selected_rack_id = None
if validation.get("rack", {}).get("rack"):
selected_rack_id = validation["rack"]["rack"].pk
# Build dropdown with HTMX attributes
options = ['<option value="">--</option>']
for rack in available_racks:
location_name = rack.location.name if rack.location else "No Location"
display_text = f"{location_name} - {rack.name}"
selected = " selected" if rack.pk == selected_rack_id else ""
options.append(f'<option value="{rack.pk}"{selected}>{escape(display_text)}</option>')
# Add HTMX attributes to update the entire row when rack is selected
from django.urls import reverse
update_url = reverse(
"plugins:netbox_librenms_plugin:device_rack_update",
kwargs={"device_id": device_id},
)
# Include VC detection flag in URL if present in validation (from initial load)
vc_detection_flag = ""
if validation.get("_vc_detection_enabled"):
vc_detection_flag = "?enable_vc_detection=true"
select_html = (
f'<select class="form-select form-select-sm rack-select" '
f'name="rack_{device_id}" '
f'data-device-id="{device_id}" '
f'hx-post="{update_url}{vc_detection_flag}" '
f'hx-trigger="change" '
f'hx-swap="none" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}]" '
f'style="width: 200px;">'
f"{''.join(options)}"
f"</select>"
)
return mark_safe(select_html)
def render_actions(self, value, record):
"""
Render action buttons for import using HTMX.
Shows Import button if can import, otherwise shows Preview/Configure.
Permission checks are handled by backend require_write_permission() which shows toast.
"""
validation = record.get("_validation", {})
device_id = record.get("device_id")
is_ready = validation.get("is_ready", False)
can_import = validation.get("can_import", False)
existing = validation.get("existing_device")
vc_attributes = self._build_vc_attributes(validation, record)
buttons = []
if existing:
# Link to existing device/VM in NetBox + details button for conflict resolution
if isinstance(existing, VirtualMachine):
url_name = "virtualization:virtualmachine"
title = "View VM in NetBox"
else:
url_name = "dcim:device"
title = "View Device in NetBox"
device_url = reverse(url_name, kwargs={"pk": existing.pk})
buttons.append(
f'<a href="{device_url}" class="btn btn-sm btn-secondary" '
f'title="{title}" aria-label="{title}"><i class="mdi mdi-open-in-new"></i></a>'
)
# Add details/conflict button for conflict resolution actions
details_url = self._build_validation_details_url(device_id, validation)
match_type = validation.get("existing_match_type", "")
serial_action = validation.get("serial_action")
has_mismatch = validation.get("device_type_mismatch", False)
has_actions = match_type == "hostname" or (match_type == "serial" and serial_action is not None)
has_name_sync = validation.get("name_sync_available", False)
has_sync_needed = match_type == "librenms_id" and serial_action in ("update_serial", "conflict")
if has_mismatch:
btn_class = "btn-outline-danger"
btn_icon = "mdi-alert-circle"
btn_label = " Conflict"
btn_title = "View conflict details"
elif has_actions:
btn_class = "btn-outline-warning"
btn_icon = "mdi-alert"
btn_label = " Conflict"
btn_title = "View conflict details"
elif has_name_sync or has_sync_needed:
btn_class = "btn-outline-warning"
btn_icon = "mdi-information-outline"
btn_label = " Details"
btn_title = "View details"
elif match_type == "librenms_id" and validation.get("librenms_id_needs_migration"):
btn_class = "btn-outline-warning"
btn_icon = "mdi-database-alert"
btn_label = " Legacy ID"
btn_title = "View legacy ID migration details"
else:
btn_class = "btn-outline-success"
btn_icon = "mdi-check-circle"
btn_label = ""
btn_title = "View details"
aria_attr = f'aria-label="{btn_title}" '
buttons.append(
f'<button type="button" '
f'class="btn btn-sm {btn_class}" '
f"{aria_attr}"
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="{btn_title}">'
f'<i class="mdi {btn_icon}"></i>{btn_label}</button>'
)
elif is_ready:
# Ready to import - show Import and Details buttons
details_url = self._build_validation_details_url(device_id, validation)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-success device-import-btn device-ready" '
f'data-device-id="{device_id}" '
f'data-import-mode="single"{vc_attributes} '
f'title="Import this device">'
f'<i class="mdi mdi-download"></i> Import</button>'
)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-outline-primary" '
f'aria-label="View details" '
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View details">'
f'<i class="mdi mdi-information-outline"></i></button>'
)
elif can_import:
# Has warnings - show Review button with Details
details_url = self._build_validation_details_url(device_id, validation)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-warning" '
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="Review and import">'
f'<i class="mdi mdi-alert"></i> Review</button>'
)
else:
# Cannot import (usually missing role) - show Import button (disabled until role selected) and Details
details_url = self._build_validation_details_url(device_id, validation)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-success device-import-btn" '
f'data-device-id="{device_id}" '
f"disabled{vc_attributes} "
f'title="Select a role to enable import">'
f'<i class="mdi mdi-download"></i> Import</button>'
)
buttons.append(
f'<button type="button" '
f'class="btn btn-sm btn-outline-danger" '
f'hx-get="{details_url}" '
f'hx-include="[name=cluster_{device_id}], [name=role_{device_id}], [name=rack_{device_id}], #use-sysname-toggle, #strip-domain-toggle" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View validation details">'
f'<i class="mdi mdi-alert-circle"></i> Details</button>'
)
return mark_safe('<div class="btn-group btn-group-sm">' + " ".join(buttons) + "</div>")
def render_virtual_chassis(self, value, record):
"""Render Virtual Chassis status and details button."""
validation = record.get("_validation", {})
vc_data = validation.get("virtual_chassis", {})
device_id = record.get("device_id")
# Show dash for non-VC or single member stacks
if not vc_data.get("is_stack") or vc_data.get("member_count", 0) <= 1:
return mark_safe('<span class="text-muted">—</span>')
vc_url = reverse(
"plugins:netbox_librenms_plugin:device_vc_details",
kwargs={"device_id": device_id},
)
# Show error button if detection failed
if vc_data.get("detection_error"):
return mark_safe(
f'<button type="button" '
f'class="btn btn-sm btn-outline-warning" '
f'hx-get="{vc_url}" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View virtual chassis error details">'
f'<i class="mdi mdi-alert"></i> Error</button>'
)
# Show member count button for valid multi-member stacks
member_count = vc_data.get("member_count", 0)
return mark_safe(
f'<button type="button" '
f'class="btn btn-sm btn-outline-info" '
f'hx-get="{vc_url}" '
f'hx-target="#htmx-modal-content" '
f'hx-swap="innerHTML" '
f'title="View virtual chassis details">'
f'<i class="mdi mdi-server-network"></i> {member_count} members</button>'
)
@staticmethod
def _build_validation_details_url(device_id: int, validation: dict) -> str:
"""
Build validation details URL with appropriate query parameters.
Constructs the URL for the device validation details modal, adding
cluster_id, role_id, and VC detection flag as query parameters.
Args:
device_id: LibreNMS device ID
validation: Validation dict from validate_device_for_import()
Returns:
str: Complete URL with query parameters
"""
details_url = reverse(
"plugins:netbox_librenms_plugin:device_validation_details",
kwargs={"device_id": device_id},
)
# Build query params based on import type
params = []
# Add cluster_id if this is a VM import
if validation.get("cluster", {}).get("found") and validation.get("cluster", {}).get("cluster"):
cluster_id = validation["cluster"]["cluster"].id
params.append(f"cluster_id={cluster_id}")
# Add role_id if device role is found
elif validation.get("device_role", {}).get("found") and validation.get("device_role", {}).get("role"):
role_id = validation["device_role"]["role"].id
params.append(f"role_id={role_id}")
# Add VC detection flag if it was enabled during initial load
if validation.get("_vc_detection_enabled"):
params.append("enable_vc_detection=true")
if params:
details_url += "?" + "&".join(params)
return details_url
@staticmethod
def _build_vc_attributes(validation: dict, record: dict) -> str:
vc_data = validation.get("virtual_chassis") or {}
if not vc_data.get("is_stack"):
return ' data-vc-is-stack="false"'
members_payload = []
for member in vc_data.get("members", []):
members_payload.append(
{
"position": member.get("position"),
"serial": member.get("serial"),
"suggested_name": member.get("suggested_name"),
}
)
payload = {
"member_count": vc_data.get("member_count", len(members_payload)),
"members": members_payload,
"detection_error": vc_data.get("detection_error"),
}
payload_json = escape(json.dumps(payload))
master_name = record.get("hostname") or record.get("sysName") or ""
master_value = escape(master_name)
return (
' data-vc-is-stack="true"'
f' data-vc-member-count="{payload["member_count"]}"'
f' data-vc-info="{payload_json}"'
f' data-vc-master="{master_value}"'
)
class Meta:
"""Meta options for DeviceImportTable."""
# No model - we're working with LibreNMS API dictionaries, not Django model instances
# This prevents NetBoxTable from auto-adding custom fields from Device model
# Add row attributes to give each row a unique ID for HTMX targeting
row_attrs = {
"id": lambda record: f"device-row-{record.get('device_id')}",
}
fields = (
"selection",
"hostname",
"sysname",
"location",
"hardware",
"netbox_cluster",
"netbox_role",
"netbox_rack",
"virtual_chassis",
"actions",
)
sequence = (
"selection",
"hostname",
"sysname",
"location",
"hardware",
"netbox_cluster",
"netbox_role",
"netbox_rack",
"virtual_chassis",
"actions",
)
default_columns = fields
orderable = True
attrs = {
"class": "table table-hover",
"id": "device-import-table",
}

View File

@@ -0,0 +1,616 @@
import json as json_module
import django_tables2 as tables
from django.utils.html import escape, format_html
from django.utils.safestring import mark_safe
from netbox.tables.columns import BooleanColumn, ToggleColumn
from utilities.paginator import EnhancedPaginator
from utilities.templatetags.helpers import humanize_speed
from netbox_librenms_plugin.models import InterfaceTypeMapping
from netbox_librenms_plugin.utils import (
check_vlan_group_matches,
convert_speed_to_kbps,
format_mac_address,
get_interface_name_field,
get_librenms_device_id,
get_missing_vlan_warning,
get_table_paginate_count,
get_tagged_vlan_css_class,
get_untagged_vlan_css_class,
get_virtual_chassis_member,
)
class LibreNMSInterfaceTable(tables.Table):
"""
Table for displaying LibreNMS interface data.
"""
class Meta:
"""Meta options for LibreNMSInterfaceTable."""
sequence = [
"selection",
"name",
"type",
"speed",
"vlans",
"mac_address",
"mtu",
"enabled",
"description",
"librenms_id",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-interface-table",
}
def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, server_key=None, **kwargs):
"""Initialize table with device context and interface name field."""
self.device = device
self.interface_name_field = interface_name_field or get_interface_name_field()
self.vlan_groups = vlan_groups or []
self.server_key = server_key
# Update column accessors after initialization
for column in ["selection", "name"]:
self.base_columns[column].accessor = self.interface_name_field
# Set row attributes using interface_name_field
self._meta.row_attrs = {
"data-interface": lambda record: record.get(self.interface_name_field),
"data-name": lambda record: record.get(self.interface_name_field),
"data-enabled": lambda record: (
str(record.get("ifAdminStatus")).lower() if record.get("ifAdminStatus") is not None else ""
),
}
super().__init__(*args, **kwargs)
self.tab = "interfaces"
self.htmx_url = None
self.prefix = "interfaces_"
selection = ToggleColumn(
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
)
name = tables.Column(verbose_name="Name", attrs={"td": {"data-col": "name"}})
type = tables.Column(
accessor="ifType",
verbose_name="Interface Type",
attrs={"td": {"data-col": "type"}},
)
speed = tables.Column(accessor="ifSpeed", verbose_name="Speed", attrs={"td": {"data-col": "speed"}})
mac_address = tables.Column(
accessor="ifPhysAddress",
verbose_name="MAC Address",
attrs={"td": {"data-col": "mac_address"}},
)
mtu = tables.Column(accessor="ifMtu", verbose_name="MTU", attrs={"td": {"data-col": "mtu"}})
enabled = BooleanColumn(verbose_name="Enabled", attrs={"td": {"data-col": "enabled"}})
description = tables.Column(
accessor="ifAlias",
verbose_name="Description",
attrs={"td": {"data-col": "description"}},
)
librenms_id = tables.Column(
accessor="port_id",
verbose_name="LibreNMS ID",
attrs={"td": {"data-col": "librenms_id"}},
)
vlans = tables.Column(
verbose_name="VLANs",
empty_values=(),
orderable=False,
attrs={"td": {"data-col": "vlans"}},
)
def render_vlans(self, value, record):
"""
Render VLANs column showing untagged and tagged VLANs.
Format: "100(U), 200(T), 300(T)" or "100(U)" for access ports.
Color logic:
- Red + warning icon: VLAN not in any NetBox group (cannot sync)
- Red: Not present in NetBox (no VLAN assigned on interface)
- Orange: Mismatched (different untagged VLAN assigned)
- Green: Matching (VLAN matches NetBox assignment)
Compact display: shows up to 3 VLANs inline, then summarizes.
An edit button opens the VLAN detail modal.
Hidden inputs store per-VLAN group assignments for form submission.
"""
untagged = record.get("untagged_vlan")
tagged = record.get("tagged_vlans", [])
missing_vlans = record.get("missing_vlans", [])
# Get NetBox interface for comparison
exists_in_netbox = record.get("exists_in_netbox", False)
netbox_interface = record.get("netbox_interface")
# Get NetBox VLAN assignments (VID + group for group-aware comparison)
netbox_untagged_vid = None
netbox_untagged_group_id = None
netbox_tagged_vids = set()
netbox_tagged_group_ids = {}
if netbox_interface:
if netbox_interface.untagged_vlan:
netbox_untagged_vid = netbox_interface.untagged_vlan.vid
netbox_untagged_group_id = netbox_interface.untagged_vlan.group_id
for v in netbox_interface.tagged_vlans.all():
netbox_tagged_vids.add(v.vid)
netbox_tagged_group_ids[v.vid] = v.group_id
all_vlans = []
if untagged:
all_vlans.append(("U", untagged))
for vid in sorted(tagged):
all_vlans.append(("T", vid))
if not all_vlans:
return mark_safe("")
interface_name = record.get(self.interface_name_field, "")
safe_name = interface_name.replace("/", "_").replace(":", "_")
# Build compact colored summary (show up to 3 VLANs, summarize rest)
vlan_group_map = record.get("vlan_group_map", {})
MAX_INLINE = 3
inline_parts = []
for vlan_type, vid in all_vlans[:MAX_INLINE]:
selected_gid = self._parse_group_id(vlan_group_map.get(vid, {}).get("group_id", ""))
group_matches = check_vlan_group_matches(
vlan_type,
vid,
selected_gid,
netbox_untagged_group_id,
netbox_tagged_group_ids,
netbox_untagged_vid,
netbox_tagged_vids,
)
if vlan_type == "U":
css = get_untagged_vlan_css_class(
vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches
)
else:
css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches)
warning = get_missing_vlan_warning(vid, missing_vlans)
inline_parts.append(f'<span class="{css}">{vid}({vlan_type}){warning}</span>')
summary = ", ".join(inline_parts)
if len(all_vlans) > MAX_INLINE:
extra = len(all_vlans) - MAX_INLINE
summary += f' <span class="text-muted">+{extra} more</span>'
# Build tooltip showing auto-selected VLAN group per VLAN
tooltip_lines = []
for vlan_type, vid in all_vlans:
if vid in missing_vlans:
tooltip_lines.append(f"VLAN {vid}({vlan_type}) → ⚠ Not in NetBox")
else:
group_info = vlan_group_map.get(vid, {})
group_name = group_info.get("group_name", "Global")
tooltip_lines.append(f"VLAN {vid}({vlan_type}) → {escape(group_name)}")
tooltip_text = "&#10;".join(tooltip_lines)
# Build hidden inputs for per-VLAN group selections (submitted with form)
hidden_inputs = []
for vlan_type, vid in all_vlans:
group_info = vlan_group_map.get(vid, {})
group_id = group_info.get("group_id", "")
hidden_inputs.append(
format_html(
'<input type="hidden" name="vlan_group_{}_{}" '
'value="{}" class="vlan-group-hidden" '
'data-interface="{}" data-vid="{}">',
safe_name,
vid,
group_id,
interface_name,
vid,
)
)
# Build JSON data for modal (use proper json serialization for safety)
vlan_json_items = []
for vlan_type, vid in all_vlans:
group_info = vlan_group_map.get(vid, {})
is_missing = vid in missing_vlans
selected_gid = self._parse_group_id(group_info.get("group_id", ""))
group_matches = check_vlan_group_matches(
vlan_type,
vid,
selected_gid,
netbox_untagged_group_id,
netbox_tagged_group_ids,
netbox_untagged_vid,
netbox_tagged_vids,
)
if vlan_type == "U":
css = get_untagged_vlan_css_class(
vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches
)
else:
css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches)
display_group_name = "Not in NetBox" if is_missing else group_info.get("group_name", "Global")
vlan_json_items.append(
{
"vid": vid,
"type": vlan_type,
"group_id": group_info.get("group_id", ""),
"group_name": display_group_name,
"css": css,
"missing": is_missing,
}
)
vlan_json = json_module.dumps(vlan_json_items)
device_id = self.device.pk if self.device else ""
# Build vlan_groups JSON for modal dropdowns
group_options = [{"id": "", "name": "-- No Group (Global) --", "scope": ""}]
for group in self.vlan_groups:
scope_info = str(group.scope) if hasattr(group, "scope") and group.scope else ""
group_options.append({"id": str(group.pk), "name": group.name, "scope": scope_info})
groups_json = json_module.dumps(group_options)
# Escape JSON for safe embedding in HTML attributes
escaped_vlan_json = escape(vlan_json)
escaped_groups_json = escape(groups_json)
edit_btn = format_html(
'<button type="button" class="btn btn-sm btn-link p-0 ms-1 vlan-edit-btn" '
'data-interface="{}" '
'data-safe-name="{}" '
'data-device-id="{}" '
"data-vlans='{}' "
"data-vlan-groups='{}' "
'title="Edit VLAN group assignments">'
'<i class="mdi mdi-pencil"></i></button>',
interface_name,
safe_name,
device_id,
escaped_vlan_json,
escaped_groups_json,
)
hidden_inputs_html = mark_safe("".join(str(h) for h in hidden_inputs))
return format_html(
'<span title="{}">{}</span>{}{}',
mark_safe(tooltip_text),
mark_safe(summary),
edit_btn,
hidden_inputs_html,
)
@staticmethod
def _parse_group_id(group_id_str):
"""Normalize a group ID string to int or None for comparison."""
return int(group_id_str) if group_id_str else None
def render_speed(self, value, record):
"""Render interface speed with appropriate styling based on comparison with NetBox"""
kbps_value = convert_speed_to_kbps(value)
return self._render_field(humanize_speed(kbps_value), record, "ifSpeed", "speed")
def render_name(self, value, record):
"""Render interface name with appropriate styling based on comparison with NetBox"""
return self._render_field(value, record, self.interface_name_field, "name")
def _get_interface_status_display(self, enabled, record):
"""
Determine interface status display and CSS class based on enabled state and NetBox comparison.
Args:
enabled (bool): Interface enabled state.
record (dict): Interface data record.
Returns:
tuple: (display_value, css_class)
"""
display_value = "Enabled" if enabled else "Disabled"
if not record.get("exists_in_netbox"):
return display_value, "text-danger"
netbox_interface = record.get("netbox_interface")
if netbox_interface:
netbox_enabled = netbox_interface.enabled
if enabled == netbox_enabled:
return display_value, "text-success"
return display_value, "text-warning"
return display_value, "text-danger"
def _parse_enabled_status(self, value):
"""Convert interface status value to boolean enabled state"""
if isinstance(value, str):
return value.lower() == "up"
return bool(value)
def render_enabled(self, value, record):
"""Render interface enabled status with appropriate styling based on comparison with NetBox"""
enabled = self._parse_enabled_status(value)
display_value, css_class = self._get_interface_status_display(enabled, record)
return format_html('<span class="{}">{}</span>', css_class, display_value)
def render_description(self, value, record):
"""Render interface description with appropriate styling based on comparison with NetBox"""
return self._render_field(value, record, "ifAlias", "description")
def render_mac_address(self, value, record):
"""Render MAC address with appropriate styling based on comparison with NetBox"""
formatted_mac = format_mac_address(value)
return self._render_field(formatted_mac, record, "ifPhysAddress", "mac_address")
def render_mtu(self, value, record):
"""Render MTU with appropriate styling based on comparison with NetBox"""
return self._render_field(value, record, "ifMtu", "mtu")
def render_librenms_id(self, value, record):
"""Render the 'librenms_id' field with appropriate styling based on comparison with NetBox."""
if not record.get("exists_in_netbox"):
return mark_safe(f'<span class="text-danger">{value}</span>')
netbox_interface = record.get("netbox_interface")
if not netbox_interface:
return mark_safe(f'<span class="text-danger">{value}</span>')
netbox_librenms_id = get_librenms_device_id(netbox_interface, self.server_key, auto_save=False)
if netbox_librenms_id is None:
return mark_safe(
f'<span class="text-danger" title="No librenms_id custom field value found">{value}</span>'
)
# Compare the IDs
if str(value) != str(netbox_librenms_id):
# IDs do not match
return mark_safe(
f'<span class="text-warning" title="Existing LibreNMS ID: {netbox_librenms_id}">{value}</span>'
)
else:
# IDs match
return mark_safe(f'<span class="text-success">{value}</span>')
def _compare_mac_addresses(self, librenms_mac, netbox_interface):
"""
Compare LibreNMS MAC address against all MAC addresses on NetBox interface.
Args:
librenms_mac (str): MAC address from LibreNMS.
netbox_interface (Interface): NetBox interface record.
Returns:
True if MAC exists on interface.
"""
if not netbox_interface:
return False
interface_macs = [mac.mac_address for mac in netbox_interface.mac_addresses.all()]
return librenms_mac in interface_macs
def _render_field(self, value, record, librenms_key, netbox_key):
"""Render a field value with appropriate styling based on the comparison with NetBox."""
if not record.get("exists_in_netbox"):
return mark_safe(f'<span class="text-danger">{value}</span>')
netbox_interface = record.get("netbox_interface")
if not netbox_interface:
return mark_safe(f'<span class="text-danger">{value}</span>')
if librenms_key == "ifPhysAddress":
mac_matches = self._compare_mac_addresses(value, netbox_interface)
css_class = "text-success" if mac_matches else "text-warning"
return mark_safe(f'<span class="{css_class}">{value}</span>')
netbox_value = getattr(netbox_interface, netbox_key, None)
librenms_value = record.get(librenms_key)
if librenms_key == "ifSpeed":
librenms_value = convert_speed_to_kbps(librenms_value)
if librenms_value != netbox_value:
return mark_safe(f'<span class="text-warning">{value}</span>')
return mark_safe(f'<span class="text-success">{value}</span>')
def render_type(self, value, record):
"""Render interface type with appropriate styling based on comparison with NetBox"""
speed = convert_speed_to_kbps(record.get("ifSpeed", 0))
mapping = self.get_interface_mapping(value, speed)
tooltip_value, icon = self.render_mapping_tooltip(value, speed, mapping)
combined_display = format_html("{} {}", tooltip_value, icon)
if not record.get("exists_in_netbox"):
return format_html('<span class="text-danger">{}</span>', combined_display)
netbox_interface = record.get("netbox_interface")
if netbox_interface:
netbox_type = getattr(netbox_interface, "type", None)
if mapping and mapping.netbox_type == netbox_type:
return format_html('<span class="text-success">{}</span>', combined_display)
elif mapping:
return format_html('<span class="text-warning">{}</span>', combined_display)
return format_html('<span class="text-danger">{}</span>', combined_display)
def get_interface_mapping(self, librenms_type, speed):
"""Get interface type mapping based on type and speed"""
# First try exact match with type and speed
mapping = InterfaceTypeMapping.objects.filter(librenms_type=librenms_type, librenms_speed=speed).first()
# If no match found, fall back to type-only match
if not mapping:
mapping = InterfaceTypeMapping.objects.filter(
librenms_type=librenms_type, librenms_speed__isnull=True
).first()
return mapping
def render_mapping_tooltip(self, value, speed, mapping):
"""Render tooltip for interface type mapping"""
if mapping:
display = mapping.netbox_type
icon = format_html(
'<i class="mdi mdi-link-variant" title="Mapped from LibreNMS type: {} (Speed: {})"></i>',
value,
speed,
)
else:
display = value
icon = mark_safe('<i class="mdi mdi-link-variant-off" title="No mapping to NetBox type"></i>')
return display, icon
def format_interface_data(self, port_data, device):
"""Format single interface data using table rendering logic"""
# Add NetBox interface data
interface_name = port_data.get(self.interface_name_field)
port_data["netbox_interface"] = device.interfaces.filter(name=interface_name).first()
port_data["exists_in_netbox"] = bool(port_data["netbox_interface"])
# Clear description if it matches interface name
if port_data["ifAlias"] == port_data["ifName"] or port_data["ifAlias"] == port_data["ifDescr"]:
port_data["ifAlias"] = ""
formatted_data = {
"name": self.render_name(interface_name, port_data),
"type": self.render_type(port_data["ifType"], port_data),
"speed": self.render_speed(port_data["ifSpeed"], port_data),
"mac_address": self.render_mac_address(port_data["ifPhysAddress"], port_data),
"mtu": self.render_mtu(port_data["ifMtu"], port_data),
"enabled": self.render_enabled(port_data["ifAdminStatus"], port_data),
"description": self.render_description(port_data["ifAlias"], port_data),
}
return formatted_data
def configure(self, request):
"""Configure the table with pagination and other options"""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)
class VCInterfaceTable(LibreNMSInterfaceTable):
"""
Table for displaying Virtual Chassis interface data.
"""
device_selection = tables.Column(
verbose_name="Virtual Chassis member",
accessor="device",
orderable=False,
empty_values=[],
attrs={"td": {"data-col": "device_selection"}},
)
def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, **kwargs):
"""Initialize VC interface table with device and name field."""
super().__init__(
*args, device=device, interface_name_field=interface_name_field, vlan_groups=vlan_groups, **kwargs
)
# Ensure device_selection column is visible
if hasattr(self.device, "virtual_chassis") and self.device.virtual_chassis:
self.columns.show("device_selection")
# Update selection column accessor to match interface_name_field
self.base_columns["selection"].accessor = self.interface_name_field
def render_device_selection(self, value, record):
"""
Renders a device selection dropdown for virtual chassis members.
Determines the selected member based on interface type and name.
Returns an HTML select element with appropriate member options.
"""
members = self.device.virtual_chassis.members.all()
if_type = record.get("ifType", "").lower()
interface_name = record.get(self.interface_name_field)
if "ethernet" in if_type:
chassis_member = get_virtual_chassis_member(self.device, interface_name)
selected_member_id = chassis_member.id if chassis_member else self.device.id
else:
selected_member_id = self.device.id
# Create unique base ID for TomSelect components
base_id = f"device_selection_{interface_name}_{hash(interface_name)}"
options = [
f'<option value="{member.id}"{" selected" if member.id == selected_member_id else ""}>{member.name}</option>'
for member in members
]
return format_html(
'<select name="device_selection_{0}" id="{1}" class="form-select vc-member-select" data-interface="{0}" data-row-id="{0}">{2}</select>',
interface_name,
base_id,
mark_safe("".join(options)),
)
def format_interface_data(self, port_data, device):
"""Format interface data including VC device selection column."""
formatted_data = super().format_interface_data(port_data, device)
formatted_data["device_selection"] = self.render_device_selection(None, port_data)
return formatted_data
class Meta:
"""Meta options for VCInterfaceTable."""
sequence = [
"selection",
"device_selection",
"name",
"type",
"speed",
"vlans",
"mac_address",
"mtu",
"enabled",
"description",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-interface-table",
}
class LibreNMSVMInterfaceTable(LibreNMSInterfaceTable):
"""
Table for displaying LibreNMS VM interface data.
"""
class Meta(LibreNMSInterfaceTable.Meta):
"""Meta options for LibreNMSVMInterfaceTable."""
sequence = [
"selection",
"name",
"vlans",
"mac_address",
"mtu",
"enabled",
"description",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-interface-table-vm",
}
# Remove the type and speed column for VMs
type = None
speed = None

View File

@@ -0,0 +1,122 @@
import django_tables2 as tables
from django.utils.html import format_html, mark_safe
from netbox.tables.columns import ToggleColumn
from utilities.paginator import EnhancedPaginator
from netbox_librenms_plugin.utils import get_table_paginate_count
class IPAddressTable(tables.Table):
"""
Table for displaying LibreNMS IP address data.
"""
def __init__(self, *args, **kwargs):
"""Initialize IP address table."""
super().__init__(*args, **kwargs)
class Meta:
"""Meta options for IPAddressTable."""
sequence = [
"selection",
"address",
"prefix_length",
"device",
"interface_name",
"vrf",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-ipaddress-table",
}
row_attrs = {
"data-interface": lambda record: record["ip_address"],
"data-name": lambda record: record["ip_address"],
}
selection = ToggleColumn(
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
accessor="ip_address",
)
address = tables.Column(
accessor="ip_address",
verbose_name="IP Address",
linkify=lambda record: record.get("ip_url"),
attrs={"td": {"data-col": "address"}},
)
prefix_length = tables.Column(
accessor="prefix_length",
verbose_name="Prefix Length",
attrs={"td": {"data-col": "prefix"}},
)
device = tables.Column(
linkify=lambda record: record.get("device_url"),
attrs={"td": {"data-col": "device"}},
)
interface_name = tables.Column(
accessor="interface_name",
verbose_name="Interface",
linkify=lambda record: record.get("interface_url"),
attrs={"td": {"data-col": "interface"}},
)
vrf = tables.TemplateColumn(
template_code="""
<select id="vrf_select_{{ record.ip_address|slugify }}" class="form-select vrf-select" data-ip="{{ record.ip_address }}" data-prefix="{{ record.prefix_length }}" data-row-id="{{ record.ip_address }}" name="vrf_{{ record.ip_address }}">
<option value="">Global</option>
{% for vrf in record.vrfs %}
<option value="{{ vrf.pk }}" {% if record.vrf_id == vrf.pk %}selected{% endif %}>
{{ vrf.name }}
</option>
{% endfor %}
</select>
""",
attrs={"td": {"data-col": "vrf"}},
verbose_name="VRF",
)
status = tables.Column(
verbose_name="Status",
attrs={"td": {"data-col": "status"}},
)
def render_status(self, value, record):
"""Render the status column with appropriate buttons or text styling"""
if value == "update":
return format_html(
'<button type="submit" class="btn btn-sm btn-warning" onclick="document.getElementById(\'selected_ip\').value=\'{}\'">'
'<i class="mdi mdi-pencil" aria-hidden="true"></i> Update</button>',
record["ip_address"],
)
elif value == "matched":
return mark_safe('<span class="text-success"><i class="mdi mdi-check-circle"></i> Synced</span>')
elif record.get("interface_url"):
return format_html(
'<button type="submit" class="btn btn-sm btn-primary" onclick="document.getElementById(\'selected_ip\').value=\'{}\'">'
'<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Create</button>',
record["ip_address"],
)
return mark_safe('<span class="text-muted">Missing NetBox Object</span>')
def render_device(self, value, record):
"""Render the device column with a link if available"""
if url := record.get("device_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def render_interface_name(self, value, record):
"""Render the interface column with a link if available"""
if url := record.get("interface_url"):
return format_html('<a href="{}">{}</a>', url, value)
return value
def configure(self, request):
"""Configure the table"""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)

View File

@@ -0,0 +1,84 @@
import django_tables2 as tables
from django.middleware.csrf import get_token
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from utilities.paginator import EnhancedPaginator, get_paginate_count
class SiteLocationSyncTable(tables.Table):
"""
Table for displaying Netbox Site and Librenms Location data.
"""
netbox_site = tables.Column(linkify=True)
latitude = tables.Column(accessor="netbox_site__latitude")
longitude = tables.Column(accessor="netbox_site__longitude")
librenms_location = tables.Column(accessor="librenms_location__location", verbose_name="LibreNMS Location")
librenms_latitude = tables.Column(accessor="librenms_location__lat", verbose_name="LibreNMS Latitude")
librenms_longitude = tables.Column(accessor="librenms_location__lng", verbose_name="LibreNMS Longitude")
actions = tables.Column(empty_values=())
def render_latitude(self, value, record):
"""Render latitude with sync-status styling."""
return self.render_coordinate(value, record.is_synced)
def render_longitude(self, value, record):
"""Render longitude with sync-status styling."""
return self.render_coordinate(value, record.is_synced)
def render_coordinate(self, value, is_synced):
"""Render coordinate with success or danger text color."""
css_class = "text-success" if is_synced else "text-danger"
return format_html('<span class="{}">{}</span>', css_class, value)
def render_actions(self, record):
"""Render action buttons with styles based on sync status or action."""
csrf_token = get_token(self.request)
if record.is_synced:
return mark_safe(
'<span class="text-success"><i class="mdi mdi-check-circle" aria-hidden="true"></i> Synced</span>'
)
if record.librenms_location:
return mark_safe(
f'<form method="post">'
f'<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">'
f'<input type="hidden" name="action" value="update">'
f'<input type="hidden" name="pk" value="{record.netbox_site.pk}">'
'<button type="submit" class="btn btn-sm btn-warning">'
'<i class="mdi mdi-pencil" aria-hidden="true"></i> Update in LibreNMS'
"</button>"
"</form>"
)
else:
return mark_safe(
f'<form method="post">'
f'<input type="hidden" name="csrfmiddlewaretoken" value="{csrf_token}">'
f'<input type="hidden" name="action" value="create">'
f'<input type="hidden" name="pk" value="{record.netbox_site.pk}">'
'<button type="submit" class="btn btn-sm btn-primary">'
'<i class="mdi mdi-plus-thick" aria-hidden="true"></i> Create in LibreNMS'
"</button>"
"</form>"
)
def configure(self, request):
"""Configure the table with pagination and custom attributes."""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_paginate_count(request),
}
tables.RequestConfig(request, paginate).configure(self)
class Meta:
"""Meta options for SiteLocationSyncTable."""
fields = (
"netbox_site",
"latitude",
"longitude",
"librenms_location",
"librenms_latitude",
"librenms_longitude",
"actions",
)
attrs = {"class": "table table-hover table-headings table-striped"}

View File

@@ -0,0 +1,38 @@
import django_tables2 as tables
from netbox.tables import NetBoxTable, columns
from netbox_librenms_plugin.models import InterfaceTypeMapping
class InterfaceTypeMappingTable(NetBoxTable):
"""
Table for displaying InterfaceTypeMapping data.
"""
librenms_type = tables.Column(verbose_name="LibreNMS Type")
librenms_speed = tables.Column(verbose_name="LibreNMS Speed (Kbps)")
netbox_type = tables.Column(verbose_name="NetBox Type")
description = tables.Column(verbose_name="Description", linkify=False)
actions = columns.ActionsColumn(actions=("edit", "delete"))
class Meta:
"""Meta options for InterfaceTypeMappingTable."""
model = InterfaceTypeMapping
fields = (
"id",
"librenms_type",
"librenms_speed",
"netbox_type",
"description",
"actions",
)
default_columns = (
"id",
"librenms_type",
"librenms_speed",
"netbox_type",
"description",
"actions",
)
attrs = {"class": "table table-hover table-headings table-striped"}

View File

@@ -0,0 +1,183 @@
import django_tables2 as tables
from django.utils.html import format_html, format_html_join
from django.utils.safestring import mark_safe
from netbox.tables.columns import ToggleColumn
from utilities.paginator import EnhancedPaginator
from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE
from netbox_librenms_plugin.utils import get_table_paginate_count, get_vlan_sync_css_class
class LibreNMSVLANTable(tables.Table):
"""
Table for displaying LibreNMS VLAN data for a device.
Shows VLANs configured on the device and their sync status with NetBox.
Includes per-row VLAN group selection dropdown.
"""
class Meta:
sequence = [
"selection",
"vlan_id",
"name",
"vlan_group_selection",
"type",
"state",
]
attrs = {
"class": "table table-hover object-list",
"id": "librenms-vlan-table",
}
row_attrs = {
"data-vlan-id": lambda record: record.get("vlan_id"),
}
def __init__(self, *args, vlan_groups=None, **kwargs):
super().__init__(*args, **kwargs)
self.prefix = "vlans_"
self.vlan_groups = vlan_groups or []
selection = ToggleColumn(
orderable=False,
visible=True,
attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
accessor="vlan_id",
)
vlan_id = tables.Column(
accessor="vlan_id",
verbose_name="VLAN ID",
attrs={"td": {"data-col": "vlan_id"}},
)
name = tables.Column(
accessor="name",
verbose_name="Name",
attrs={"td": {"data-col": "name"}},
)
vlan_group_selection = tables.Column(
verbose_name="VLAN Group",
empty_values=(),
orderable=False,
attrs={"td": {"data-col": "vlan_group_selection"}},
)
type = tables.Column(
accessor="type",
verbose_name="Type",
attrs={"td": {"data-col": "type"}},
)
state = tables.Column(
accessor="state",
verbose_name="State",
attrs={"td": {"data-col": "state"}},
)
def render_vlan_id(self, value, record):
"""Render VLAN ID with color based on sync status."""
css_class = get_vlan_sync_css_class(
record.get("exists_in_netbox", False),
record.get("name_matches", True),
)
return format_html('<span class="{}">{}</span>', css_class, value)
def render_name(self, value, record):
"""Render VLAN name with color based on sync status."""
css_class = get_vlan_sync_css_class(
record.get("exists_in_netbox", False),
record.get("name_matches", True),
)
# Add tooltip on name mismatch
if record.get("exists_in_netbox") and not record.get("name_matches", True):
netbox_name = record.get("netbox_vlan_name", "")
tooltip = f"NetBox: {netbox_name} | LibreNMS: {value}"
return format_html(
'<span class="{}" title="{}">{}</span>',
css_class,
tooltip,
value or "",
)
return format_html('<span class="{}">{}</span>', css_class, value or "")
def render_vlan_group_selection(self, value, record):
"""
Render per-row VLAN group dropdown.
Auto-selects based on matching priority:
1. Existing NetBox VLAN's group (if exists_in_netbox)
2. Unique VID match (if VID exists in exactly one group)
3. No selection (with warning icon if ambiguous)
"""
vlan_id = record.get("vlan_id")
# Determine which group to auto-select
selected_group_id = None
# Priority 1: Existing NetBox VLAN group
if record.get("exists_in_netbox") and record.get("netbox_vlan_group_id"):
selected_group_id = record["netbox_vlan_group_id"]
elif record.get("auto_selected_group_id"):
# Priority 2: unique VID match
selected_group_id = record["auto_selected_group_id"]
# Build the select element using format_html_join to prevent XSS
options_html = format_html_join(
"",
'<option value="{}" data-scope="{}"{}>{}{}</option>',
[
(
"",
"",
"",
"-- No Group (Global) --",
"",
),
]
+ [
(
group.pk,
group.scope_id if group.scope_id else "",
" selected" if group.pk == selected_group_id else "",
group.name,
f" ({group.scope})" if group.scope else "",
)
for group in self.vlan_groups
],
)
select_html = format_html(
'<select name="vlan_group_{}" class="form-select form-select-sm vlan-sync-group-select"'
' data-vlan-id="{}" data-vlan-name="{}" style="min-width: 180px;">{}</select>',
vlan_id,
vlan_id,
record.get("name", ""),
options_html,
)
# Add warning icon if ambiguous (VID exists in multiple groups at same priority level)
if record.get("is_ambiguous") and not record.get("exists_in_netbox"):
warning_html = mark_safe(
'<i class="mdi mdi-alert text-warning ms-1" '
'title="VID exists in multiple groups at the same scope level. Please select the target group."></i>'
)
return format_html("{}{}", select_html, warning_html)
return select_html
def render_state(self, value, record):
"""Render VLAN state (active/inactive)."""
if value == LIBRENMS_VLAN_STATE_ACTIVE or value == "active":
return mark_safe('<span class="text-success">Active</span>')
return mark_safe('<span class="text-muted">Inactive</span>')
def configure(self, request):
"""Configure the table with pagination."""
paginate = {
"paginator_class": EnhancedPaginator,
"per_page": get_table_paginate_count(request, self.prefix),
}
tables.RequestConfig(request, paginate).configure(self)