723 lines
28 KiB
Python
723 lines
28 KiB
Python
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",
|
|
}
|