From 0d746a77aa4d0aabbb8f6520319c01262350125d Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Mon, 25 May 2026 15:46:03 +0200 Subject: [PATCH 01/10] feat: UI improvements extracted from oob-sync - Extract device type mapping form into reusable partial (_dt_mapping_form.html) - Extract platform mapping form into reusable partial (_platform_mapping_form.html) - Add AddPlatformMappingView for creating PlatformMapping from import modal - Register add_platform_mapping URL - device_validation_details.html: replace 100-line inline DT form with include; add DT mapping option in mismatch branch; add no-mapping elif with warning; add platform mapping form in platform row - librenms_sync_base.html: fix VM-without-cluster breadcrumb; add object_model_name guard on platform modal; rename _create_platform_url -> create_platform_url - librenms_import.html: wrap toggles with span+hidden-input so unchecked values submit; rename checkbox IDs to -cb suffix - librenms_import.js: rename toggle IDs to match -cb suffix; fix nested modal dismiss so inner modals (e.g. Promote-to-host picker) are not broken --- .../js/librenms_import.js | 16 ++- .../htmx/_dt_mapping_form.html | 112 +++++++++++++++++ .../htmx/_platform_mapping_form.html | 110 ++++++++++++++++ .../htmx/device_validation_details.html | 117 +++--------------- .../librenms_import.html | 22 ++-- netbox_librenms_plugin/urls.py | 6 + netbox_librenms_plugin/views/__init__.py | 1 + .../views/imports/actions.py | 104 ++++++++++++++++ 8 files changed, 372 insertions(+), 116 deletions(-) create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_dt_mapping_form.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_mapping_form.html diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js index a2facc706f..edfda206a8 100644 --- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js +++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js @@ -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); }); } @@ -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); } } diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_dt_mapping_form.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_dt_mapping_form.html new file mode 100644 index 0000000000..8fc88a2e25 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_dt_mapping_form.html @@ -0,0 +1,112 @@ +{% 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 %} +
+ {% csrf_token %} +
+ + + + +
+ {# Disabled until a typeahead item is chosen (or a preselect value is present). #} + +
+ diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_mapping_form.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_mapping_form.html new file mode 100644 index 0000000000..27904eb510 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_mapping_form.html @@ -0,0 +1,110 @@ +{% 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 %} +
+ {% csrf_token %} +
+ + + + +
+ {# Disabled until a typeahead item is chosen (or a preselect value is present). #} + +
+ diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html index ade94ebe0b..1b2e4fcb4a 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -175,6 +175,9 @@
+ {% if libre_device.hardware and libre_device.hardware != "-" %} + {% include "netbox_librenms_plugin/htmx/_dt_mapping_form.html" with preselect_device_type=validation.existing_device.device_type submit_label="Map LibreNMS hardware to current type" %} + {% endif %} {% 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 %} @@ -189,6 +192,12 @@
+ {% elif sync_info and not sync_info.device_type_synced and not sync_info.librenms_device_type and libre_device.hardware and libre_device.hardware != "-" %} + {# LibreNMS hardware has no NetBox mapping yet -- let the user create one, pre-selected to the existing device type so the common case is one click. #} +
+ No mapping for LibreNMS hardware + {% include "netbox_librenms_plugin/htmx/_dt_mapping_form.html" with preselect_device_type=validation.existing_device.device_type submit_label="Map LibreNMS hardware to current type" %} +
{% elif validation.device_type.device_type %} {% endif %} @@ -201,106 +210,8 @@
{% if libre_device.hardware and libre_device.hardware != "-" %}
No matching type -
- {% csrf_token %} - -
- - - - -
- -
+ {% include "netbox_librenms_plugin/htmx/_dt_mapping_form.html" %}
- {% else %} No matching type {% endif %} @@ -452,9 +363,11 @@
{% else %} Optional {% endif %} - {% endif %} - - {{ libre_device.os|default:"—" }} + {% if libre_device.os and libre_device.os != "-" and sync_info and not sync_info.platform_info.platform_exists %} + {% include "netbox_librenms_plugin/htmx/_platform_mapping_form.html" with preselect_platform=validation.existing_device.platform %} + {% endif %} + + {{ libre_device.os|default:"—" }} {% if validation.import_as_vm and not validation.existing_device or validation.existing_device and existing_device_model_name == "virtualmachine" %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html index d1bdbd549c..da9e4a3192 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html @@ -348,18 +348,24 @@
data-save-pref-url="{% url 'plugins:netbox_librenms_plugin:save_user_pref' %}"> Settings:
- -
- -
diff --git a/netbox_librenms_plugin/urls.py b/netbox_librenms_plugin/urls.py index 4d7881c17a..73b954c544 100644 --- a/netbox_librenms_plugin/urls.py +++ b/netbox_librenms_plugin/urls.py @@ -13,6 +13,7 @@ from .views import ( AddDeviceToLibreNMSView, AddDeviceTypeMappingView, + AddPlatformMappingView, AssignVCSerialView, BulkImportConfirmView, BulkImportDevicesView, @@ -429,6 +430,11 @@ AddDeviceTypeMappingView.as_view(), name="add_device_type_mapping", ), + path( + "device-import/add-platform-mapping//", + AddPlatformMappingView.as_view(), + name="add_platform_mapping", + ), path( "device-import/create-platform//", CreatePlatformFromImportView.as_view(), diff --git a/netbox_librenms_plugin/views/__init__.py b/netbox_librenms_plugin/views/__init__.py index 4ce30a5c58..ece34847a1 100644 --- a/netbox_librenms_plugin/views/__init__.py +++ b/netbox_librenms_plugin/views/__init__.py @@ -110,6 +110,7 @@ PlatformMappingView, ) from .imports.actions import AddDeviceTypeMappingView # noqa: F401 +from .imports.actions import AddPlatformMappingView # noqa: F401 from .object_sync import ( # noqa: F401 DeviceCableTableView, DeviceInterfaceTableView, diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index be442d93db..1a4828bfd1 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -1789,3 +1789,107 @@ def post(self, request): save_user_pref(request, self.ALLOWED_PREFS[key], value) return JsonResponse({"status": "ok"}) + + +class AddPlatformMappingView( + LibreNMSPermissionMixin, NetBoxObjectPermissionMixin, LibreNMSAPIMixin, DeviceImportHelperMixin, View +): + """HTMX view to create a PlatformMapping from the import validation modal.""" + + def post(self, request, device_id): + """Create a PlatformMapping linking the LibreNMS OS string to a NetBox Platform.""" + if error := self.require_write_permission(): + return error + + from dcim.models import Platform + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + from netbox_librenms_plugin.models import PlatformMapping + + post_server_key = (request.POST.get("server_key") or "").strip() + if post_server_key: + self._librenms_api = LibreNMSAPI(server_key=post_server_key) + + libre_device = fetch_device_with_cache(device_id, self.librenms_api) + if not libre_device: + return HttpResponse( + 'Device not found in LibreNMS.', + status=404, + ) + + librenms_os = (libre_device.get("os") or "").strip() + if not librenms_os: + return HttpResponse( + 'Device has no OS string -- cannot create mapping.', + status=400, + ) + + platform_id = request.POST.get("platform_id", "").strip() + if not platform_id: + return HttpResponse( + 'Please select a platform before submitting.', + status=400, + ) + + try: + platform_id = int(platform_id) + except (ValueError, TypeError): + return HttpResponse( + 'Invalid platform selection.', + status=400, + ) + + try: + platform = Platform.objects.get(pk=platform_id) + except Platform.DoesNotExist: + return HttpResponse( + 'Selected platform not found.', + status=404, + ) + + existing_mapping = PlatformMapping.objects.filter(librenms_os__iexact=librenms_os).first() + self.required_object_permissions = { + "POST": [("change", PlatformMapping) if existing_mapping else ("add", PlatformMapping)] + } + if error := self.require_object_permissions("POST"): + return error + + try: + with transaction.atomic(): + mapping, created = PlatformMapping.objects.get_or_create( + librenms_os=librenms_os.lower(), + defaults={"netbox_platform": platform}, + ) + if not created and existing_mapping is None and mapping.netbox_platform_id != platform_id: + self.required_object_permissions = {"POST": [("change", PlatformMapping)]} + if error := self.require_object_permissions("POST"): + return error + if not created and mapping.netbox_platform_id != platform_id: + mapping.netbox_platform = platform + mapping.save() + except Exception as exc: + logger.warning("AddPlatformMappingView: failed to save mapping: %s", exc) + return HttpResponse( + f'Error saving mapping: {escape(str(exc))}', + status=500, + ) + + cache_key = get_import_device_cache_key(device_id, self.librenms_api.server_key) + cache.delete(cache_key) + + detail_view = DeviceValidationDetailsView() + detail_view._librenms_api = self._librenms_api + modal_html = detail_view.get(request, device_id).content.decode("utf-8") + oob_modal = format_html( + '
{}
', + mark_safe(modal_html), + ) + + libre_device, validation, selections = self.get_validated_device_with_selections(device_id, request) + if libre_device is not None and validation is not None: + row_response = self.render_device_row(request, libre_device, validation, selections) + row_html = row_response.content.decode("utf-8") + row_html = format_html("{}
", mark_safe(row_html)) + else: + row_html = mark_safe("") + + return HttpResponse(oob_modal + row_html, content_type="text/html") From 0d71995d8d02dcb87b38550946d5d8f8bffadcbb Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Mon, 25 May 2026 23:52:34 +0200 Subject: [PATCH 02/10] fix(template): close platform-row if and make new-import mapping form reachable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Outer {% if validation.existing_device and ... %} at line 293 was missing its {% endif %}, breaking template compilation. The new-import branch's platform mapping include was also guarded on sync_info, which is only set when an existing device matches — so the form never rendered for new imports. Drop the sync_info clause and the unreachable preselect_platform. --- .../htmx/device_validation_details.html | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html index 1b2e4fcb4a..c5cd487fd1 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -363,9 +363,10 @@
{% else %} Optional {% endif %} - {% if libre_device.os and libre_device.os != "-" and sync_info and not sync_info.platform_info.platform_exists %} - {% include "netbox_librenms_plugin/htmx/_platform_mapping_form.html" with preselect_platform=validation.existing_device.platform %} + {% if libre_device.os and libre_device.os != "-" %} + {% include "netbox_librenms_plugin/htmx/_platform_mapping_form.html" %} {% endif %} + {% endif %} {{ libre_device.os|default:"—" }} From 88635b4b3c621832eabe4327e737240d34c9b74a Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 26 May 2026 18:59:46 +0200 Subject: [PATCH 03/10] fix(AddPlatformMappingView): close TOCTOU window with select_for_update MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The upfront permission check at line 1849-1854 selects "add" or "change" based on whether a mapping exists, but the get_or_create that follows could race with a concurrent delete: a caller with only "change" permission could end up creating a new row. Mirror the established AddDeviceTypeMappingView pattern — lock the row with select_for_update, re-derive the required permission from the locked state, and surface IntegrityError on concurrent inserts. --- .../views/imports/actions.py | 42 +++++++++++++------ 1 file changed, 29 insertions(+), 13 deletions(-) diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index 1a4828bfd1..d3ed44d94b 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -1855,23 +1855,39 @@ def post(self, request, device_id): try: with transaction.atomic(): - mapping, created = PlatformMapping.objects.get_or_create( - librenms_os=librenms_os.lower(), - defaults={"netbox_platform": platform}, - ) - if not created and existing_mapping is None and mapping.netbox_platform_id != platform_id: - self.required_object_permissions = {"POST": [("change", PlatformMapping)]} + # Lock the row to close the TOCTOU window between the upfront + # permission check and the actual write. select_for_update cannot + # lock absent rows, so the create branch handles IntegrityError. + locked = PlatformMapping.objects.select_for_update().filter(librenms_os__iexact=librenms_os).first() + if locked and not existing_mapping: + # Concurrent request created the mapping after our upfront read. + # Only escalate to change permission if we would actually mutate. + if locked.netbox_platform_id != platform_id: + self.required_object_permissions = {"POST": [("change", PlatformMapping)]} + if error := self.require_object_permissions("POST"): + return error + if existing_mapping and not locked: + # Mapping was deleted between our upfront read and the lock. + # We are about to CREATE a new row, so require add permission. + self.required_object_permissions = {"POST": [("add", PlatformMapping)]} if error := self.require_object_permissions("POST"): return error - if not created and mapping.netbox_platform_id != platform_id: - mapping.netbox_platform = platform - mapping.save() + if locked: + if locked.netbox_platform_id != platform_id: + locked.netbox_platform = platform + locked.full_clean() + locked.save() + else: + try: + PlatformMapping.objects.create( + librenms_os=librenms_os.lower(), + netbox_platform=platform, + ) + except IntegrityError: + return _htmx_error_response("Mapping was created concurrently. Please try again.") except Exception as exc: logger.warning("AddPlatformMappingView: failed to save mapping: %s", exc) - return HttpResponse( - f'Error saving mapping: {escape(str(exc))}', - status=500, - ) + return _htmx_error_response("Error saving mapping. Please try again.") cache_key = get_import_device_cache_key(device_id, self.librenms_api.server_key) cache.delete(cache_key) From 2bc4457bfdfc19c7e608e0b9e1cea76e13bc466b Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Wed, 27 May 2026 08:47:33 +0200 Subject: [PATCH 04/10] fix(AddPlatformMappingView): use _htmx_error_response and reject "-" OS Mirror the sibling AddDeviceTypeMappingView style for consistent HTMX toast behavior on validation errors, and reject the LibreNMS "-" placeholder OS that previously slipped past the empty-string check. --- .../views/imports/actions.py | 27 +++++-------------- 1 file changed, 6 insertions(+), 21 deletions(-) diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index d3ed44d94b..bad97f76fd 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -1811,40 +1811,25 @@ def post(self, request, device_id): libre_device = fetch_device_with_cache(device_id, self.librenms_api) if not libre_device: - return HttpResponse( - 'Device not found in LibreNMS.', - status=404, - ) + return _htmx_error_response("Device not found in LibreNMS.") librenms_os = (libre_device.get("os") or "").strip() - if not librenms_os: - return HttpResponse( - 'Device has no OS string -- cannot create mapping.', - status=400, - ) + if not librenms_os or librenms_os == "-": + return _htmx_error_response("Device has no OS string — cannot create mapping.") platform_id = request.POST.get("platform_id", "").strip() if not platform_id: - return HttpResponse( - 'Please select a platform before submitting.', - status=400, - ) + return _htmx_error_response("Please select a platform before submitting.") try: platform_id = int(platform_id) except (ValueError, TypeError): - return HttpResponse( - 'Invalid platform selection.', - status=400, - ) + return _htmx_error_response("Invalid platform selection.") try: platform = Platform.objects.get(pk=platform_id) except Platform.DoesNotExist: - return HttpResponse( - 'Selected platform not found.', - status=404, - ) + return _htmx_error_response("Selected platform not found.") existing_mapping = PlatformMapping.objects.filter(librenms_os__iexact=librenms_os).first() self.required_object_permissions = { From b72ee932a96d392877c9bf33b54528d3d24775d7 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sun, 31 May 2026 15:52:01 +0200 Subject: [PATCH 05/10] fix(ui): expose platform-mapping form in existing-device branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Existing devices whose LibreNMS OS has no PlatformMapping previously only got the 'Create Platform' path, nudging users toward duplicate Platform records. Mirror the device-type flow: also offer the platform-mapping form — preselected to the device's current platform when it has one, or unselected when it doesn't — so the LibreNMS OS can be mapped to an existing platform instead. Addresses CodeRabbit feedback surfaced on PR #84 (the file belongs to this branch / PR #299). --- .../htmx/device_validation_details.html | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html index c5cd487fd1..4801b2de5f 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -318,6 +318,10 @@
aria-label="Create platform for {{ libre_device.os }}"> + {# Let the user map the LibreNMS OS to the device's current platform instead of creating a duplicate (mirrors the device-type flow). #} +
+ {% include "netbox_librenms_plugin/htmx/_platform_mapping_form.html" with preselect_platform=validation.existing_device.platform submit_label="Map LibreNMS OS to current platform" %} +
{% endif %} {% endif %} {% elif sync_info and sync_info.platform_synced %} @@ -346,6 +350,10 @@
title="Create platform for {{ libre_device.os }}"> Create Platform + {# No current platform to preselect, but still let the user map the OS to an existing platform rather than create one. #} +
+ {% include "netbox_librenms_plugin/htmx/_platform_mapping_form.html" with submit_label="Map LibreNMS OS to a platform" %} +
{% endif %} {% elif validation.platform.platform %} {# New import — platform will be assigned on import #} From 7d7be71f33b9d454364feaaedabe43e2d9e08313 Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sun, 31 May 2026 16:29:45 +0200 Subject: [PATCH 06/10] fix(ui): guard against duplicate PlatformMapping rows with proper row locking AddPlatformMappingView now rejects an ambiguous OS string up front, and inside the transaction materialises the candidate rows with select_for_update()[:2] instead of count(). count() silently drops the FOR UPDATE clause, so the rows were never actually locked before the create/update decision (race window). Addresses CodeRabbit feedback on PR #303; the platform-mapping logic belongs to this branch / PR #299. --- netbox_librenms_plugin/views/imports/actions.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/netbox_librenms_plugin/views/imports/actions.py b/netbox_librenms_plugin/views/imports/actions.py index bad97f76fd..496e9f419f 100644 --- a/netbox_librenms_plugin/views/imports/actions.py +++ b/netbox_librenms_plugin/views/imports/actions.py @@ -1831,6 +1831,10 @@ def post(self, request, device_id): except Platform.DoesNotExist: return _htmx_error_response("Selected platform not found.") + if PlatformMapping.objects.filter(librenms_os__iexact=librenms_os).count() > 1: + return _htmx_error_response( + "Multiple mappings exist for this OS string. Remove duplicates before updating." + ) existing_mapping = PlatformMapping.objects.filter(librenms_os__iexact=librenms_os).first() self.required_object_permissions = { "POST": [("change", PlatformMapping) if existing_mapping else ("add", PlatformMapping)] @@ -1843,7 +1847,16 @@ def post(self, request, device_id): # Lock the row to close the TOCTOU window between the upfront # permission check and the actual write. select_for_update cannot # lock absent rows, so the create branch handles IntegrityError. - locked = PlatformMapping.objects.select_for_update().filter(librenms_os__iexact=librenms_os).first() + # Materialise the locked rows in one query — count() would drop + # the FOR UPDATE clause, leaving the rows unlocked. + locked_rows = list( + PlatformMapping.objects.select_for_update().filter(librenms_os__iexact=librenms_os)[:2] + ) + if len(locked_rows) > 1: + return _htmx_error_response( + "Multiple mappings exist for this OS string. Remove duplicates before updating." + ) + locked = locked_rows[0] if locked_rows else None if locked and not existing_mapping: # Concurrent request created the mapping after our upfront read. # Only escalate to change permission if we would actually mutate. From 517cc95d215ffdea9e69086735cb86d70e09818d Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Sun, 31 May 2026 19:21:55 +0200 Subject: [PATCH 07/10] ui(platform): compact platform cell with a single edit icon -> combined modal The platform cell in the import validation modal had become cluttered (inline mapping typeahead + Create Platform button + sync button all at once). Replace the inline forms/buttons with a single pencil icon that opens one modal offering BOTH options: - map the LibreNMS OS to an existing platform (typeahead), and - create a new platform. The icon also appears where a platform is already resolved (direct match or via mapping), so the mapping can be changed compactly. The combined modal reuses the existing create-platform modal plus the _platform_mapping_form partial, guarded so the device-sync page (create-only, no libre_device) is unaffected. - _platform_manage_icon.html: the compact pencil-icon trigger - create_platform_modal.html: adds a 'map to existing' section when libre_device is present; neutral alert wording - CreatePlatformFromImportView.get: passes libre_device + current_platform - device_validation_details.html: platform cell rewritten to use the icon; inline _platform_mapping_form includes + Create Platform buttons removed --- .../htmx/_platform_manage_icon.html | 13 ++++++ .../htmx/create_platform_modal.html | 15 +++++-- .../htmx/device_validation_details.html | 45 +++---------------- .../views/imports/actions.py | 5 +++ 4 files changed, 36 insertions(+), 42 deletions(-) create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_manage_icon.html diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_manage_icon.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_manage_icon.html new file mode 100644 index 0000000000..70b2fee6b7 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_manage_icon.html @@ -0,0 +1,13 @@ +{# 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. #} +{% if libre_device.os and libre_device.os != "-" %} + +{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/create_platform_modal.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/create_platform_modal.html index f2b557e0de..27db062c86 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/create_platform_modal.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/create_platform_modal.html @@ -13,10 +13,19 @@ {# server_key - (optional) LibreNMS server key, forwarded as hidden field #} +{% if libre_device %} +{# Map the LibreNMS OS to an EXISTING platform (separate form -> add_platform_mapping). #} + +
+{% endif %} {% csrf_token %} @@ -27,9 +36,9 @@