first commit
This commit is contained in:
56
netbox_librenms_plugin/tables/VM_status.py
Normal file
56
netbox_librenms_plugin/tables/VM_status.py
Normal 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",
|
||||
)
|
||||
21
netbox_librenms_plugin/tables/__init__.py
Normal file
21
netbox_librenms_plugin/tables/__init__.py
Normal 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",
|
||||
]
|
||||
161
netbox_librenms_plugin/tables/cables.py
Normal file
161
netbox_librenms_plugin/tables/cables.py
Normal 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",
|
||||
}
|
||||
722
netbox_librenms_plugin/tables/device_status.py
Normal file
722
netbox_librenms_plugin/tables/device_status.py
Normal 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",
|
||||
}
|
||||
616
netbox_librenms_plugin/tables/interfaces.py
Normal file
616
netbox_librenms_plugin/tables/interfaces.py
Normal 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 = " ".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
|
||||
122
netbox_librenms_plugin/tables/ipaddresses.py
Normal file
122
netbox_librenms_plugin/tables/ipaddresses.py
Normal 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)
|
||||
84
netbox_librenms_plugin/tables/locations.py
Normal file
84
netbox_librenms_plugin/tables/locations.py
Normal 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"}
|
||||
38
netbox_librenms_plugin/tables/mappings.py
Normal file
38
netbox_librenms_plugin/tables/mappings.py
Normal 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"}
|
||||
183
netbox_librenms_plugin/tables/vlans.py
Normal file
183
netbox_librenms_plugin/tables/vlans.py
Normal 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)
|
||||
Reference in New Issue
Block a user