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

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

View File

@@ -0,0 +1,28 @@
{% load helpers %}
{% load static %}
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Cable Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_cable_sync' pk=object.pk %}"
hx-target="#cable-sync-content"
class="btn btn-outline-primary">
Refresh Cables
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- Container for the cable sync content -->
<div id="cable-sync-content">
{% include 'netbox_librenms_plugin/_cable_sync_content.html' %}
</div>

View File

@@ -0,0 +1,118 @@
{% load helpers %}
{% include 'inc/messages.html' %}
<!-- Cable Sync Table -->
{% if cable_sync.table %}
<form method="post" action="{% url 'plugins:netbox_librenms_plugin:sync_device_cables' cable_sync.object.pk %}">
{% csrf_token %}
{% if cable_sync.server_key %}<input type="hidden" name="server_key" value="{{ cable_sync.server_key }}">{% endif %}
<input type="hidden" id="selected_port" name="select" value="">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected Cables</span>
</button>
<a href="#" class="m-2" data-bs-toggle="modal" data-bs-target="#cableSyncHelpModal">
<i class="mdi mdi-help-circle"></i> info
</a>
</div>
{% if cable_sync.cache_expiry %}
<div id="cable-cache-countdown" class="me-3">
Cache expires in: <span id="cable-countdown-timer" data-expiry="{{ cable_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="d-flex justify-content-end align-items-center mb-3">
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#cableFilterSection" aria-expanded="false" aria-controls="cableFilterSection">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse mb-3" id="cableFilterSection">
<div class="mb-2">
<small class="text-muted">
<i class="mdi mdi-information"></i> Filters apply to currently displayed cables.
</small>
</div>
<div class="filter-container d-flex gap-2">
{% if cable_sync.table.attrs.id == "librenms-cable-table-vc" %}
<input type="text" id="filter-vc-member" placeholder="Filter by VC Member" class="form-control">
{% endif %}
<input type="text" id="filter-local-port" placeholder="Filter by Local Port" class="form-control">
<input type="text" id="filter-remote-port" placeholder="Filter by Remote Port" class="form-control">
<input type="text" id="filter-remote-device" placeholder="Filter by Remote Device" class="form-control">
</div>
</div>
<style>
/* Your existing CSS rules */
.ts-wrapper.multi .ts-control {
display: flex !important;
flex-wrap: wrap;
align-items: center;
}
/* Updated rules using min-width */
td[data-col="device_selection"] {
width: 300px;
min-width: 200px;
}
td[data-col="device_selection"] .ts-wrapper {
width: 100%;
max-width: 100%;
}
</style>
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=cable_sync.table %}
{% include 'inc/table.html' with table=cable_sync.table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=cable_sync.table %}
</div>
</div>
</div>
</form>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No cable data loaded. Click <strong>Refresh Cables</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% endif %}
<!-- Interface Type Help Modal -->
<div class="modal fade" id="cableSyncHelpModal" tabindex="-1" aria-labelledby="cableSyncHelpModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="cableSyncHelpModalLabel">NetBox Interface Sync Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h5>Device Association</h5>
<p>The plugin uses two methods to associate LibreNMS devices with NetBox devices:</p>
<ol>
<li><strong>LibreNMS ID (Recommended)</strong>
<ul>
<li>Uses the custom field 'librenms_id' on NetBox devices</li>
<li>Automatically populated when viewing the LibreNMS Sync page if device is found.</li>
<li>Can be manually entered in device custom fields</li>
<li>Provides the most reliable device matching</li>
</ul>
</li>
<li><strong>Device Name Fallback</strong>
<ul>
<li>Used when librenms_id is not available</li>
<li>Matches are case-sensitive</li>
<li>Less reliable due to potential naming differences</li>
</ul>
</li>
</ol>
<p>For best results, ensure the librenms_id custom field is populated on your devices.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
{% load helpers %}
{% load static %}
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>Interface Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_interface_sync' pk=object.pk %}"
hx-target="#interface-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh Interfaces
</button>
{% elif model_name == "virtualmachine" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:vm_interface_sync' pk=object.pk %}"
hx-target="#interface-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh Interfaces
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- End Action Buttons -->
<!-- Container for the interface sync content -->
<div id="interface-sync-content">
{% include 'netbox_librenms_plugin/_interface_sync_content.html' %}
</div>

View File

@@ -0,0 +1,358 @@
{% load helpers %}
{% include 'inc/messages.html' %}
<!-- Interface Sync Table -->
{% if interface_sync.table %}
{% with model_name=interface_sync.object|meta:"model_name" %}
<form method="post"
action="{% url 'plugins:netbox_librenms_plugin:sync_selected_interfaces' object_type=model_name object_id=interface_sync.object.pk %}?interface_name_field={{ interface_name_field }}">
{% endwith %}
{% csrf_token %}
{% if interface_sync.server_key %}<input type="hidden" name="server_key" value="{{ interface_sync.server_key }}">{% endif %}
{% block table_actions %}
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected Interfaces</span>
</button>
<a href="#" class="m-2" data-bs-toggle="modal" data-bs-target="#interfaceTypeHelpModal">
<i class="mdi mdi-help-circle"></i> info
</a>
</div>
<div class="ms-auto d-flex align-items-center">
<div class="exclude-columns-section d-flex align-items-center gap-2 me-2">
<h6 class="mb-0">Exclude from Sync:</h6>
<div class="d-flex align-items-center m-0">
<span class="small me-1">Type</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="type" id="excludeType">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">Speed</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="speed" id="excludeSpeed">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">VLANs</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="vlans" id="excludeVlans">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">MAC</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="mac_address" id="excludeMACAddress">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">MTU</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="mtu" id="excludeMTU">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">Enabled</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="enabled" id="excludeEnabled">
</div>
<div class="d-flex align-items-center m-1">
<span class="small me-1">Description</span>
<input class="form-check-input form-check-input-sm" type="checkbox" name="exclude_columns"
value="description" id="excludeDescription">
</div>
</div>
</div>
</div>
{% endblock %} <!-- End block table_actions -->
<div class="row mb-3">
<div class="col col-md-12">
<div class="d-flex justify-content-between align-items-center mb-3">
<div>
{% if interface_sync.object.virtual_chassis %}
<button type="button" class="btn btn-secondary" id="bulk-vc-member-button" data-bs-toggle="modal"
data-bs-target="#bulkVCMemberModal" disabled>
Bulk Edit VC Member
</button>
{% endif %}
{% if interface_sync.netbox_only_interfaces %}
<a href="#" class="ms-2 text-warning text-decoration-none netbox-only-link" data-bs-toggle="modal"
data-bs-target="#netboxOnlyInterfacesModal"
title="Click to view and delete NetBox-only interfaces">
<i class="mdi mdi-alert-circle-outline me-1"></i>
{{interface_sync.netbox_only_interfaces|length}} NetBox only interfaces
</a>
{% endif %}
</div>
<div class="ms-auto d-flex align-items-center">
{% if interface_sync.cache_expiry %}
<div id="cache-countdown" class="me-3">
Cache expires in: <span id="countdown-timer"
data-expiry="{{ interface_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
<div class="color-key me-3">
<span class="badge text-success text-white">Matching values</span>
<span class="badge text-warning text-white">Mismatched values</span>
<span class="badge text-danger text-white">Not present in NetBox</span>
</div>
</div>
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse"
data-bs-target="#interfaceFilterSection" aria-expanded="false"
aria-controls="interfaceFilterSection">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse mb-3" id="interfaceFilterSection">
<div class="mb-2">
<small class="text-muted">
<i class="mdi mdi-information"></i> Filters apply to currently displayed interfaces. Adjust the
"per page" setting to apply the filters to more interfaces.
</small>
</div>
<div class="filter-container d-flex gap-2">
<input type="text" id="filter-name" placeholder="Filter by Name" class="form-control">
{% if interface_sync.table.attrs.id == 'librenms-interface-table' %}
<input type="text" id="filter-type" placeholder="Filter by Type" class="form-control">
<input type="text" id="filter-speed" placeholder="Filter by Speed" class="form-control">
{% endif %}
<input type="text" id="filter-mac" placeholder="Filter by MAC" class="form-control">
<input type="text" id="filter-mtu" placeholder="Filter by MTU" class="form-control">
<input type="text" id="filter-enabled" placeholder="Filter by Status" class="form-control">
<input type="text" id="filter-description" placeholder="Filter by Description" class="form-control">
</div>
</div>
<style>
/* Your existing CSS rules */
.ts-wrapper.multi .ts-control {
display: flex !important;
flex-wrap: wrap;
align-items: center;
}
/* Updated rules using min-width */
td[data-col="device_selection"] {
width: 300px;
min-width: 200px;
}
td[data-col="device_selection"] .ts-wrapper {
width: 100%;
max-width: 100%;
}
/* NetBox-only interfaces link styling */
.netbox-only-link {
cursor: pointer;
transition: opacity 0.2s ease;
}
.netbox-only-link:hover {
opacity: 0.8;
text-decoration: underline !important;
}
</style>
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=interface_sync.table %}
{% include 'inc/table.html' with table=interface_sync.table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=interface_sync.table %}
</div>
</div>
</div>
<!-- VLAN Detail Modal (inside form so hidden inputs are submitted) -->
<div class="modal fade" id="vlanDetailModal" tabindex="-1" aria-labelledby="vlanDetailModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="vlanDetailModalLabel">VLAN Assignments</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="text-muted mb-2">Interface: <strong id="vlanModalInterfaceName"></strong></p>
<table class="table table-sm table-hover mb-3" id="vlanDetailTable">
<thead>
<tr>
<th style="width: 80px;">VID</th>
<th style="width: 60px;">Type</th>
<th>VLAN Group</th>
</tr>
</thead>
<tbody id="vlanDetailTableBody">
<!-- Populated by JS -->
</tbody>
</table>
<div class="form-check">
<input class="form-check-input" type="checkbox" id="applyVlanGroupToAll">
<label class="form-check-label" for="applyVlanGroupToAll">
Apply group assignments to all interfaces with matching VLANs
</label>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" id="saveVlanGroups">Save</button>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
</form>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No interface data loaded. Click <strong>Refresh Interfaces</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% endif %} <!-- End if interface_sync.table -->
<!-- Interface Type Help Modal -->
<div class="modal fade" id="interfaceTypeHelpModal" tabindex="-1" aria-labelledby="interfaceTypeHelpModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="interfaceTypeHelpModalLabel">NetBox Interface Sync Info</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<h5>Interface Type mapping</h5>
<p>Interface type mappings control how LibreNMS interface types are translated to NetBox interface types
during synchronization. These mappings can be customized in the plugin settings menu. The icons in
the interface list indicate the mapping status for each interface type:</p>
<ul>
<li><i class="mdi mdi-link-variant"></i> - A mapping is configured for this interface type</li>
<li><i class="mdi mdi-link-variant-off"></i> - No mapping is currently set for this interface type
</li>
</ul>
<h5>Virtual Chassis Member Selection</h5>
<p>For devices that are part of a virtual chassis, the plugin will attempt to select the correct virutal
chassis member by matching the first number in the interface name to the device position in the
virutal chassis. The selected device can be changed in the table before sync the interface.</p>
<p>When changing the selected device for an interface row, the data will be checked again against the
newly selected device.</p>
<h5>VLAN Group Selection</h5>
<p>Each VLAN in the VLANs column is automatically assigned to a VLAN group using a priority
scope order: <strong>Rack → Location → Site → Site Group → Region → Global</strong>.
The most specific scope that contains the VLAN wins. If a VLAN exists in only one group,
that group is selected regardless of scope. Click the <i class="mdi mdi-pencil"></i> icon
on any row to change the group assignment for individual VLANs.</p>
<p>Check <strong>Apply group assignments to all interfaces with matching VLANs</strong> in the
modal to apply your selection to every interface that shares the same VLAN IDs. This choice is
saved for the duration of the cache, so subsequent table pages will also use it.</p>
<p>VLANs shown with a <i class="mdi mdi-alert text-danger"></i>
warning icon do not exist in the selected VLAN group in NetBox yet. Use the <strong>VLAN Sync</strong> tab to create them
before syncing interfaces.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
<!-- Bulk VC Member Selection Modal -->
<div class="modal fade" id="bulkVCMemberModal" tabindex="-1" aria-labelledby="bulkVCMemberModalLabel"
aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bulkVCMemberModalLabel">Set Virtual Chassis Member</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<label for="bulk-vc-member-select">Select Virtual Chassis Member:</label>
<select id="bulk-vc-member-select" class="form-select">
{% for member in interface_sync.virtual_chassis_members %}
<option value="{{ member.id }}">{{ member.name }}</option>
{% endfor %}
</select>
</div>
<div class="modal-footer">
<button class="btn btn-primary" id="apply-bulk-vc-member" data-bs-dismiss="modal">Apply</button>
<button class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>
<!-- NetBox-only Interfaces Modal -->
<div class="modal fade" id="netboxOnlyInterfacesModal" tabindex="-1" aria-labelledby="netboxOnlyInterfacesModalLabel"
aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="netboxOnlyInterfacesModalLabel">
NetBox-only Interfaces - {{ interface_sync.object.name }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="mdi mdi-alert me-2"></i>
<strong>Warning:</strong> The following interfaces exist in NetBox but are not found in the LibreNMS
data.
Deleting these interfaces will permanently remove them from NetBox. This action cannot be undone.
</div>
{% if interface_sync.netbox_only_interfaces %}
<form id="delete-netbox-interfaces-form">
{% csrf_token %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>
<input type="checkbox" id="select-all-netbox-interfaces"
class="form-check-input">
</th>
<th>Interface Name</th>
<th>Type</th>
<th>Status</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{% for interface in interface_sync.netbox_only_interfaces %}
<tr>
<td>
<input type="checkbox" name="interface_ids" value="{{ interface.id }}"
class="form-check-input netbox-interface-checkbox">
</td>
<td>
<a href="{{ interface.url }}" target="_blank">{{ interface.name }}</a>
</td>
<td>{{ interface.type }}</td>
<td>
{% if interface.enabled %}
<span class="text-success">Enabled</span>
{% else %}
<span class="text-danger">Disabled</span>
{% endif %}
</td>
<td>{{ interface.description|default:"-" }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</form>
{% else %}
<div class="alert alert-info">
<i class="mdi mdi-information me-2"></i>
No interfaces found that exist only in NetBox.
</div>
{% endif %}
</div>
<div class="modal-footer">
{% if interface_sync.netbox_only_interfaces %}
<button type="button" class="btn btn-danger" id="confirm-delete-interfaces">
<i class="mdi mdi-delete"></i> Delete Selected Interfaces
</button>
{% endif %}
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
</div>
</div>
</div>
</div>

View File

@@ -0,0 +1,37 @@
{% load helpers %}
{% load static %}
<!-- Action Buttons -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>IP Address Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_ipaddress_sync' pk=object.pk %}"
hx-target="#ipaddress-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh IP Addresses
</button>
{% elif model_name == "virtualmachine" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:vm_ipaddress_sync' pk=object.pk %}"
hx-target="#ipaddress-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh IP Addresses
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- End Action Buttons -->
<!-- Container for the interface sync content -->
<div id="ipaddress-sync-content">
{% include 'netbox_librenms_plugin/_ipaddress_sync_content.html' %}
</div>

View File

@@ -0,0 +1,84 @@
{% load helpers %}
{% include 'inc/messages.html' %}
<!-- IP Address Sync Table -->
{% if ip_sync.table %}
{% with model_name=ip_sync.object|meta:"model_name" %}
<form method="post" action="{% url 'plugins:netbox_librenms_plugin:sync_device_ip_addresses' object_type=model_name pk=ip_sync.object.pk %}">
{% endwith %}
{% csrf_token %}
{% if ip_sync.server_key %}<input type="hidden" name="server_key" value="{{ ip_sync.server_key }}">{% endif %}
<input type="hidden" id="selected_ip" name="select" value="">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected IP Addresses</span>
</button>
</div>
{% if ip_sync.cache_expiry %}
<div id="ip-cache-countdown" class="me-3">
Cache expires in: <span id="ip-countdown-timer" data-expiry="{{ ip_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="d-flex justify-content-end align-items-center mb-3">
<button class="btn btn-sm btn-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#ipFilterSection" aria-expanded="false" aria-controls="ipFilterSection">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse mb-3" id="ipFilterSection">
<div class="mb-2">
<small class="text-muted">
<i class="mdi mdi-information"></i> Filters apply to currently displayed IP addresses.
</small>
</div>
<div class="filter-container d-flex gap-2">
<input type="text" id="filter-address" placeholder="Filter by Address" class="form-control">
<input type="text" id="filter-prefix" placeholder="Filter by Prefix" class="form-control">
<input type="text" id="filter-device" placeholder="Filter by Device" class="form-control">
<input type="text" id="filter-interface" placeholder="Filter by Interface" class="form-control">
</div>
</div>
<style>
.ts-wrapper.multi .ts-control {
display: flex !important;
flex-wrap: wrap;
align-items: center;
}
/* VRF dropdown styles */
td[data-col="vrf"] {
width: 250px;
min-width: 180px;
}
td[data-col="vrf"] .ts-wrapper {
width: 100%;
max-width: 100%;
}
/* Ensure select elements in VRF column maintain consistent width */
td[data-col="vrf"] select.form-select {
width: 100%;
min-width: 160px;
}
</style>
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=ip_sync.table %}
{% include 'inc/table.html' with table=ip_sync.table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=ip_sync.table %}
</div>
</div>
</div>
</form>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No IP address data loaded. Click <strong>Refresh IP Addresses</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% endif %}

View File

@@ -0,0 +1,30 @@
{% load helpers %}
{% load static %}
<!-- VLAN Sync Header -->
<div class="d-flex justify-content-between align-items-center mb-3">
<h2>VLAN Sync</h2>
<div class="btn-list">
<form method="post">
{% csrf_token %}
{% if has_librenms_id %}
{% with model_name=object|meta:"model_name" %}
{% if model_name == "device" %}
<button hx-post="{% url 'plugins:netbox_librenms_plugin:device_vlan_sync' pk=object.pk %}"
hx-target="#vlan-sync-content"
hx-include="[name='interface_name_field']"
class="btn btn-outline-primary">
Refresh VLANs
</button>
{% endif %}
{% endwith %}
{% endif %}
</form>
</div>
</div>
<!-- End VLAN Sync Header -->
<!-- Container for the VLAN sync content -->
<div id="vlan-sync-content">
{% include 'netbox_librenms_plugin/_vlan_sync_content.html' %}
</div>

View File

@@ -0,0 +1,63 @@
{% load helpers %}
{% include 'inc/messages.html' %}
{% if vlan_sync.error_message %}
<!-- Error Message -->
<div class="alert alert-danger">
<i class="mdi mdi-alert-circle"></i> {{ vlan_sync.error_message }}
</div>
{% elif not vlan_sync.vlan_table or not vlan_sync.vlan_table.rows %}
<!-- No VLAN Data -->
<div class="card">
<div class="card-body text-center text-muted py-4">
<i class="mdi mdi-sync-off mdi-48px"></i>
<p class="mt-2 mb-0">No VLAN data loaded. Click <strong>Refresh VLANs</strong> to fetch data from LibreNMS.</p>
</div>
</div>
{% else %}
{% with model_name=vlan_sync.object|meta:"model_name" %}
<form method="post"
action="{% url 'plugins:netbox_librenms_plugin:sync_selected_vlans' object_type=model_name object_id=vlan_sync.object.pk %}">
{% endwith %}
{% csrf_token %}
{% if vlan_sync.server_key %}<input type="hidden" name="server_key" value="{{ vlan_sync.server_key }}">{% endif %}
<input type="hidden" name="action" value="create_vlans">
<div class="noprint d-flex justify-content-between align-items-center mt-3 mb-3">
<div>
<button type="submit" class="btn btn-primary">
<span class="spinner spinner-border d-none" id="sync-spinner"></span>
<span>Sync Selected VLANs</span>
</button>
<small class="text-muted ms-2">
<i class="mdi mdi-information-outline"></i> Select a VLAN Group for each row, or leave empty for global VLANs
</small>
</div>
<div class="ms-auto d-flex align-items-center">
{% if vlan_sync.cache_expiry %}
<div id="vlan-cache-countdown" class="me-3">
Cache expires in: <span id="vlan-countdown-timer"
data-expiry="{{ vlan_sync.cache_expiry|date:'c' }}"></span>
</div>
{% endif %}
<div class="color-key me-3">
<span class="badge text-success text-white">Matching values</span>
<span class="badge text-warning text-white">Mismatched values</span>
<span class="badge text-danger text-white">Not present in NetBox</span>
</div>
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %}
{% include 'inc/table.html' with table=vlan_sync.vlan_table %}
{% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %}
</div>
</div>
</div>
</form>
{% endif %}

View File

@@ -0,0 +1,212 @@
{# Confirmation modal content for bulk imports #}
<div class="modal-header">
<h5 class="modal-title">
<i class="mdi mdi-download"></i> Confirm Import
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-3">Review the devices that will be created in NetBox.</p>
{% if errors %}
<div class="alert alert-warning" role="alert">
<i class="mdi mdi-alert"></i>
{{ errors|join:', ' }}
</div>
{% endif %}
{% for entry in devices %}
{% with vc=entry.validation.virtual_chassis %}
<div class="border rounded-3 p-2 mb-2">
{% if vc.is_stack and vc.members %}
<button class="d-flex flex-wrap align-items-center gap-2 w-100 text-start border-0 bg-transparent p-0 collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#bulk-vc-{{ entry.device_id }}"
aria-expanded="false">
{% else %}
<div class="d-flex flex-wrap align-items-center gap-2">
{% endif %}
<i class="mdi mdi-{{ entry.is_vm|yesno:'cloud,server' }} fs-4 text-primary"></i>
<div>
<div class="fw-semibold">{{ entry.device_name }}</div>
<div class="small text-muted">{{ entry.is_vm|yesno:'Virtual Machine,Device' }}</div>
</div>
{% if entry.role %}
<span class="badge text-white ms-1" style="background-color: #{{ entry.role.color|default:'6c757d' }};">{{ entry.role.name }}</span>
{% endif %}
{% if vc.is_stack %}
<span class="badge bg-info text-white text-uppercase fw-semibold small">
<i class="mdi mdi-switch"></i>
VC{% if vc.member_count %} · {{ vc.member_count }}{% endif %}
</span>
{% if vc.members %}
<i class="mdi mdi-chevron-down ms-auto"></i>
{% endif %}
{% endif %}
{% if vc.is_stack and vc.members %}
</button>
{% else %}
</div>
{% endif %}
<div class="text-muted small mt-1 d-flex flex-wrap gap-3">
{% if entry.cluster %}<span><strong>Cluster:</strong> {{ entry.cluster.name }}</span>{% endif %}
{% if entry.rack %}
<span>
<strong>Rack:</strong>
{% if entry.rack.location %}{{ entry.rack.location.name }} {% endif %}
{{ entry.rack.name }}
</span>
{% endif %}
</div>
{% if entry.validation.issues %}
<div class="alert alert-warning py-2 px-3 mt-3 mb-0 small">
<i class="mdi mdi-alert"></i>
{{ entry.validation.issues|join:'; ' }}
</div>
{% endif %}
{% if vc.is_stack and vc.members %}
<div class="collapse mt-2" id="bulk-vc-{{ entry.device_id }}">
<div class="border rounded">
<div class="px-3 pb-2">
<ul class="list-unstyled mb-0 small">
{% for member in vc.members %}
<li class="d-flex flex-wrap align-items-center py-2 {% if not forloop.last %}border-bottom{% endif %}">
<span class="text-muted me-2">
{% if member.position %}
Pos {{ member.position }}
{% else %}
Pos —
{% endif %}
</span>
<div class="flex-grow-1 d-flex align-items-center gap-2">
<code class="flex-grow-1 mb-0">{{ member.suggested_name }}
{% if member.is_master %}
<span class="badge bg-success text-white">Master</span>
{% endif %}
</code>
</div>
{% if member.serial %}
<span class="text-muted">SN {{ member.serial }}</span>
{% endif %}
</li>
{% endfor %}
</ul>
</div>
</div>
</div>
{% elif vc.is_stack and vc.detection_error %}
<div class="alert alert-warning py-2 px-3 mt-3 mb-0 small">
<i class="mdi mdi-alert"></i>
Unable to display virtual chassis members: {{ vc.detection_error }}
</div>
{% endif %}
</div>
{% endwith %}
{% endfor %}
</div>
<div class="modal-body pt-0">
<div class="p-3 bg-primary-subtle border border-primary rounded-1">
<div class="form-check">
<input type="checkbox" name="use_background_job" id="use-background-job-checkbox" class="form-check-input" checked>
<label class="form-check-label" for="use-background-job-checkbox">
Run as background job
</label>
<small class="form-text text-muted d-block mt-1">
Recommended: Jobs are logged and can be cancelled.
</small>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="mdi mdi-close"></i> Cancel
</button>
<form class="d-inline-flex align-items-center gap-2"
id="bulk-import-confirm-form"
hx-post="{% url 'plugins:netbox_librenms_plugin:bulk_import_devices' %}"
hx-target="body"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="server_key" value="{{ server_key }}">
<input type="hidden" name="vc_detection_enabled" value="{{ vc_detection_enabled|yesno:'on,off' }}">
{% for entry in devices %}
<input type="hidden" name="select" value="{{ entry.device_id }}">
{% if entry.role %}<input type="hidden" name="role_{{ entry.device_id }}" value="{{ entry.role.pk }}">{% endif %}
{% if entry.cluster %}<input type="hidden" name="cluster_{{ entry.device_id }}" value="{{ entry.cluster.pk }}">{% endif %}
{% if entry.rack %}<input type="hidden" name="rack_{{ entry.device_id }}" value="{{ entry.rack.pk }}">{% endif %}
{% endfor %}
<input type="hidden" name="use_sysname" value="{{ use_sysname|yesno:'true,false' }}">
<input type="hidden" name="strip_domain" value="{{ strip_domain|yesno:'true,false' }}">
<input type="hidden" name="enable_vc_detection" value="{{ vc_detection_enabled|yesno:'true,false' }}">
<input type="hidden" name="use_background_job" id="use-background-job-hidden" value="on">
<button type="submit" class="btn btn-success d-inline-flex align-items-center gap-1" data-bulk-confirm-submit>
<span data-state="idle" class="d-inline-flex align-items-center gap-1">
<i class="mdi mdi-download"></i>
Import {{ device_count }} device{{ device_count|pluralize }}
</span>
<span data-state="loading" class="d-none align-items-center gap-1">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Importing...
</span>
</button>
</form>
</div>
<script>
(function() {
if (window.__bulkConfirmHandlersAttached) {
return;
}
window.__bulkConfirmHandlersAttached = true;
const formId = 'bulk-import-confirm-form';
const checkboxId = 'use-background-job-checkbox';
const hiddenId = 'use-background-job-hidden';
function toggleButtonState(isLoading) {
const form = document.getElementById(formId);
if (!form) {
return;
}
const button = form.querySelector('[data-bulk-confirm-submit]');
if (!button) {
return;
}
const idle = button.querySelector('[data-state="idle"]');
const loading = button.querySelector('[data-state="loading"]');
button.disabled = isLoading;
if (idle) {
idle.classList.toggle('d-none', isLoading);
}
if (loading) {
loading.classList.toggle('d-none', !isLoading);
}
}
// Delegated so it survives HTMX re-renders of the modal fragment.
document.addEventListener('change', function(event) {
if (event.target && event.target.id === checkboxId) {
const hiddenInput = document.getElementById(hiddenId);
if (hiddenInput) {
hiddenInput.value = event.target.checked ? 'on' : 'off';
}
}
});
document.addEventListener('htmx:beforeRequest', function(event) {
if (event.target && event.target.id === formId) {
toggleButtonState(true);
}
});
document.addEventListener('htmx:afterRequest', function(event) {
if (event.target && event.target.id === formId) {
toggleButtonState(false);
}
});
})();
</script>

View File

@@ -0,0 +1,22 @@
{% load render_table from django_tables2 %}
{% if table.rows %}
{% for row in table.rows %}
<tr {{ row.attrs.as_html }} hx-swap-oob="true">
{% for column, cell in row.items %}
<td {{ column.attrs.td.as_html }}>{{ cell }}</td>
{% endfor %}
</tr>
{% endfor %}
{% else %}
<tr id="device-row-{{ record.device_id }}">
<td colspan="13">
<div class="alert alert-danger">
ERROR: No table rows found for device {{ record.device_id }}
</div>
</td>
</tr>
{% endif %}
{# Consume and clear any pending Django messages to prevent reappearing toasts #}
<div id="django-messages" class="toast-container position-fixed bottom-0 end-0 p-3" hx-swap-oob="true">
{% for _ in messages %}{% endfor %}
</div>

View File

@@ -0,0 +1,625 @@
{# HTMX template for device validation details modal #}
{# Redesigned to match the sync page's clean table layout #}
<div class="modal-header">
<h5 class="modal-title">
{% if validation.existing_device %}
{% if validation.device_type_mismatch %}
<i class="mdi mdi-alert-circle text-danger"></i>
{% elif validation.existing_match_type == 'librenms_id' %}
<i class="mdi mdi-check-decagram text-info"></i>
{% else %}
<i class="mdi mdi-alert text-warning"></i>
{% endif %}
{% elif validation.is_ready %}
<i class="mdi mdi-check-circle text-success"></i>
{% elif validation.can_import %}
<i class="mdi mdi-alert text-warning"></i>
{% else %}
<i class="mdi mdi-close-circle text-danger"></i>
{% endif %}
Import Validation: {{ validation.resolved_name|default:libre_device.sysName|default:libre_device.hostname }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Compute existing device URL once for use throughout #}
{% if validation.existing_device %}
{% if existing_device_model_name == "virtualmachine" %}
{% url 'virtualization:virtualmachine' pk=validation.existing_device.pk as existing_device_url %}
{% else %}
{% url 'dcim:device' pk=validation.existing_device.pk as existing_device_url %}
{% endif %}
{% endif %}
{# Row: LibreNMS Status (left) + Device Info table (right) #}
<div class="row mb-3">
{# Left: LibreNMS Status card #}
<div class="col-md-4">
<div class="card h-100">
<h6 class="card-header">LibreNMS Status</h6>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<tbody>
<tr>
<th style="padding-left: 0.75rem;">Status</th>
<td>
{% if libre_device.status == 1 or libre_device.status == "1" %}
<span class="badge bg-success text-white"><i class="mdi mdi-check"></i> Up</span>
{% elif libre_device.status == 0 or libre_device.status == "0" %}
<span class="badge bg-danger text-white"><i class="mdi mdi-close"></i> Down</span>
{% else %}
<span class="badge bg-secondary text-white"><i class="mdi mdi-help"></i> Unknown</span>
{% endif %}
</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">Hostname</th>
<td>{{ libre_device.hostname }}</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">ID</th>
<td>{{ libre_device.device_id }}</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">IP</th>
<td>{{ libre_device.ip|default:"—" }}</td>
</tr>
<tr>
<th style="padding-left: 0.75rem;">Location</th>
<td>{{ libre_device.location|default:"—" }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{# Right: Device Information table #}
<div class="col-md-8">
<div class="card h-100">
<h6 class="card-header d-flex justify-content-between align-items-center">
Device Information
<span class="d-flex gap-1">
{% if use_sysname %}
<span class="badge bg-success-lt"><i class="mdi mdi-check-circle-outline"></i> sysName</span>
{% else %}
<span class="badge bg-success-lt"><i class="mdi mdi-check-circle-outline"></i> hostname</span>
{% endif %}
{% if strip_domain %}
<span class="badge bg-success-lt"><i class="mdi mdi-check-circle-outline"></i> Domain stripped</span>
{% endif %}
</span>
</h6>
<div class="card-body p-0">
<table class="table table-sm table-hover mb-0">
<thead>
<tr>
<th style="width: 22%; padding-left: 0.75rem;">Field</th>
<th style="width: 39%;">NetBox Value</th>
<th style="width: 39%;">LibreNMS Value</th>
</tr>
</thead>
<tbody>
{# Name row #}
<tr>
<td style="padding-left: 0.75rem;">Name</td>
<td>
{% if validation.existing_device %}
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">
{{ validation.existing_device.name }}
</a>
{% if validation.name_sync_available %}
<form style="display:inline" class="ms-2"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_name">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync name to {{ validation.suggested_name }}" aria-label="Sync name to {{ validation.suggested_name }}">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% endif %}
{% else %}
<span class="text-muted">New device</span>
{% endif %}
</td>
<td>{{ validation.resolved_name|default:libre_device.sysName|default:libre_device.hostname }}</td>
</tr>
{# Show only for non-VM contexts: true when no existing device (new import, not VM) OR when existing device is not a VM. #}
{% if not validation.existing_device and not validation.import_as_vm or validation.existing_device and existing_device_model_name != "virtualmachine" %}
<tr>
<td style="padding-left: 0.75rem;">Site</td>
<td>
{% if validation.existing_device and validation.existing_device.site %}
{{ validation.existing_device.site }}
{% if validation.site.site and validation.existing_device.site.pk == validation.site.site.pk %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% elif validation.site.site %}
{{ validation.site.site.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No matching site</span>
{% endif %}
</td>
<td>{{ libre_device.location|default:"—" }}</td>
</tr>
{# Device Type row #}
<tr>
<td style="padding-left: 0.75rem;">Device Type</td>
<td>
{% if validation.device_type_mismatch %}
<span class="text-danger">
{{ validation.existing_device.device_type }}
<i class="mdi mdi-alert-circle"></i>
</span>
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="update_type">
<input type="hidden" name="force" value="on">
<button type="submit" class="btn btn-sm btn-outline-danger py-0 px-1" title="Update to LibreNMS type" aria-label="Update to LibreNMS type">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% elif validation.existing_device and validation.existing_device.device_type %}
{{ validation.existing_device.device_type }}
{% if sync_info and not sync_info.device_type_synced and sync_info.librenms_device_type %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_device_type">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync device type" aria-label="Sync device type">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% elif validation.device_type.device_type %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% elif validation.device_type.device_type %}
<span {% if validation.device_type.match_type == 'chassis' %}title="Matched via chassis inventory ({{ validation.device_type.chassis_model }})"{% elif validation.device_type.match_type == 'mapping' %}title="Matched via device type mapping"{% endif %}>
{{ validation.device_type.device_type }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
</span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No matching type</span>
{% endif %}
</td>
<td>{{ libre_device.hardware|default:"—" }}</td>
</tr>
{% endif %}
{# Serial row (only for devices) #}
{# Show only for non-VM contexts: true when no existing device (new import, not VM) OR when existing device is not a VM. #}
{% if not validation.existing_device and not validation.import_as_vm or validation.existing_device and existing_device_model_name != "virtualmachine" %}
<tr>
<td style="padding-left: 0.75rem;">Serial</td>
<td>
{% if validation.existing_device %}
{% if validation.existing_device.serial %}
{{ validation.existing_device.serial }}
{% else %}
<span class="text-muted">Not set</span>
{% endif %}
{% if sync_info and not sync_info.serial_synced %}
{% if validation.serial_action == 'conflict' %}
<span class="text-danger ms-1" title="Serial conflict — another device already has this serial">
<i class="mdi mdi-alert-circle"></i>
</span>
{% else %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_serial">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync serial from LibreNMS" aria-label="Sync serial from LibreNMS">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% endif %}
{% elif sync_info and sync_info.serial_synced and sync_info.librenms_serial != '-' %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% else %}
<span class="text-muted"></span>
{% endif %}
</td>
<td>
{{ libre_device.serial|default:"—" }}
{% if validation.serial_duplicate %}
<span class="badge bg-danger ms-1">Conflict</span>
{% endif %}
</td>
</tr>
{% endif %}
{# Role row #}
<tr>
<td style="padding-left: 0.75rem;">
{% if validation.import_as_vm %}Role{% else %}Device Role{% endif %}
</td>
<td>
{% if validation.existing_device and validation.existing_device.role %}
{{ validation.existing_device.role }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% elif validation.device_role.role %}
{{ validation.device_role.role.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No role assigned</span>
{% endif %}
</td>
<td><span class="text-muted"></span></td>
</tr>
{# Platform row #}
<tr>
<td style="padding-left: 0.75rem;">Platform</td>
<td>
{% if validation.existing_device and validation.existing_device.platform %}
{{ validation.existing_device.platform }}
{% if sync_info and not sync_info.platform_synced %}
{% if sync_info.platform_info.platform_exists %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_platform">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync platform" aria-label="Sync platform">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% else %}
<span class="text-muted ms-1" title="Platform '{{ sync_info.platform_info.librenms_os }}' not in NetBox">
<i class="mdi mdi-alert-outline"></i>
</span>
{% endif %}
{% elif sync_info and sync_info.platform_synced %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% endif %}
{% elif validation.platform.platform %}
{{ validation.platform.platform.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% elif sync_info and sync_info.platform_info.platform_exists %}
<span class="text-muted">Not set</span>
{% if validation.existing_device %}
<form style="display:inline" class="ms-1"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="action" value="sync_platform">
<button type="submit" class="btn btn-sm btn-outline-primary py-0 px-1" title="Sync platform" aria-label="Sync platform">
<i class="mdi mdi-sync"></i>
</button>
</form>
{% endif %}
{% else %}
<span class="text-muted">Optional</span>
{% endif %}
</td>
<td>{{ libre_device.os|default:"—" }}</td>
</tr>
{% if validation.import_as_vm and not validation.existing_device or validation.existing_device and existing_device_model_name == "virtualmachine" %}
{# Cluster row (VMs only) #}
<tr>
<td style="padding-left: 0.75rem;">Cluster</td>
<td>
{% if validation.cluster.cluster %}
{{ validation.cluster.cluster.name }}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-danger"><i class="mdi mdi-close-circle"></i> No cluster assigned</span>
{% endif %}
</td>
<td><span class="text-muted"></span></td>
</tr>
{% else %}
{# Rack row #}
<tr>
<td style="padding-left: 0.75rem;">Rack</td>
<td>
{% if validation.rack.rack %}
{% if validation.rack.rack.location %}
{{ validation.rack.rack.location.name }} — {{ validation.rack.rack.name }}
{% else %}
{{ validation.rack.rack.name }}
{% endif %}
<span class="text-success"><i class="mdi mdi-check-circle"></i></span>
{% else %}
<span class="text-muted">Optional</span>
{% endif %}
</td>
<td><span class="text-muted"></span></td>
</tr>
{% endif %}
{# Primary IP row #}
<tr>
<td style="padding-left: 0.75rem;">Primary IP</td>
<td>
{% if validation.existing_device and validation.existing_device.primary_ip %}
{{ validation.existing_device.primary_ip }}
{% elif libre_device.ip %}
{{ libre_device.ip }}
{% else %}
<span class="text-muted">No primary IP</span>
{% endif %}
</td>
<td>{{ libre_device.ip|default:"—" }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
{# Status & Actions #}
{% if validation.existing_device %}
{% if validation.existing_match_type == 'librenms_id' %}
<div class="alert alert-info py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
{% if existing_id_servers %}
{% for srv in existing_id_servers %}
<span class="badge bg-info-lt me-1"><i class="mdi mdi-check-decagram"></i> Linked — ID {{ srv.device_id }} @ {{ srv.display_name }}</span>
{% endfor %}
{% else %}
<span class="badge bg-info-lt me-1"><i class="mdi mdi-check-decagram"></i> Linked — ID {{ libre_device.device_id }}</span>
{% endif %}
{% if validation.name_matches %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Name match</span>
{% elif validation.name_sync_available %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Name differs</span>
{% endif %}
{% if validation.serial_confirmed %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Serial confirmed</span>
{% elif validation.serial_action == 'conflict' %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Serial conflict</span>
{% elif validation.serial_action == 'update_serial' %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Serial differs</span>
{% endif %}
{% if validation.device_type_mismatch %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Type mismatch</span>
{% endif %}
{% if validation.librenms_id_needs_migration %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-database-alert"></i> Legacy ID format</span>
{% endif %}
</div>
{% if validation.librenms_id_needs_migration %}
<div class="mb-3">
<form style="display:inline"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
<input type="hidden" name="server_key" value="{{ server_key }}">
<input type="hidden" name="action" value="migrate_librenms_id">
{% if validation.existing_device.cluster %}
<input type="hidden" name="cluster_{{ libre_device.device_id }}" value="{{ validation.existing_device.cluster.pk }}">
{% endif %}
{% if not validation.serial_confirmed %}
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="force" id="force-migrate-{{ libre_device.device_id }}"
onchange="this.closest('form').querySelector('.migrate-btn').disabled = !this.checked">
<label class="form-check-label text-warning" for="force-migrate-{{ libre_device.device_id }}">
<i class="mdi mdi-alert"></i> Serial not confirmed — check to migrate anyway
</label>
</div>
{% endif %}
<button type="submit" class="btn btn-sm btn-warning migrate-btn"{% if not validation.serial_confirmed %} disabled{% endif %}>
<i class="mdi mdi-database-sync"></i> Migrate ID format
</button>
</form>
</div>
{% endif %}
{% elif validation.existing_match_type == 'hostname' %}
<div class="alert alert-warning py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-link-variant"></i> Hostname match</span>
{% if validation.serial_confirmed %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Serial confirmed</span>
{% elif validation.serial_action == 'conflict' %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Serial conflict</span>
{% elif validation.serial_action == 'update_serial' %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Serial differs</span>
{% endif %}
{% if validation.device_type_mismatch %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Type mismatch</span>
{% endif %}
<span class="text-muted ms-1">
— Exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>,
not linked to LibreNMS.
</span>
</div>
{% if validation.serial_action == 'conflict' %}
<div class="alert alert-danger py-2 mb-3">
<i class="mdi mdi-alert-circle"></i>
Import blocked: The incoming serial number is already assigned to another device in NetBox.
Resolve the duplicate serial before linking.
</div>
{% elif validation.import_as_vm or existing_device_model_name == "virtualmachine" %}
<div class="alert alert-info py-2 mb-3">
<i class="mdi mdi-information-outline"></i>
Hostname match found for a VM — use the import action to proceed.
</div>
{% else %}
<div class="mb-3">
<form style="display:inline"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
{% if validation.device_type_mismatch %}
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="force" id="force-hostname-{{ libre_device.device_id }}"
onchange="this.closest('form').querySelector('.force-action-btn').disabled = !this.checked">
<label class="form-check-label text-danger" for="force-hostname-{{ libre_device.device_id }}">
<i class="mdi mdi-alert-circle"></i> Device type mismatch — check to force
</label>
</div>
{% endif %}
{% if validation.serial_action == 'update_serial' %}
<input type="hidden" name="action" value="update_serial">
<button type="submit" class="btn btn-sm btn-warning{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-swap-horizontal"></i> Update Serial &amp; Link
</button>
{% else %}
<input type="hidden" name="action" value="link">
<button type="submit" class="btn btn-sm btn-primary{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-link-plus"></i> Link to LibreNMS
</button>
{% endif %}
</form>
</div>
{% endif %}
{% elif validation.existing_match_type == 'serial' %}
<div class="alert alert-warning py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-barcode"></i> Serial match</span>
{% if validation.serial_action == 'hostname_differs' %}
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-alert"></i> Name differs</span>
{% elif validation.serial_action == 'link' %}
<span class="badge bg-success-lt me-1"><i class="mdi mdi-check-circle-outline"></i> Name match</span>
{% endif %}
{% if validation.device_type_mismatch %}
<span class="badge bg-danger-lt me-1"><i class="mdi mdi-alert-circle"></i> Type mismatch</span>
{% endif %}
<span class="text-muted ms-1">
— Exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>,
not linked to LibreNMS.
</span>
</div>
<div class="mb-3">
<form style="display:inline"
hx-post="{% url 'plugins:netbox_librenms_plugin:device_conflict_action' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="#use-sysname-toggle, #strip-domain-toggle">
{% csrf_token %}
<input type="hidden" name="existing_device_id" value="{{ validation.existing_device.pk }}">
<input type="hidden" name="existing_device_type" value="{{ existing_device_model_name|default:'device' }}">
{% if validation.device_type_mismatch %}
<div class="form-check mb-2">
<input class="form-check-input" type="checkbox" name="force" id="force-serial-{{ libre_device.device_id }}"
onchange="this.closest('form').querySelector('.force-action-btn').disabled = !this.checked">
<label class="form-check-label text-danger" for="force-serial-{{ libre_device.device_id }}">
<i class="mdi mdi-alert-circle"></i> Device type mismatch — check to force
</label>
</div>
{% endif %}
{% if validation.serial_action == 'link' %}
<input type="hidden" name="action" value="link">
<button type="submit" class="btn btn-sm btn-primary{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-link-plus"></i> Link to LibreNMS
</button>
{% elif validation.serial_action == 'hostname_differs' %}
<button type="submit" name="action" value="update" class="btn btn-sm btn-warning{% if validation.device_type_mismatch %} force-action-btn{% endif %}"{% if validation.device_type_mismatch %} disabled{% endif %}>
<i class="mdi mdi-pencil"></i> Update &amp; Link
</button>
{% endif %}
</form>
</div>
{% elif validation.existing_match_type == 'primary_ip' %}
<div class="alert alert-warning py-2 mb-3 d-flex flex-wrap align-items-center gap-1">
<span class="badge bg-warning-lt me-1"><i class="mdi mdi-ip-network"></i> IP match</span>
<span class="text-muted ms-1">
— Device with IP {{ libre_device.ip }} exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>.
Consider adding LibreNMS ID manually.
</span>
</div>
{% else %}
<div class="alert alert-info py-2 mb-3">
<i class="mdi mdi-link"></i>
<strong>Exists</strong> — Device already exists as
<a href="{{ existing_device_url }}" target="_blank" rel="noopener noreferrer">{{ validation.existing_device.name }}</a>.
</div>
{% endif %}
{% elif validation.is_ready %}
<div class="alert alert-success py-2 mb-3">
<i class="mdi mdi-check-circle"></i>
<strong>Ready to Import</strong> — All prerequisites are met.
</div>
{% elif validation.can_import %}
<div class="alert alert-warning py-2 mb-3">
<i class="mdi mdi-alert"></i>
<strong>Can Import</strong> — Review warnings below before importing.
</div>
{% else %}
<div class="alert alert-danger py-2 mb-3">
<i class="mdi mdi-close-circle"></i>
<strong>Cannot Import</strong> — Validation issues prevent import.
</div>
{% endif %}
{# Warnings #}
{% if validation.warnings %}
<div class="card mb-3">
<div class="card-header py-2">
<i class="mdi mdi-alert"></i> Warnings ({{ validation.warnings|length }})
</div>
<div class="card-body py-2">
<ul class="mb-0 ps-3">
{% for warning in validation.warnings %}
<li><small>{{ warning }}</small></li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
</div>
<div class="modal-footer">
{% if validation.existing_device %}
{% if existing_device_model_name == "virtualmachine" %}
<a href="{{ existing_device_url }}"
class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer">
<i class="mdi mdi-open-in-new"></i> View VM in NetBox
</a>
{% else %}
<a href="{{ existing_device_url }}"
class="btn btn-primary btn-sm" target="_blank" rel="noopener noreferrer">
<i class="mdi mdi-open-in-new"></i> View in NetBox
</a>
{% if validation.existing_match_type == 'librenms_id' %}
<a href="{% url 'plugins:netbox_librenms_plugin:device_librenms_sync' pk=validation.existing_device.pk %}"
class="btn btn-outline-primary btn-sm" target="_blank" rel="noopener noreferrer">
<i class="mdi mdi-sync"></i> Full Sync Page
</a>
{% endif %}
{% endif %}
{% endif %}
<button type="button" class="btn btn-secondary btn-sm" data-bs-dismiss="modal">
<i class="mdi mdi-close"></i> Close
</button>
</div>

View File

@@ -0,0 +1,121 @@
{# HTMX template for virtual chassis details modal #}
{# Shows virtual chassis/stack information for a LibreNMS device #}
<div class="modal-header">
<h5 class="modal-title">
<i class="mdi mdi-server-network"></i> Virtual Chassis: {{ libre_device.hostname }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{# Device Summary #}
<div class="card mb-3">
<div class="card-header">
<i class="mdi mdi-server"></i> LibreNMS Device Information
</div>
<div class="card-body">
<dl class="row mb-0">
<dt class="col-sm-3">Hostname:</dt>
<dd class="col-sm-9"><strong>{{ libre_device.hostname }}</strong></dd>
<dt class="col-sm-3">System Name:</dt>
<dd class="col-sm-9">{{ libre_device.sysName|default:"N/A" }}</dd>
<dt class="col-sm-3">LibreNMS ID:</dt>
<dd class="col-sm-9">{{ libre_device.device_id }}</dd>
<dt class="col-sm-3">Hardware:</dt>
<dd class="col-sm-9">{{ libre_device.hardware|default:"N/A" }}</dd>
<dt class="col-sm-3">OS:</dt>
<dd class="col-sm-9">{{ libre_device.os|default:"N/A" }}</dd>
</dl>
</div>
</div>
{# Virtual Chassis Detection Error #}
{% if vc_data.detection_error %}
<div class="alert alert-warning">
<h6 class="alert-heading">
<i class="mdi mdi-alert"></i> Virtual Chassis Detection Error
</h6>
<p class="mb-0">
Unable to detect virtual chassis information: <strong>{{ vc_data.detection_error }}</strong>
</p>
</div>
{% endif %}
{# Virtual Chassis Details #}
{% if vc_data.is_stack %}
<div class="card mb-3">
<div class="card-header">
<i class="mdi mdi-switch"></i> Virtual Chassis Stack
</div>
<div class="card-body">
<p class="mb-3">
This device is part of a <strong>{{ vc_data.member_count }}-member</strong> stack/virtual chassis.
</p>
{% if vc_data.members %}
<h6 class="mb-3">Stack Members:</h6>
<div class="border rounded">
<div class="px-3 pb-2">
<ul class="list-unstyled mb-0 small">
{% for member in vc_data.members %}
<li class="py-3 {% if not forloop.last %}border-bottom{% endif %}">
<div class="d-flex flex-wrap align-items-center gap-2">
<span class="text-muted me-2">
{% if member.position %}
Pos {{ member.position }}
{% else %}
Pos —
{% endif %}
</span>
<code class="flex-grow-1 mb-0">{{ member.suggested_name|default:member.name|default:"(unknown)" }}</code>
{% if member.is_master %}
<span class="badge bg-success text-white">Master</span>
{% endif %}
</div>
<div class="text-muted mt-2 d-flex flex-wrap gap-3">
{% if member.model %}
<span><strong>Model:</strong> {{ member.model }}</span>
{% endif %}
{% if member.serial %}
<span><strong>Serial:</strong> <code>{{ member.serial }}</code></span>
{% endif %}
{% if not member.model and not member.serial %}
<span>No additional metadata</span>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
</div>
</div>
{% else %}
<p class="text-muted mb-0">
<i class="mdi mdi-information-outline"></i> No member details available.
</p>
{% endif %}
</div>
</div>
{% elif not vc_data.detection_error %}
<div class="alert alert-info">
<h6 class="alert-heading">
<i class="mdi mdi-information-outline"></i> Not a Virtual Chassis
</h6>
<p class="mb-0">
This device does not appear to be part of a virtual chassis or stack.
</p>
</div>
{% endif %}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="mdi mdi-close"></i> Close
</button>
</div>

View File

@@ -0,0 +1,59 @@
{% load i18n %}
{% if table.page %}
{% with page_param=table.prefix|stringformat:"s"|add:"page" %}
<div class="d-flex justify-content-between align-items-center border-{% if placement == 'top' %}bottom{% else %}top{% endif %} p-2">
{% if table.paginator.num_pages > 1 %}
<nav aria-label="{% trans "Page selection" %}">
<ul class="pagination mb-0">
{% if table.page.has_previous %}
<li class="page-item">
<a href="?tab={{ table.tab }}&{{ page_param }}={{ table.page.previous_page_number }}&{{ table.prefix }}per_page={{ table.paginator.per_page }}&interface_name_field={{ interface_name_field }}" class="page-link">
<i class="mdi mdi-chevron-left"></i>
</a>
</li>
{% endif %}
{% for p in table.page.smart_pages %}
<li class="page-item{% if table.page.number == p %} active{% endif %}">
{% if p %}
<a href="?tab={{ table.tab }}&{{ page_param }}={{ p }}&{{ table.prefix }}per_page={{ table.paginator.per_page }}&interface_name_field={{ interface_name_field }}" class="page-link">{{ p }}</a>
{% else %}
<span class="page-link" disabled>&hellip;</span>
{% endif %}
</li>
{% endfor %}
{% if table.page.has_next %}
<li class="page-item">
<a href="?tab={{ table.tab }}&{{ page_param }}={{ table.page.next_page_number }}&{{ table.prefix }}per_page={{ table.paginator.per_page }}&interface_name_field={{ interface_name_field }}" class="page-link">
<i class="mdi mdi-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
<small class="text-end text-muted">
{% blocktrans trimmed with start=table.page.start_index end=table.page.end_index total=table.paginator.count %}
Showing {{ start }}-{{ end }} of {{ total }}
{% endblocktrans %}
</small>
<nav class="text-end" aria-label="{% trans "Pagination options" %}">
<div class="dropdown">
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
{% trans "Per Page" %}
</button>
<div class="dropdown-menu">
{% for n in table.paginator.get_page_lengths %}
<a href="?tab={{ table.tab }}&{{ table.prefix }}per_page={{ n }}&{{ page_param }}={{ table.page.number }}&interface_name_field={{ interface_name_field }}" class="dropdown-item">{{ n }}</a>
{% endfor %}
</div>
</div>
</nav>
</div>
{% endwith %}
{% endif %}

View File

@@ -0,0 +1,30 @@
{% extends 'generic/object.html' %}
{% load helpers %}
{% load plugins %}
{% block content %}
<div class="row mb-3">
<div class="col col-md-12">
<div class="card">
<table class="table table-hover attr-table">
<thead>
<tr>
<th>LibreNMS Type</th>
<th>LibreNMS Speed (Kbps)</th>
<th>NetBox Type</th>
<th>Description</th>
</tr>
</thead>
<tbody>
<tr>
<td>{{ object.librenms_type }}</td>
<td>{{ object.librenms_speed }}</td>
<td>{{ object.get_netbox_type_display }}</td>
<td>{{ object.description|default:"—" }}</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,12 @@
{% extends 'generic/object_list.html' %}
{% block content %}
<div class="alert alert-info">
<h4>Interface Type Mapping</h4>
<p>This section allows you to map LibreNMS interface types to NetBox interface types.
When synchronizing interfaces from LibreNMS, these mappings will be used to ensure
correct interface type assignment in NetBox.</p>
<p>Example: Map LibreNMS type "ethernetCsmacd" to NetBox type "1000base-t"</p>
</div>
{{ block.super }}
{% endblock %}

View File

@@ -0,0 +1,438 @@
{% extends 'generic/_base.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% load render_table from django_tables2 %}
{% load static %}
{% load i18n %}
{% block title %}{{ title }}{% endblock %}
{% block javascript %}
{{ block.super }}
<script src="{% static 'netbox_librenms_plugin/js/librenms_import.js' %}"></script>
{% endblock %}
{% block controls %}
<div class="btn-list">
{% plugin_list_buttons model %}
{% action_buttons actions model %}
</div>
{% endblock controls %}
{% block content %}
{# Display Django messages #}
{% include 'inc/messages.html' %}
{# LibreNMS Server Information #}
{% if librenms_server_info %}
<div class="card mb-3">
<div class="card-body p-2">
<div class="d-flex justify-content-between align-items-center">
<div>
<i class="mdi mdi-server-network text-primary"></i>
<strong>Active LibreNMS Server:</strong>
{% if librenms_server_info.is_legacy %}
{{ librenms_server_info.url }}
{% else %}
{{ librenms_server_info.display_name }} ({{ librenms_server_info.url }})
{% endif %}
</div>
{% if not librenms_server_info.is_legacy %}
<a href="{% url 'plugins:netbox_librenms_plugin:settings' %}" class="btn btn-sm btn-outline-primary ms-3">
<i class="mdi mdi-cog"></i> Change Server
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
{# Collapsible Filter Section #}
{% if filter_form %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="card-title mb-0">
Search Filters
{% if filter_form.changed_data %}
{% badge filter_form.changed_data|length bg_color="primary" %}
{% endif %}
</h5>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#filter-collapse" aria-expanded="{% if filter_form.changed_data %}false{% else %}true{% endif %}" aria-controls="filter-collapse">
<i class="mdi mdi-filter"></i> Toggle Filters
</button>
</div>
<div class="collapse {% if not filter_form.changed_data %}show{% endif %}" id="filter-collapse">
<div class="card-body">
<div class="row">
{# Instructions column #}
<div class="col-md-4">
<h6 class="mb-3"><i class="mdi mdi-information-outline"></i> Search Instructions</h6>
<p class="mb-3">
<strong>At least one filter is required</strong> to search for devices.
</p>
<p class="mb-3">
The following matching rules apply:
<ul class="mb-3" style="padding-left: 1.2rem; font-size: 0.9rem;">
<li><strong>Location/Type/OS:</strong> Exact match</li>
<li><strong>Hostname:</strong> Partial match</li>
<li><strong>System Name:</strong> Exact match (or partial when combined with other filters)</li>
</ul>
<div class="alert alert-info small mt-2 mb-0" role="status">
<ul class="mb-0" style="padding-left: 1.2rem;">
<strong>Performance note:</strong> <li>Results are cached (default: 5 minutes).</li>
<li>Large LibreNMS datasets take time to process</li>
<li>Repeating the same filter search will use cached data if available.</li>
<li>Using background jobs is default and recommended.</li>
<li>Background jobs can be cancelled if needed.</li>
</ul>
</div>
<p class="mt-3 text-muted small">
<i class="mdi mdi-lightbulb-on-outline"></i> <strong>Tip:</strong> Start with Location and/or Type to narrow results, then refine with additional filters.
</p>
</div>
{# Filters column #}
<div class="col-md-8">
<form method="get" class="form" id="librenms-import-filter-form">
<input type="hidden" name="apply_filters" value="1">
{% if show_filter_warning %}
<div class="alert alert-warning alert-dismissible fade show border-warning" role="alert" style="border-left: 4px solid #ffc107;">
<i class="mdi mdi-alert text-warning"></i>
<strong>Filter Required:</strong> {{ filter_warning }}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
{% endif %}
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ filter_form.librenms_location.id_for_label }}" class="form-label">{{ filter_form.librenms_location.label }}</label>
{{ filter_form.librenms_location }}
</div>
<div class="col-md-6">
<label for="{{ filter_form.librenms_type.id_for_label }}" class="form-label">{{ filter_form.librenms_type.label }}</label>
{{ filter_form.librenms_type }}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ filter_form.librenms_os.id_for_label }}" class="form-label">{{ filter_form.librenms_os.label }}</label>
{{ filter_form.librenms_os }}
{% if filter_form.librenms_os.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_os.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ filter_form.librenms_hostname.id_for_label }}" class="form-label">{{ filter_form.librenms_hostname.label }}</label>
{{ filter_form.librenms_hostname }}
{% if filter_form.librenms_hostname.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_hostname.help_text }}</small>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<label for="{{ filter_form.librenms_sysname.id_for_label }}" class="form-label">{{ filter_form.librenms_sysname.label }}</label>
{{ filter_form.librenms_sysname }}
{% if filter_form.librenms_sysname.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_sysname.help_text }}</small>
{% endif %}
</div>
<div class="col-md-6">
<label for="{{ filter_form.librenms_hardware.id_for_label }}" class="form-label">{{ filter_form.librenms_hardware.label }}</label>
{{ filter_form.librenms_hardware }}
{% if filter_form.librenms_hardware.help_text %}
<small class="form-text text-muted">{{ filter_form.librenms_hardware.help_text }}</small>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
{{ filter_form.exclude_existing }}
<label class="form-check-label" for="{{ filter_form.exclude_existing.id_for_label }}">
{{ filter_form.exclude_existing.label }}
</label>
{% if filter_form.exclude_existing.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.exclude_existing.help_text }}</small>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="form-check">
{{ filter_form.show_disabled }}
<label class="form-check-label" for="{{ filter_form.show_disabled.id_for_label }}">
{{ filter_form.show_disabled.label }}
</label>
{% if filter_form.show_disabled.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.show_disabled.help_text }}</small>
{% endif %}
</div>
</div>
</div>
<div class="row mb-3">
<div class="col-md-6">
<div class="form-check">
{{ filter_form.enable_vc_detection }}
<label class="form-check-label" for="{{ filter_form.enable_vc_detection.id_for_label }}">
{{ filter_form.enable_vc_detection.label }}
</label>
{% if filter_form.enable_vc_detection.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.enable_vc_detection.help_text }}</small>
{% endif %}
</div>
</div>
<div class="col-md-6">
<div class="form-check">
{{ filter_form.clear_cache }}
<label class="form-check-label" for="{{ filter_form.clear_cache.id_for_label }}">
{{ filter_form.clear_cache.label }}
</label>
{% if filter_form.clear_cache.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.clear_cache.help_text }}</small>
{% endif %}
</div>
</div>
</div>
<div class="p-3 my-3 bg-primary-subtle border border-primary rounded-1">
<div class="form-check">
{% if can_use_background_jobs %}
{{ filter_form.use_background_job }}
<label class="form-check-label" for="{{ filter_form.use_background_job.id_for_label }}">
{{ filter_form.use_background_job.label }}
</label>
{% if filter_form.use_background_job.help_text %}
<small class="form-text text-muted d-block">{{ filter_form.use_background_job.help_text }}</small>
{% endif %}
{% else %}
<input type="checkbox" class="form-check-input" disabled title="Background jobs require superuser access">
<label class="form-check-label text-muted">
{{ filter_form.use_background_job.label }}
</label>
<small class="form-text text-muted d-block">
<i class="mdi mdi-information-outline"></i> Background jobs require superuser access. Filters will process synchronously.
</small>
{% endif %}
</div>
</div>
<div class="text-end">
<button type="submit" class="btn btn-primary" id="apply-filters-btn">
<i class="mdi mdi-filter"></i> Apply Filters
</button>
<a href="{% url 'plugins:netbox_librenms_plugin:librenms_import' %}" class="btn btn-secondary">
<i class="mdi mdi-close"></i> Clear
</a>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
{% endif %}
{# Cached Searches Section #}
{% if cached_searches %}
<div class="card mb-3">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0">
Active Cached Searches
<span id="cached-searches-badge">{% badge cached_searches|length bg_color="primary" %}</span>
</h6>
<button class="btn btn-sm btn-outline-secondary" type="button" data-bs-toggle="collapse" data-bs-target="#cached-searches-collapse" aria-expanded="true" aria-controls="cached-searches-collapse">
<i class="mdi mdi-clock-fast"></i> Toggle Cached Searches
</button>
</div>
<div class="collapse show" id="cached-searches-collapse">
<div class="card-body p-2">
<div class="d-flex flex-wrap gap-2">
{% for search in cached_searches %}
<a href="?apply_filters=1{% for key, value in search.filters.items %}&librenms_{{ key }}={{ value|urlencode }}{% endfor %}{% if search.vc_enabled %}&enable_vc_detection=1{% endif %}"
class="btn btn-sm btn-outline-secondary"
style="text-decoration: none;"
title="Click to load this cached search">
<i class="mdi mdi-filter-outline"></i>
{% with display_data=search.display_filters|default:search.filters %}
{% for key, value in display_data.items %}
{% if not forloop.first %} <span class="text-muted">|</span> {% endif %}
{{ value }}
{% endfor %}
{% endwith %}
{% if search.vc_enabled %}
<span class="text-muted">| VC</span>
{% endif %}
<span class="text-muted ms-2">({{ search.device_count }} devices, <span class="cached-search-countdown" data-cache-timestamp="{{ search.cached_at }}" data-cache-timeout="{{ search.cache_timeout }}">{{ search.remaining_seconds }}s</span> left)</span>
</a>
{% endfor %}
</div>
<small class="text-muted d-block mt-2">
<i class="mdi mdi-information-outline"></i> Click any cached search to load those results again.
</small>
</div>
</div>
</div>
{% endif %}
{# Applied filters #}
{% if filter_form %}
{% applied_filters model filter_form request.GET %}
{% endif %}
<style>
/* Allow dropdown menus to extend beyond table container */
.device-import-table-wrapper {
overflow-x: auto;
overflow-y: visible;
/* Add space below table for dropdowns in last rows */
padding-bottom: 200px;
/* Pull up pagination to remove visual gap */
margin-bottom: -200px;
}
/* Ensure card doesn't clip overflowing dropdowns */
.device-import-table-wrapper + .pagination {
position: relative;
z-index: 1;
}
</style>
{# Results Section #}
{% if table.page.paginator.count == 0 %}
<div class="alert alert-warning mb-3">
<h6 class="alert-heading">
<i class="mdi mdi-alert"></i> No Results Found
</h6>
<p class="mb-0">
No devices found matching your filters. Try adjusting your search criteria above.
</p>
</div>
{% else %}
{# Bulk import toolbar #}
<div class="d-flex justify-content-between align-items-center flex-wrap gap-2 mb-3">
<div class="d-flex align-items-center gap-2 flex-wrap">
<button type="button" class="btn btn-outline-primary" id="select-all-ready">
<i class="mdi mdi-checkbox-multiple-marked"></i> Select All Ready
</button>
<button type="button" class="btn btn-outline-secondary" id="select-none">
<i class="mdi mdi-checkbox-multiple-blank-outline"></i> Deselect All
</button>
<span class="text-muted" id="selection-count">
<i class="mdi mdi-information"></i> 0 devices selected
</span>
</div>
<div class="d-flex align-items-center gap-2 flex-wrap">
{# Cache expiration info - before Settings section #}
{% if filters_submitted and table.rows %}
{% if cache_metadata_missing %}
<div class="d-flex align-items-center gap-1 text-warning small" title="Cache metadata not found. This may indicate a cache synchronization issue.">
<i class="mdi mdi-alert"></i>
<span>Cache status: <strong>unavailable</strong></span>
</div>
<span class="text-muted">|</span>
{% elif cache_timestamp and cache_timeout %}
<div class="d-flex align-items-center gap-1 text-muted small" id="cache-info-display"
data-cache-timestamp="{{ cache_timestamp }}"
data-cache-timeout="{{ cache_timeout }}">
<i class="mdi mdi-clock-outline"></i>
<span>Cache expires in <strong id="cache-expiry-countdown">{{ cache_timeout }}s</strong></span>
</div>
<span class="text-muted">|</span>
{% endif %}
{% endif %}
{# Import Settings #}
<div class="d-flex align-items-center gap-2 border-start ps-2"
data-save-pref-url="{% url 'plugins:netbox_librenms_plugin:save_user_pref' %}">
<strong class="small">Settings:</strong>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="use-sysname-toggle" name="use-sysname-toggle" {% if use_sysname %}checked{% endif %}
data-bs-toggle="tooltip"
title="Use SNMP sysName instead of LibreNMS hostname">
<label class="form-check-label small" for="use-sysname-toggle">
Use sysName
</label>
</div>
<div class="form-check form-switch mb-0">
<input class="form-check-input" type="checkbox" id="strip-domain-toggle" name="strip-domain-toggle" {% if strip_domain %}checked{% endif %}
data-bs-toggle="tooltip"
title="Remove domain suffix (e.g., 'switch01.example.com' → 'switch01'). IP addresses preserved.">
<label class="form-check-label small" for="strip-domain-toggle">
Strip domain
</label>
</div>
</div>
<button type="button" class="btn btn-success" id="bulk-import-btn" disabled
hx-post="{% url 'plugins:netbox_librenms_plugin:bulk_import_confirm' %}"
hx-target="#htmx-modal-content"
hx-swap="innerHTML"
hx-include="#import-selection-form, #use-sysname-toggle, #strip-domain-toggle">
<span class="d-inline-flex align-items-center gap-1">
<i class="mdi mdi-download"></i>
<span>Import Selected (<span id="import-count">0</span>)</span>
</span>
<span class="spinner-border spinner-border-sm ms-2 htmx-indicator" role="status" aria-hidden="true" style="display: none;"></span>
</button>
</div>
</div>
{% endif %}
<form method="post" class="form form-horizontal" id="import-selection-form">
{% csrf_token %}
<input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
<input type="hidden" name="enable_vc_detection" value="{{ vc_detection_enabled|yesno:'true,false' }}" />
{# Objects table #}
<div class="card">
<div class="htmx-container" id="object_list">
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
<div class="table-responsive device-import-table-wrapper">
{% include 'inc/table.html' %}
</div>
{% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %}
</div>
</div>
{# /Objects table #}
</form>
{# HTMX Modal for import actions #}
<div class="modal fade" id="htmx-modal" tabindex="-1" aria-labelledby="htmxModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content" id="htmx-modal-content">
{# Content loaded via HTMX #}
<div class="modal-body text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
</div>
</div>
</div>
{# Filter Processing Modal #}
<div class="modal fade" id="filter-processing-modal" tabindex="-1" aria-labelledby="filterProcessingLabel" aria-hidden="true" data-bs-backdrop="static" data-bs-keyboard="false">
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-body text-center py-4">
<div class="spinner-border text-primary mb-3" style="width: 3rem; height: 3rem;" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<h5 class="mb-2">Applying Filters</h5>
<p class="text-muted mb-1" id="filter-progress-message">Fetching LibreNMS data and processing filters...</p>
<p class="text-info mb-3" id="filter-device-count" style="display: none;">
<strong>LibreNMS Devices:</strong> <span id="filter-device-count-value">0</span>
</p>
{% if can_use_background_jobs %}
<button type="button" class="btn btn-secondary btn-sm" id="cancel-filter-btn">
<i class="mdi mdi-close"></i> Cancel
</button>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,339 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block title %}Plugin Settings{% endblock %}
{% block tabs %}
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" id="config-tab" data-bs-toggle="tab" data-bs-target="#config" type="button" role="tab" aria-controls="config" aria-selected="true">
<i class="ti ti-server-2 me-1"></i> Server Config
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="import-settings-tab" data-bs-toggle="tab" data-bs-target="#import-settings" type="button" role="tab" aria-controls="import-settings" aria-selected="false">
<i class="ti ti-download me-1"></i> Import Settings
</button>
</li>
</ul>
{% endblock tabs %}
{% block content %}
<div class="tab-content" data-active-tab="{{ active_tab|default:'' }}">
<!-- Server Config Tab -->
<div class="tab-pane fade show active" id="config" role="tabpanel" aria-labelledby="config-tab">
<form action="" method="post" class="form" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="form_type" value="server_config">
<div class="row justify-content-start">
<div class="col-lg-5 col-xl-4">
<div class="card h-100">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-server-2 me-2"></i>
LibreNMS Server Settings
</h3>
</div>
<div class="card-body d-flex flex-column">
<div class="mb-3 flex-grow-1">
<p class="text-muted mb-3">
Configure which LibreNMS server to use for synchronization operations.
Multiple servers can be configured in the NetBox configuration file.
</p>
{% render_field server_form.selected_server %}
</div>
<div class="text-end mt-auto">
<button type="submit" id="save-server-btn" class="btn btn-primary" disabled>
<i class="ti ti-check me-1"></i> Save Server Config
</button>
</div>
</div>
</div>
</div>
<!-- Connection Test Card -->
<div class="col-lg-5 col-xl-4">
<div class="card h-100">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-network me-2"></i>
Connection Test
</h3>
</div>
<div class="card-body d-flex flex-column">
<div class="flex-grow-1">
<p class="text-muted mb-3">Test the connection to the selected LibreNMS server to verify configuration.</p>
<button type="button"
id="test-connection-btn"
class="btn btn-outline-info w-100"
hx-post="{% url 'plugins:netbox_librenms_plugin:test_connection' %}"
hx-target="#test-result"
hx-swap="innerHTML"
hx-include="[name='selected_server']">
<i class="ti ti-network me-1"></i> Test Connection
<span class="spinner-border spinner-border-sm ms-1 htmx-indicator" role="status" style="display: none;"></span>
</button>
<!-- Test result area -->
<div id="test-result" class="mt-3"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Spacing between sections -->
<div class="my-4"></div>
<!-- Configuration Example Section -->
<div class="row">
<div class="col-lg-10 col-xl-8">
<div class="card">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-code me-2"></i>
Configuration Example
</h3>
</div>
<div class="card-body">
<p class="mb-3">
To configure multiple LibreNMS servers, update your NetBox
<code>configuration.py</code>:
</p>
<div class="highlight">
<pre class="mb-3"><code>PLUGINS_CONFIG = {
'netbox_librenms_plugin': {
'servers': {
'production': {
'display_name': 'Production LibreNMS',
'librenms_url': 'https://librenms-prod.example.com',
'api_token': 'your_production_token',
'cache_timeout': 300,
'verify_ssl': True,
'interface_name_field': 'ifDescr'
},
'testing': {
'display_name': 'Test LibreNMS',
'librenms_url': 'https://librenms-test.example.com',
'api_token': 'your_test_token',
'cache_timeout': 300,
'verify_ssl': False,
'interface_name_field': 'ifName'
}
}
}
}</code></pre>
</div>
<div class="alert alert-info">
<i class="ti ti-info-circle me-2"></i>
<strong>Note:</strong> For backward compatibility, the legacy single-server configuration
is still supported if no "servers" configuration is provided.
</div>
</div>
</div>
</div>
</div>
</form>
</div>
<!-- Import Settings Tab -->
<div class="tab-pane fade" id="import-settings" role="tabpanel" aria-labelledby="import-settings-tab">
<form action="" method="post" class="form" enctype="multipart/form-data">
{% csrf_token %}
<input type="hidden" name="form_type" value="import_settings">
<div class="row justify-content-start">
<!-- Left Column: Device Naming and Virtual Chassis -->
<div class="col-lg-6">
<!-- Device Naming Defaults Card -->
<div class="card mb-3">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-edit me-2"></i>
Device Naming Defaults
</h3>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Configure default naming preferences for imported devices.
</p>
<div class="alert alert-info small mb-3">
<i class="ti ti-info-circle me-1"></i>
<strong>User preferences:</strong> These defaults apply to users who have not yet changed their own toggle settings on the import page. Once a user changes a toggle, their personal preference is saved and takes priority over these defaults.
Saving these settings will also update your own preferences to match.
</div>
<div class="row">
<div class="col-12">
<div class="form-group mb-3">
<div class="form-check form-switch">
{{ import_form.use_sysname_default }}
<label class="form-check-label" for="{{ import_form.use_sysname_default.id_for_label }}">
{{ import_form.use_sysname_default.label }}
</label>
{% if import_form.use_sysname_default.help_text %}
<small class="form-text text-muted d-block">{{ import_form.use_sysname_default.help_text }}</small>
{% endif %}
</div>
</div>
<div class="form-group mb-3">
<div class="form-check form-switch">
{{ import_form.strip_domain_default }}
<label class="form-check-label" for="{{ import_form.strip_domain_default.id_for_label }}">
{{ import_form.strip_domain_default.label }}
</label>
{% if import_form.strip_domain_default.help_text %}
<small class="form-text text-muted d-block">{{ import_form.strip_domain_default.help_text }}</small>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Virtual Chassis Card -->
<div class="card mb-3">
<div class="card-header">
<h3 class="card-title">
<i class="ti ti-switch me-2"></i>
Virtual Chassis Member Naming
</h3>
</div>
<div class="card-body">
<p class="text-muted mb-3">
Configure how virtual chassis member devices are named during import.
</p>
<div class="row">
<div class="col-12">
<div class="form-group mb-3">
<label for="{{ import_form.vc_member_name_pattern.id_for_label }}" class="form-label">
{{ import_form.vc_member_name_pattern.label }}
</label>
{{ import_form.vc_member_name_pattern }}
{% if import_form.vc_member_name_pattern.errors %}
<div class="invalid-feedback d-block">
{{ import_form.vc_member_name_pattern.errors }}
</div>
{% endif %}
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="alert alert-info mb-0">
<p class="small mb-2"><strong>Available placeholders:</strong></p>
<ul class="mb-3 small" style="padding-left: 1.2rem;">
<li><code>{position}</code> - VC position number</li>
<li><code>{serial}</code> - Member serial number</li>
</ul>
<p class="small mb-0"><strong>Note:</strong> The pattern is appended to the master device name. At least one placeholder is required to ensure each member gets a unique name.</p>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<p class="small mb-2"><strong>Examples:</strong></p>
<ul class="mb-0 small" style="padding-left: 1.2rem;">
<li><code>-M{position}</code> → switch01-M1, switch01-M2</li>
<li><code> ({position})</code> → switch01 (1), switch01 (2)</li>
<li><code>-SW{position}</code> → switch01-SW1, switch01-SW2</li>
<li><code> [{serial}]</code> → switch01 [ABC123], switch01 [ABC124]</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Save Button -->
<div class="row justify-content-start">
<div class="col-lg-6">
<div class="text-end">
<button type="submit" id="save-import-btn" class="btn btn-primary" disabled>
<i class="ti ti-check me-1"></i> Save Import Settings
</button>
</div>
</div>
</div>
</form>
</div>
</div>
<!-- Spacing -->
<div class="my-4"></div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Auto-select the correct tab if there was a validation error
// Uses the same pattern as librenms_sync.js for consistency
const tabContent = document.querySelector('.tab-content');
const activeTab = tabContent ? tabContent.dataset.activeTab : '';
if (activeTab === 'import_settings') {
const importTab = document.getElementById('import-settings-tab');
const importPane = document.getElementById('import-settings');
const configTab = document.getElementById('config-tab');
const configPane = document.getElementById('config');
if (importTab && importPane && configTab && configPane) {
// Deactivate config tab
configTab.classList.remove('active');
configTab.setAttribute('aria-selected', 'false');
configPane.classList.remove('show', 'active');
// Activate import settings tab
importTab.classList.add('active');
importTab.setAttribute('aria-selected', 'true');
importPane.classList.add('show', 'active');
}
}
const serverSelect = document.getElementById('id_selected_server');
const vcPatternInput = document.getElementById('id_vc_member_name_pattern');
const saveServerBtn = document.getElementById('save-server-btn');
const saveImportBtn = document.getElementById('save-import-btn');
const useSysnameCheckbox = document.getElementById('id_use_sysname_default');
const stripDomainCheckbox = document.getElementById('id_strip_domain_default');
// Store the initial values to detect changes
const initialServerValue = serverSelect.value;
const initialVcPatternValue = vcPatternInput.value;
const initialUseSysnameValue = useSysnameCheckbox ? useSysnameCheckbox.checked : true;
const initialStripDomainValue = stripDomainCheckbox ? stripDomainCheckbox.checked : false;
// Enable/disable server save button based on changes
function updateServerSaveButton() {
const hasChanges = serverSelect.value !== initialServerValue;
saveServerBtn.disabled = !hasChanges;
saveServerBtn.classList.toggle('btn-primary', hasChanges);
saveServerBtn.classList.toggle('btn-secondary', !hasChanges);
}
// Enable/disable import save button based on changes
function updateImportSaveButton() {
const vcPatternChanged = vcPatternInput.value !== initialVcPatternValue;
const useSysnameChanged = useSysnameCheckbox ? (useSysnameCheckbox.checked !== initialUseSysnameValue) : false;
const stripDomainChanged = stripDomainCheckbox ? (stripDomainCheckbox.checked !== initialStripDomainValue) : false;
const hasChanges = vcPatternChanged || useSysnameChanged || stripDomainChanged;
saveImportBtn.disabled = !hasChanges;
saveImportBtn.classList.toggle('btn-primary', hasChanges);
saveImportBtn.classList.toggle('btn-secondary', !hasChanges);
}
// Listen for changes on respective form fields
serverSelect.addEventListener('change', updateServerSaveButton);
vcPatternInput.addEventListener('input', updateImportSaveButton);
if (useSysnameCheckbox) useSysnameCheckbox.addEventListener('change', updateImportSaveButton);
if (stripDomainCheckbox) stripDomainCheckbox.addEventListener('change', updateImportSaveButton);
// Initialize button states
updateServerSaveButton();
updateImportSaveButton();
});
</script>
{% endblock %}

View File

@@ -0,0 +1,51 @@
{% extends 'base/layout.html' %}
{% load buttons %}
{% load helpers %}
{% load plugins %}
{% load static %}
{% load i18n %}
{% load render_table from django_tables2 %}
{% block header %}
<div class="container-fluid mt-2 d-print-none">
<div>
<h1 class="page-title mt-1 mb-2">Site and Location Sync</h1>
<p>This page displays the synchronization status between NetBox sites and LibreNMS locations. It allows you to update or create locations in LibreNMS based on NetBox data.</p>
<p class="text-muted">Note: Only LibreNMS locations that match a Netbox site are shown on this page.</p>
</div>
<div class="mt-3">
<form method="get">
<div class="d-flex">
<div class="search-box" style="width: 300px">
{% for field in filter_form %}
{{ field }}
{% endfor %}
</div>
<div class="buttons m-2">
<button type="submit" class="btn btn-primary">Search</button>
<a href="." class="btn btn-secondary">Clear</a>
</div>
</div>
</form>
</div>
</div>
{% endblock %}
{% block content %}
<div class="container-fluid">
<form method="post">
{% csrf_token %}
<div class="row mb-3">
<div class="col-12">
<div class="card">
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
{% render_table table 'inc/table.html' %}
{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
</div>
</div>
</div>
</form>
</div>
{% endblock content %}

View File

@@ -0,0 +1,15 @@
{% extends 'generic/object_list.html' %}
{% load helpers %}
{% block title %}LibreNMS Status Check{% endblock %}
{% block content %}
{% if table.page.paginator.count == 0 %}
<div class="text-muted mb-3">
Select filters to check device status in LibreNMS. Click the status to view device LibreNMS sync details.
</div>
{% endif %}
{{ block.super }}
{% endblock %}