Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
0d746a7
feat: UI improvements extracted from oob-sync
marcinpsk May 25, 2026
0d71995
fix(template): close platform-row if and make new-import mapping form…
marcinpsk May 25, 2026
88635b4
fix(AddPlatformMappingView): close TOCTOU window with select_for_update
marcinpsk May 26, 2026
2bc4457
fix(AddPlatformMappingView): use _htmx_error_response and reject "-" OS
marcinpsk May 27, 2026
b72ee93
fix(ui): expose platform-mapping form in existing-device branches
marcinpsk May 31, 2026
7d7be71
fix(ui): guard against duplicate PlatformMapping rows with proper row…
marcinpsk May 31, 2026
517cc95
ui(platform): compact platform cell with a single edit icon -> combin…
marcinpsk May 31, 2026
55509aa
fix(ui): use {% comment %} in _platform_manage_icon (multi-line {# #}…
marcinpsk May 31, 2026
6020daa
test(templates): guard against multi-line {# #} comments leaking as text
marcinpsk May 31, 2026
abf769b
fix(import): forward server_key from mapping forms; use logger.exception
marcinpsk Jun 1, 2026
847e274
feat(ipam): add auto-create IPAM on device/VM import
marcinpsk May 31, 2026
eeb9099
fix(ip_helpers): wrap create() in nested savepoint to avoid poisoning…
marcinpsk May 31, 2026
ee95c05
fix: duplicate PlatformMapping guard and expose VM created_ips in bul…
marcinpsk May 31, 2026
506c40d
fix(ipam): address PR #84 review — server_key in HTMX mapping forms, …
marcinpsk May 31, 2026
4bced09
refactor(ipam): remove generic auto-create-IPAM-on-import feature
marcinpsk May 31, 2026
81ac570
feat(ipam): set Primary IP from LibreNMS mgmt IP on the IP-sync tab
marcinpsk May 31, 2026
a48ff0d
ui(ipam): rename toggle label to 'Set Primary IP'
marcinpsk May 31, 2026
dd0c621
feat(ipam): auto-select the management-IP row when 'Set Primary IP' i…
marcinpsk May 31, 2026
8983a22
fix(ipam): surface IP-sync errors and warn when Primary IP has no int…
marcinpsk May 31, 2026
0c17b06
fix(ipam): check response.ok when persisting the Set-Primary-IP toggl…
marcinpsk May 31, 2026
c7acffa
fix(ip-sync): scope existing-IP lookup to the target VRF
marcinpsk Jun 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion netbox_librenms_plugin/import_utils/device_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -749,7 +749,7 @@ def import_single_device(
'interfaces': int,
'cables': int,
'ip_addresses': int
}
},
}
"""
try:
Expand Down
6 changes: 4 additions & 2 deletions netbox_librenms_plugin/import_utils/vm_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,10 @@ def bulk_import_vms(
# Use job logger if available, otherwise standard logger
log = job.logger if job else logger

# Resolve options once before the loop — they do not change per-VM
use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True
strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False

for idx, vm_id in enumerate(vm_ids, start=1):
# Check for job cancellation before first VM and every 5 thereafter
if job and (idx == 1 or idx % 5 == 0) and _is_job_cancelled(job):
Expand All @@ -173,8 +177,6 @@ def bulk_import_vms(
continue

# Validate as VM
use_sysname_opt = sync_options.get("use_sysname", True) if sync_options else True
strip_domain_opt = sync_options.get("strip_domain", False) if sync_options else False
validation = validate_device_for_import(
libre_device,
import_as_vm=True,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -349,8 +349,8 @@
* Persists toggle state to user preferences on change.
*/
function initializeTogglePrefs() {
const sysname = document.getElementById('use-sysname-toggle');
const strip = document.getElementById('strip-domain-toggle');
const sysname = document.getElementById('use-sysname-toggle-cb');
const strip = document.getElementById('strip-domain-toggle-cb');
if (sysname) sysname.addEventListener('change', function () { savePref('use_sysname', this.checked); });
if (strip) strip.addEventListener('change', function () { savePref('strip_domain', this.checked); });
}
Expand Down Expand Up @@ -1199,10 +1199,14 @@

const dismissTrigger = event.target.closest('[data-bs-dismiss="modal"]');
if (dismissTrigger) {
event.preventDefault();

// Check if it's in the HTMX modal
if (modalElement.contains(dismissTrigger)) {
// Only handle dismiss triggers whose nearest .modal ancestor IS
// the outer HTMX modal. Buttons inside nested modals (e.g. the
// Promote-to-host modal rendered inside #htmx-modal-content)
// must be left for Bootstrap's own dismiss handler so they
// close the inner modal, not the outer one.
const nearestModal = dismissTrigger.closest('.modal');
if (nearestModal === modalElement) {
event.preventDefault();
hideModal(modalElement, fallbackBackdropRef);
}
}
Expand Down
1 change: 1 addition & 0 deletions netbox_librenms_plugin/tables/ipaddresses.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ class Meta:
row_attrs = {
"data-interface": lambda record: record["ip_address"],
"data-name": lambda record: record["ip_address"],
"data-mgmt-ip": lambda record: "true" if record.get("is_mgmt_ip") else "",
}

selection = ToggleColumn(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,21 @@
{% 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>
<div class="d-flex align-items-center gap-3">
<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 class="form-check form-switch mb-0">
<input type="hidden" name="set-primary-ip-toggle" value="off">
<input class="form-check-input" type="checkbox" id="set-primary-ip-toggle-cb"
name="set-primary-ip-toggle" value="on" {% if ip_sync.set_primary_ip %}checked{% endif %}
data-bs-toggle="tooltip"
title="When the synced IP equals the device's LibreNMS management IP and is assigned to an interface, set it as the Primary IP.">
<label class="form-check-label small" for="set-primary-ip-toggle-cb">
Set Primary IP
</label>
</div>
</div>
{% if ip_sync.cache_expiry %}
<div id="ip-cache-countdown" class="me-3">
Expand Down Expand Up @@ -74,6 +84,45 @@
</div>
</div>
</form>
<script>
// "Set Primary IP" toggle behaviour:
// - persist the choice as a user preference (remembered across visits)
// - auto-tick the management-IP row (flagged server-side with data-mgmt-ip)
// so syncing it sets the device/VM Primary IP in one action.
// Guarded so it binds once even after HTMX swaps.
(function () {
var cb = document.getElementById('set-primary-ip-toggle-cb');
if (!cb || cb.dataset.prefBound) return;
cb.dataset.prefBound = '1';

function mgmtRowCheckboxes() {
return document.querySelectorAll('tr[data-mgmt-ip="true"] input[name="select"]');
}

function syncMgmtSelection() {
mgmtRowCheckboxes().forEach(function (box) { box.checked = cb.checked; });
}

// Reflect the initial (pref-restored) toggle state onto the mgmt row.
syncMgmtSelection();

cb.addEventListener('change', function () {
syncMgmtSelection();
var url = "{% url 'plugins:netbox_librenms_plugin:save_user_pref' %}";
var token = (document.querySelector('[name=csrfmiddlewaretoken]') || {}).value || '';
fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json', 'X-CSRFToken': token },
body: JSON.stringify({ key: 'set_primary_ip', value: cb.checked })
})
.then(function (response) {
if (!response.ok) { throw new Error('HTTP ' + response.status); }
})
.catch(function (e) { console.debug('savePref failed:', e.message); });
});
})();
</script>
{% else %}
<div class="card">
<div class="card-body text-center text-muted py-4">
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
{% comment %}
Reusable "Add Device Type Mapping" form for the import validation modal.

Required context:
- libre_device (dict-like with device_id, hardware)
Optional context:
- preselect_device_type: a DeviceType instance to pre-fill the typeahead
(used in the existing-device + mismatch branch so a single click maps
`libre_device.hardware` → `existing_device.device_type`).
- submit_label (defaults to "Add Mapping")
{% endcomment %}
<form hx-post="{% url 'plugins:netbox_librenms_plugin:add_device_type_mapping' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="[name=role_{{ libre_device.device_id }}], [name=rack_{{ libre_device.device_id }}], [name=cluster_{{ libre_device.device_id }}], #use-sysname-toggle, #strip-domain-toggle"
class="mt-1"
id="dt-mapping-form-{{ libre_device.device_id }}">
{% csrf_token %}
{% if server_key %}<input type="hidden" name="server_key" value="{{ server_key }}">{% endif %}
<div class="position-relative">
<label for="dt-search-{{ libre_device.device_id }}" class="visually-hidden">
Search device types
</label>
<input type="text"
class="form-control form-control-sm"
id="dt-search-{{ libre_device.device_id }}"
placeholder="Search device types…"
autocomplete="off"
{% if preselect_device_type %}value="{% if preselect_device_type.manufacturer %}{{ preselect_device_type.manufacturer.name }} — {% endif %}{{ preselect_device_type.display }}"{% endif %}>
<input type="hidden"
name="device_type_id"
id="dt-id-{{ libre_device.device_id }}"
value="{% if preselect_device_type %}{{ preselect_device_type.pk }}{% endif %}">
<div id="dt-dropdown-{{ libre_device.device_id }}"
class="dropdown-menu w-100 shadow"
style="z-index:1050; max-height:200px; overflow-y:auto; display:none;"></div>
</div>
{# Disabled until a typeahead item is chosen (or a preselect value is present). #}
<button type="submit"
id="dt-submit-{{ libre_device.device_id }}"
class="btn btn-sm btn-primary mt-1 w-100"
{% if not preselect_device_type %}disabled{% endif %}>{{ submit_label|default:"Add Mapping" }}</button>
</form>
<script>
(function () {
var searchEl = document.getElementById("dt-search-{{ libre_device.device_id }}");
var dropdownEl = document.getElementById("dt-dropdown-{{ libre_device.device_id }}");
var hiddenEl = document.getElementById("dt-id-{{ libre_device.device_id }}");
var submitBtn = document.getElementById("dt-submit-{{ libre_device.device_id }}");
if (!searchEl) return;
var timer = null;
var requestSeq = 0;

searchEl.addEventListener("input", function () {
clearTimeout(timer);
hiddenEl.value = "";
submitBtn.disabled = true;
var q = this.value.trim();
requestSeq += 1;
var seq = requestSeq;
if (q.length < 2) { dropdownEl.style.display = "none"; dropdownEl.innerHTML = ""; return; }
timer = setTimeout(function () {
var csrfToken = (document.querySelector('[name=csrfmiddlewaretoken]') || {}).value || "";
fetch("{% url 'dcim-api:devicetype-list' %}?q=" + encodeURIComponent(q) + "&limit=20", {
headers: { "X-CSRFToken": csrfToken }
})
.then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.json();
})
.then(function (data) {
if (seq !== requestSeq) return;
dropdownEl.innerHTML = "";
if (!data.results || data.results.length === 0) {
dropdownEl.innerHTML = '<a class="dropdown-item small py-1 text-muted disabled">No results</a>';
} else {
data.results.forEach(function (dt) {
var a = document.createElement("a");
a.className = "dropdown-item small py-1";
a.href = "#";
a.textContent = (dt.manufacturer ? dt.manufacturer.display + " — " : "") + dt.display;
a.addEventListener("click", function (e) {
e.preventDefault();
searchEl.value = (dt.manufacturer ? dt.manufacturer.display + " — " : "") + dt.display;
hiddenEl.value = dt.id;
submitBtn.disabled = false;
dropdownEl.style.display = "none";
});
dropdownEl.appendChild(a);
});
}
dropdownEl.style.display = "block";
})
.catch(function (error) {
if (seq !== requestSeq) return;
console.debug("Device type lookup failed:", error.message);
dropdownEl.style.display = "none";
});
}, 300);
});

// Outside-click hides the dropdown. The listener self-removes once the
// form leaves the DOM (HTMX swap, modal teardown) so handlers don't
// accumulate on document each time this template re-renders.
function onDocClick(e) {
if (!document.body.contains(searchEl)) {
document.removeEventListener("click", onDocClick);
return;
}
if (!searchEl.contains(e.target) && !dropdownEl.contains(e.target)) {
dropdownEl.style.display = "none";
}
}
document.addEventListener("click", onDocClick);
})();
</script>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
{% comment %}
Compact pencil icon that opens the combined "manage platform" modal (map
LibreNMS OS to an existing platform, or create a new one). Keeps the platform
cell uncluttered. Requires: libre_device, server_key.
{% endcomment %}
{% if libre_device.os and libre_device.os != "-" %}
<button type="button" class="btn btn-sm btn-outline-secondary py-0 px-1 ms-1"
hx-get="{% url 'plugins:netbox_librenms_plugin:create_platform_from_import' device_id=libre_device.device_id %}?server_key={{ server_key|urlencode }}"
hx-target="#htmx-modal-content"
hx-swap="innerHTML"
Comment thread
marcinpsk marked this conversation as resolved.
title="Map or create platform for '{{ libre_device.os }}'"
aria-label="Map or create platform">
<i class="mdi mdi-pencil"></i>
</button>
{% endif %}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
{% comment %}
Reusable "Add Platform Mapping" form for the import validation modal.

Required context:
- libre_device (dict-like with device_id, os)
Optional:
- preselect_platform: a Platform instance to pre-fill the typeahead.
- submit_label
{% endcomment %}
<form hx-post="{% url 'plugins:netbox_librenms_plugin:add_platform_mapping' device_id=libre_device.device_id %}"
hx-swap="none"
hx-include="[name=role_{{ libre_device.device_id }}], [name=rack_{{ libre_device.device_id }}], [name=cluster_{{ libre_device.device_id }}], #use-sysname-toggle, #strip-domain-toggle"
class="mt-1"
id="plat-mapping-form-{{ libre_device.device_id }}">
{% csrf_token %}
{% if server_key %}<input type="hidden" name="server_key" value="{{ server_key }}">{% endif %}
<div class="position-relative">
<label for="plat-search-{{ libre_device.device_id }}" class="visually-hidden">
Search platforms
</label>
<input type="text"
class="form-control form-control-sm"
id="plat-search-{{ libre_device.device_id }}"
placeholder="Search platforms…"
autocomplete="off"
{% if preselect_platform %}value="{{ preselect_platform.name }}"{% endif %}>
<input type="hidden"
name="platform_id"
id="plat-id-{{ libre_device.device_id }}"
value="{% if preselect_platform %}{{ preselect_platform.pk }}{% endif %}">
<div id="plat-dropdown-{{ libre_device.device_id }}"
class="dropdown-menu w-100 shadow"
style="z-index:1050; max-height:200px; overflow-y:auto; display:none;"></div>
</div>
{# Disabled until a typeahead item is chosen (or a preselect value is present). #}
<button type="submit"
id="plat-submit-{{ libre_device.device_id }}"
class="btn btn-sm btn-primary mt-1 w-100"
{% if not preselect_platform %}disabled{% endif %}>{{ submit_label|default:"Add Mapping" }}</button>
</form>
<script>
(function () {
var searchEl = document.getElementById("plat-search-{{ libre_device.device_id }}");
var dropdownEl = document.getElementById("plat-dropdown-{{ libre_device.device_id }}");
var hiddenEl = document.getElementById("plat-id-{{ libre_device.device_id }}");
var submitBtn = document.getElementById("plat-submit-{{ libre_device.device_id }}");
if (!searchEl) return;
var timer = null;
var requestSeq = 0;

searchEl.addEventListener("input", function () {
clearTimeout(timer);
hiddenEl.value = "";
submitBtn.disabled = true;
var q = this.value.trim();
requestSeq += 1;
var seq = requestSeq;
if (q.length < 2) { dropdownEl.style.display = "none"; dropdownEl.innerHTML = ""; return; }
timer = setTimeout(function () {
var csrfToken = (document.querySelector('[name=csrfmiddlewaretoken]') || {}).value || "";
fetch("{% url 'dcim-api:platform-list' %}?q=" + encodeURIComponent(q) + "&limit=20", {
headers: { "X-CSRFToken": csrfToken }
})
.then(function (r) {
if (!r.ok) { throw new Error("HTTP " + r.status); }
return r.json();
})
.then(function (data) {
if (seq !== requestSeq) return;
dropdownEl.innerHTML = "";
if (!data.results || data.results.length === 0) {
dropdownEl.innerHTML = '<a class="dropdown-item small py-1 text-muted disabled">No results</a>';
} else {
data.results.forEach(function (p) {
var a = document.createElement("a");
a.className = "dropdown-item small py-1";
a.href = "#";
a.textContent = p.display || p.name;
a.addEventListener("click", function (e) {
e.preventDefault();
searchEl.value = p.display || p.name;
hiddenEl.value = p.id;
submitBtn.disabled = false;
dropdownEl.style.display = "none";
});
dropdownEl.appendChild(a);
});
}
dropdownEl.style.display = "block";
})
.catch(function (error) {
if (seq !== requestSeq) return;
console.debug("Platform lookup failed:", error.message);
dropdownEl.style.display = "none";
});
}, 300);
});

// Outside-click hides the dropdown. The listener self-removes once the
// form leaves the DOM (HTMX swap, modal teardown) so handlers don't
// accumulate on document each time this template re-renders.
function onDocClick(e) {
if (!document.body.contains(searchEl)) {
document.removeEventListener("click", onDocClick);
return;
}
if (!searchEl.contains(e.target) && !dropdownEl.contains(e.target)) {
dropdownEl.style.display = "none";
}
}
document.addEventListener("click", onDocClick);
})();
</script>
Loading