diff --git a/.devcontainer/config/codespaces-configuration.py b/.devcontainer/config/codespaces-configuration.py
index 84c1b669cb..3ba387bf0b 100644
--- a/.devcontainer/config/codespaces-configuration.py
+++ b/.devcontainer/config/codespaces-configuration.py
@@ -19,6 +19,8 @@
"127.0.0.1",
"*",
]
+ # Development environment β logging config values is an accepted tradeoff here.
+ # CodeQL alert for this is dismissed intentionally.
print(f"π Codespaces detected: {codespace_name}")
print(f"π CSRF Trusted Origins: {CSRF_TRUSTED_ORIGINS}")
print(f"π Allowed Hosts: {ALLOWED_HOSTS}")
diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md
index b64357a8fe..a2be7efa2e 100644
--- a/.github/copilot-instructions.md
+++ b/.github/copilot-instructions.md
@@ -5,6 +5,7 @@
> - [frontend.instructions.md](instructions/frontend.instructions.md) β applies to templates and static files
> - [background-jobs.instructions.md](instructions/background-jobs.instructions.md) β applies to `jobs.py`, import views, and import utilities
> - [sync.instructions.md](instructions/sync.instructions.md) β applies to sync views, base views, tables, and sync JS
+> - [release.instructions.md](instructions/release.instructions.md) β applies to changelog, pyproject.toml, and `__init__.py` version bumps
## Architecture & Key Modules
- Plugin hooks into NetBox (Django 5) under `netbox_librenms_plugin/`; respect NetBox plugin APIs (`navigation.py`, `urls.py`, `api/`).
@@ -62,13 +63,36 @@
- `_get_safe_redirect_url(request)` validates referrer URLs to prevent open-redirect attacks.
### Permission Helpers for Background Jobs
-- Background jobs run outside view context and cannot use view mixins. Use standalone helpers from `import_utils.py` (`check_user_permissions`, `require_permissions`). See `background-jobs.instructions.md` for details.
+- Background jobs run outside view context and cannot use view mixins. Use standalone helpers from `import_utils/permissions.py` (`check_user_permissions`, `require_permissions`). See `background-jobs.instructions.md` for details.
### API & Navigation Permissions
- API endpoints use `LibreNMSPluginPermission` class in `api/views.py` (GET=view, others=change).
- Navigation menu (`navigation.py`) has 3 groups: **Settings** (Plugin Settings, Interface Mappings), **Import** (LibreNMS Import), **Status Check** (Site & Location Sync, Device Status, VM Status). All items use `permissions=[PERM_VIEW_PLUGIN]`.
- **Background job polling requires superuser** β non-superusers fall back to synchronous mode. See `background-jobs.instructions.md` for details.
+## CodeQL & Security Patterns
+
+### Clearing CodeQL `py/reflected-xss` false positives
+When a view builds an `HttpResponse` from Django-template-rendered HTML (decoded via `.content.decode()`), CodeQL traces `request β template-render β HttpResponse` as reflected XSS even though Django templates auto-escape all user values.
+
+**Correct fix:** use `format_html()` to compose the envelope and `mark_safe()` as a **trust assertion** on the inner HTML β CodeQL's Django taint model recognises this pattern and stops tracking the taint:
+
+```python
+from django.utils.html import format_html
+from django.utils.safestring import mark_safe
+
+modal_html = some_view.get(request, pk).content.decode("utf-8")
+oob = format_html('
{}
', mark_safe(modal_html))
+return HttpResponse(oob, content_type="text/html")
+```
+
+> **Important:** `mark_safe()` is a trust assertion, not a sanitizer β it tells Django "I guarantee this string is already safe HTML." Only use it when `modal_html` comes from a server-rendered Django view (whose templates auto-escape all user values). Never pass untrusted user input to `mark_safe()` β that would introduce real XSS.
+
+**Do NOT** use `# lgtm[py/reflected-xss]` β that is LGTM.com legacy syntax and is **not** honoured by GitHub's modern CodeQL Action.
+
+### URL converters
+Always use `` (not ``) for numeric IDs in URL patterns. Django's `` converter auto-validates and returns 404 for non-integer values, eliminating the URL-parameter taint source that CodeQL otherwise flags.
+
## When in Doubt
- Check docs in `docs/development/` for structure, view inheritance, mixins, and template conventions before introducing new patterns.
- Review the existing sync views (e.g., `views/sync/interfaces.py`) as reference implementations for data flow and caching patterns.
diff --git a/.github/instructions/background-jobs.instructions.md b/.github/instructions/background-jobs.instructions.md
index 405d9e0380..405dd0a9fd 100644
--- a/.github/instructions/background-jobs.instructions.md
+++ b/.github/instructions/background-jobs.instructions.md
@@ -1,5 +1,5 @@
---
-applyTo: "**/jobs.py,**/views/imports/**,**/import_utils.py,**/import_validation_helpers.py"
+applyTo: "**/jobs.py,**/views/imports/**,**/import_utils/**,**/import_validation_helpers.py"
description: Background job architecture, import workflow, and task management patterns
---
@@ -69,13 +69,15 @@ Filter fields: `librenms_location`, `librenms_type`, `librenms_os`, `librenms_ho
- **`DeviceVCDetailsView`** (GET) β renders VC member details via `htmx/device_vc_details.html`.
- **`DeviceRoleUpdateView`**, **`DeviceClusterUpdateView`**, **`DeviceRackUpdateView`** (POST) β per-device dropdown updates. Apply selection to validation state and return re-rendered row via `render_device_row()`.
-## Key Import Utilities (`import_utils.py`)
-- `process_device_filters(filters, ...)` β fetches and validates devices from LibreNMS, returns list.
-- `validate_device_for_import(device, ...)` β core validation function, produces validation state dict.
-- `bulk_import_devices_shared(devices, user, ...)` β shared implementation between sync and background import.
-- `bulk_import_vms(vm_imports, user, ...)` β VM import implementation.
-- `fetch_device_with_cache(device_id, ...)` β retrieves/caches individual device data.
-- Cache key functions: `get_validated_device_cache_key()`, `get_cache_metadata_key()`, `get_active_cached_searches()`, `get_import_device_cache_key()`.
+## Key Import Utilities (`import_utils/` package)
+`import_utils/` is a package; the `__init__.py` re-exports key functions so callers can still use `from import_utils import ...`.
+
+- `filters.py` β `process_device_filters(filters, ...)`, `fetch_device_with_cache(device_id, ...)`.
+- `device_operations.py` β `validate_device_for_import(device, ...)`, `bulk_import_devices_shared(devices, user, ...)`.
+- `vm_operations.py` β `bulk_import_vms(vm_imports, user, ...)`.
+- `cache.py` β `get_validated_device_cache_key()`, `get_cache_metadata_key()`, `get_active_cached_searches()`, `get_import_device_cache_key()`.
+- `permissions.py` β `check_user_permissions(user, permissions)`, `require_permissions(user, permissions, action_description)`.
+- `virtual_chassis.py` β `create_virtual_chassis_with_members()`, `_sync_module_bay_counter()`.
## Validation Helpers (`import_validation_helpers.py`)
Centralizes validation state mutation used by the role/cluster/rack update views:
diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md
index 4f02754b5b..7d6795cb65 100644
--- a/.github/instructions/frontend.instructions.md
+++ b/.github/instructions/frontend.instructions.md
@@ -11,10 +11,11 @@ description: Frontend patterns for templates, HTMX, and static assets
- All HTMX requests and `fetch()` calls must include a CSRF token. The standard pattern is `document.querySelector('[name=csrfmiddlewaretoken]').value` (from a hidden form input). The import JS also uses `getCookie('csrftoken')` as a fallback β prefer the hidden input approach for consistency.
## Modal Implementation
-- Modals use Tabler (Bootstrap-like) but **without** `bootstrap.Modal` helpers.
+- Modals try Bootstrap 5 native (`bootstrap.Modal`) first, falling back to manual DOM manipulation if unavailable. Both `librenms_sync.js` and `librenms_import.js` follow this pattern via `showModal()`/`hideModal()` helpers.
- Buttons target the `htmx-modal-content` element and JavaScript in `librenms_import.html` toggles the wrapper.
- Do not reintroduce `data-bs-toggle` or duplicate modal IDs.
- The import page uses `ModalManager` class and `filterModalManager` instanceβalways use this reference in fetch callbacks, not undefined `modalInstance` variables.
+- Dismiss handlers (backdrop click, `data-bs-dismiss` buttons) are bound once per element to prevent stacking on repeated `showModal()` calls.
## JavaScript Fetch Patterns
- Always check `response.ok` before processing fetch responses to catch HTTP errors.
diff --git a/.github/instructions/release.instructions.md b/.github/instructions/release.instructions.md
new file mode 100644
index 0000000000..1d0d29b6d9
--- /dev/null
+++ b/.github/instructions/release.instructions.md
@@ -0,0 +1,149 @@
+---
+applyTo: "**/changelog.md,**/pyproject.toml,**/__init__.py"
+---
+
+# Release Workflow
+
+This describes the standard release process for the NetBox LibreNMS Plugin. Follow these steps exactly when asked to create a new release.
+
+## Branch Strategy
+
+> **Both `develop` and `master` have branch protection.** All changes must go through pull requests β never push directly.
+
+### Standard Flow (used for all releases)
+
+1. **Create `release/X.Y.Z` branch** from develop
+2. **Version bump commit** on the release branch
+3. **PR `release/X.Y.Z` β develop** (version bump PR)
+4. **PR `develop` β master** (release PR) β GitHub will auto-include any commits master is missing
+5. **Tag `vX.Y.Z`** on master and create GitHub release
+6. **Post-release:** master may now be ahead of develop (e.g. the merge commit). This resolves naturally β step 1 of the next release starts from the current develop, and the release PR (step 4) will reconcile any divergence.
+
+## Files to Update
+
+Three files must be updated in a single commit with message `Bump version to X.Y.Z and update changelog`:
+
+### `netbox_librenms_plugin/__init__.py`
+```python
+__version__ = "X.Y.Z"
+```
+
+### `pyproject.toml`
+```toml
+version = "X.Y.Z"
+```
+
+### `docs/changelog.md`
+Prepend a new section at the top (after `# Changelog`). Use the current date in `YYYY-MM-DD` format. Categories used (pick only those that apply):
+- `### New Features`
+- `### Improvements`
+- `### Fixes`
+- `### Development`
+- `### Documentation`
+
+Example:
+```markdown
+## X.Y.Z (YYYY-MM-DD)
+
+### Fixes
+* Description of fix (#PR_NUMBER)
+```
+
+## Version Bump PR (release/X.Y.Z β develop)
+
+**Title:** `Bump version to X.Y.Z and update changelog`
+
+**Body template:**
+```markdown
+## Summary
+Bump version to X.Y.Z and update changelog for release.
+
+## Motivation / Problem
+- Maintenance / cleanup
+
+Prepare release X.Y.Z with .
+
+## Scope of Change
+
+- Config / settings
+- Docs only
+
+## How Was This Tested?
+
+- Not tested: version bump and changelog only
+
+## Risk Assessment
+- No impact on existing users
+- No code logic changes
+
+## Backwards Compatibility
+- No breaking changes
+```
+
+## Release PR (develop β master)
+
+**Title:** `Release X.Y.Z`
+
+**Body template:**
+```markdown
+## Summary
+Release X.Y.Z β merge develop into master for PyPI release.
+
+## Motivation / Problem
+-
+
+
+
+## Scope of Change
+
+
+
+## Changes
+
+- Bump version to X.Y.Z
+
+## How Was This Tested?
+
+
+
+## Risk Assessment
+
+
+## Backwards Compatibility
+- No breaking changes
+```
+
+## GitHub Release Text
+
+**Tag:** `vX.Y.Z` (create on master)
+**Release title:** `vX.Y.Z`
+
+**Body template:**
+```markdown
+##
+
+
+
+###
+
+- (#PR_NUMBER)
+
+> ### Upgrade note
+> - Standard [update process](https://github.com/bonzo81/netbox-librenms-plugin#update) applies.
+
+---
+
+## All Changes
+* by @ in https://github.com/bonzo81/netbox-librenms-plugin/pull/
+```
+
+The "All Changes" section lists only the feature/fix PRs included in the release β not version bump or merge PRs.
+
+## Checklist
+
+- [ ] Version bumped in `__init__.py` and `pyproject.toml`
+- [ ] Changelog updated in `docs/changelog.md`
+- [ ] Version bump PR merged to develop
+- [ ] Release PR merged to master
+- [ ] Tag created on master
+- [ ] GitHub release published
diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md
index 16b6e8858d..beb3ffc7bc 100644
--- a/.github/instructions/testing.instructions.md
+++ b/.github/instructions/testing.instructions.md
@@ -27,8 +27,10 @@ description: Testing patterns and conventions for the NetBox LibreNMS plugin
## Test Coverage by Module
- `librenms_api.py` β `test_librenms_api.py`, `test_librenms_api_helpers.py`
-- `import_utils.py`, `import_validation_helpers.py`, `utils.py` β `test_import_utils.py`, `test_import_validation_helpers.py`, `test_utils.py`
+- `import_utils/` package (`filters.py`, `device_operations.py`, `vm_operations.py`, `cache.py`, `permissions.py`, `virtual_chassis.py`), `import_validation_helpers.py`, `utils.py` β `test_import_utils.py`, `test_import_validation_helpers.py`, `test_utils.py`
- `jobs.py`, `views/imports/list.py` β `test_background_jobs.py`
+- `import_utils/bulk_import.py` β `test_coverage_bulk_import.py`
+- Utility helpers (`utils.py` coverage tests) β `test_coverage_utils.py`
- Permission mixins, API permissions, constants β `test_permissions.py`
- VLAN API, mode detection, comparison, sync β `test_vlan_sync.py`
- `VlanAssignmentMixin`, VLAN enrichment β `test_interface_vlan_sync.py`
diff --git a/contrib/README.md b/contrib/README.md
new file mode 100644
index 0000000000..bbd8413039
--- /dev/null
+++ b/contrib/README.md
@@ -0,0 +1,31 @@
+# Contrib: Example Mapping Files
+
+This directory contains example YAML mapping files for bulk import into the
+NetBox LibreNMS Plugin. Each file can be imported via the plugin's bulk import
+feature in the NetBox UI.
+
+## How to Import
+
+1. Navigate to the mapping page (e.g., **LibreNMS β Device Type Mappings**)
+2. Click the **Import** button (upload icon) in the top right
+3. Select **YAML** format
+4. Paste the contents of the relevant YAML file
+5. Click **Submit**
+
+## Available Mappings
+
+| File | Description |
+|------|-------------|
+| `interface_type_mappings.yaml` | Maps LibreNMS interface types + speeds to NetBox interface types |
+| `device_type_mappings.yaml` | Maps LibreNMS hardware strings to NetBox device types |
+| `module_type_mappings.yaml` | Maps LibreNMS inventory model names to NetBox module types (incl. transceivers) |
+| `module_bay_mappings.yaml` | Maps LibreNMS inventory container names to NetBox module bay names |
+| `normalization_rules.yaml` | Regex-based string normalization applied before module type/bay lookups |
+| `inventory_ignore_rules.yaml` | Suppresses phantom ENTITY-MIB entries (e.g. Cisco IOS-XR IDPROM artefacts) |
+| `platform_mappings.yaml` | Maps LibreNMS platform strings to NetBox device platforms |
+
+## Customisation
+
+These files are **examples** β adjust values to match the device types, module
+types, and interface types defined in your NetBox instance. The `netbox_*`
+fields must reference objects that already exist in your NetBox.
diff --git a/contrib/carrier_auto_install_rules.yaml b/contrib/carrier_auto_install_rules.yaml
new file mode 100644
index 0000000000..f92b6471d0
--- /dev/null
+++ b/contrib/carrier_auto_install_rules.yaml
@@ -0,0 +1,31 @@
+# Carrier Auto-Install Rules
+#
+# Suggest a holder/carrier ModuleType to install when LibreNMS reports orphan
+# child modules (e.g. CPM cards, mezzanines, MDAs) that have no matching NetBox
+# bay because their parent carrier was never installed in NetBox.
+#
+# Import via: LibreNMS Plugin β Carrier Auto-Install Rules β Import
+#
+# Fields:
+# manufacturer: Optional manufacturer name (exact). Empty = any vendor.
+# device_type_pattern: Optional regex on device_type.model. Empty = any model.
+# librenms_child_class: Exact entPhysicalClass of the orphan child (e.g. cpmModule).
+# librenms_child_name_pattern: Regex on entPhysicalName of the orphan child.
+# netbox_bay_name_pattern: Regex on candidate empty device-level bay name.
+# carrier_module_type: NetBox ModuleType model (slug-style model field) to install.
+# description: Optional description.
+#
+# Patterns use Python re.fullmatch() β they must match the entire string.
+# When the chassis has at least one empty bay matching netbox_bay_name_pattern,
+# an "Install Carrier" button appears on the module sync page (suggest-only,
+# never auto-installed). Multiple matching empty bays produce one button each.
+
+# Nokia 7750 SR-s chassis (e.g. SR-7s, SR-14s) report CPM cards in slots A/B
+# but the physical CMA carrier that holds them is invisible to LibreNMS.
+- manufacturer: Nokia
+ device_type_pattern: '^7750 SR-.*$'
+ librenms_child_class: cpmModule
+ librenms_child_name_pattern: '^Slot [AB]$'
+ netbox_bay_name_pattern: '^CMA$'
+ carrier_module_type: CMA2-7s
+ description: Install CMA2-7s carrier when CPM cards are reported orphaned
diff --git a/contrib/device_type_mappings.yaml b/contrib/device_type_mappings.yaml
new file mode 100644
index 0000000000..2dec241524
--- /dev/null
+++ b/contrib/device_type_mappings.yaml
@@ -0,0 +1,73 @@
+# Device Type Mappings
+#
+# Maps LibreNMS hardware strings to NetBox device types.
+# Import via: LibreNMS Plugin > Device Type Mappings > Import
+#
+# Fields:
+# librenms_hardware β Hardware string exactly as shown in LibreNMS
+# netbox_device_type β NetBox DeviceType (matched by model name or ID)
+# description β Optional note
+#
+# The librenms_hardware value is matched case-insensitively.
+# These mappings are checked BEFORE the built-in part_number/model fallback.
+
+# Juniper β LibreNMS reports verbose marketing names
+- librenms_hardware: "Juniper MX480 Internet Backbone Router"
+ netbox_device_type: "MX480"
+ description: "Juniper MX480 chassis"
+
+- librenms_hardware: "Juniper MX960 Internet Backbone Router"
+ netbox_device_type: "MX960"
+ description: "Juniper MX960 chassis"
+
+- librenms_hardware: "Juniper MX304 Edge Router"
+ netbox_device_type: "MX304"
+ description: "Juniper MX304 edge router"
+
+- librenms_hardware: "JNP10008 [PTX10008]"
+ netbox_device_type: "PTX10008"
+ description: "Juniper PTX10008 core router"
+
+- librenms_hardware: "JNP7100-32C [ACX7100-32C]"
+ netbox_device_type: "ACX7100-32C"
+ description: "Juniper ACX7100-32C"
+
+- librenms_hardware: "JNP7024 [ACX7024]"
+ netbox_device_type: "ACX7024"
+ description: "Juniper ACX7024"
+
+- librenms_hardware: "Juniper JNP10008 Internet Backbone Router"
+ netbox_device_type: "PTX10008"
+ description: "Juniper PTX10008 (alternate hardware string)"
+
+- librenms_hardware: "Juniper VRR Internet Backbone Router"
+ netbox_device_type: "VRR"
+ description: "Juniper Virtual Route Reflector"
+
+# Nokia β model string matches directly in most cases
+- librenms_hardware: "7750 SR-7s"
+ netbox_device_type: "7750 SR-7s"
+ description: "Nokia 7750 SR-7s service router"
+
+# Cisco β often matches by part_number but not always
+- librenms_hardware: "WS-C4900M"
+ netbox_device_type: "WS-C4900M"
+ description: "Cisco Catalyst 4900M"
+
+# Cisco IOS XR
+- librenms_hardware: "8201-SYS"
+ netbox_device_type: "8201"
+ description: "Cisco 8201 (hardware string differs from model)"
+
+# UfiSpace β LibreNMS reports SONiC/ONIE platform names
+- librenms_hardware: "x86-64-ufispace-s9610-36d-r0"
+ netbox_device_type: "S9610-36D"
+ description: "UfiSpace S9610-36D"
+
+- librenms_hardware: "x86-64-ufispace-s9610-46dx-r0"
+ netbox_device_type: "S9610-46DX"
+ description: "UfiSpace S9610-46DX"
+
+- librenms_hardware: "x86-64-ufispace-s9700-53dx-r9"
+ netbox_device_type: "S9700-53DX"
+ description: "UfiSpace S9700-53DX"
diff --git a/contrib/interface_type_mappings.yaml b/contrib/interface_type_mappings.yaml
new file mode 100644
index 0000000000..3f48cd94b9
--- /dev/null
+++ b/contrib/interface_type_mappings.yaml
@@ -0,0 +1,75 @@
+# Interface Type Mappings
+#
+# Maps LibreNMS interface types (and optional speeds) to NetBox interface types.
+# Import via: LibreNMS Plugin > Interface Mappings > Import
+#
+# Fields:
+# librenms_type β IANA ifType string from LibreNMS (e.g. ethernetCsmacd)
+# librenms_speed β Speed in Kbps (optional, null matches any speed)
+# netbox_type β NetBox InterfaceTypeChoices slug
+# description β Optional note
+#
+# Common NetBox interface type slugs:
+# 1000base-t, 10gbase-t, 10gbase-x-sfpp, 25gbase-x-sfp28,
+# 40gbase-x-qsfpp, 100gbase-x-qsfp28, 400gbase-x-qsfpdd,
+# ieee802.11ax, lag, virtual, other
+
+# WARNING: Speed-only matching cannot distinguish copper from fiber optics.
+# For example, 1G ethernetCsmacd could be 1000base-t (copper), 1000base-x-sfp (fiber),
+# or other media types. Review and adjust these mappings for your environment before
+# importing β incorrect mappings will mislabel ports.
+
+- librenms_type: ethernetCsmacd
+ librenms_speed: 1000000
+ netbox_type: 1000base-t
+ description: "1G Ethernet copper"
+
+- librenms_type: ethernetCsmacd
+ librenms_speed: 10000000
+ netbox_type: 10gbase-x-sfpp
+ description: "10G Ethernet SFP+"
+
+- librenms_type: ethernetCsmacd
+ librenms_speed: 25000000
+ netbox_type: 25gbase-x-sfp28
+ description: "25G Ethernet SFP28"
+
+- librenms_type: ethernetCsmacd
+ librenms_speed: 40000000
+ netbox_type: 40gbase-x-qsfpp
+ description: "40G Ethernet QSFP+"
+
+- librenms_type: ethernetCsmacd
+ librenms_speed: 100000000
+ netbox_type: 100gbase-x-qsfp28
+ description: "100G Ethernet QSFP28"
+
+- librenms_type: ethernetCsmacd
+ librenms_speed: 400000000
+ netbox_type: 400gbase-x-qsfpdd
+ description: "400G Ethernet QSFP-DD"
+
+- librenms_type: ieee8023adLag
+ librenms_speed:
+ netbox_type: lag
+ description: "LACP/LAG aggregation"
+
+- librenms_type: propVirtual
+ librenms_speed:
+ netbox_type: virtual
+ description: "Virtual/loopback interface"
+
+- librenms_type: softwareLoopback
+ librenms_speed:
+ netbox_type: virtual
+ description: "Software loopback"
+
+- librenms_type: tunnel
+ librenms_speed:
+ netbox_type: virtual
+ description: "Tunnel interface"
+
+- librenms_type: l2vlan
+ librenms_speed:
+ netbox_type: virtual
+ description: "VLAN interface"
diff --git a/contrib/inventory_ignore_rules.yaml b/contrib/inventory_ignore_rules.yaml
new file mode 100644
index 0000000000..e56b7c997b
--- /dev/null
+++ b/contrib/inventory_ignore_rules.yaml
@@ -0,0 +1,94 @@
+# Inventory Ignore Rules β filter ENTITY-MIB items during module sync
+#
+# Two actions are supported:
+# skip β remove the item from the sync table entirely
+# transparent β hide the item's row but promote its ENTITY-MIB children to
+# device-level bay matching (use for embedded/fixed-chassis modules)
+#
+# Match types:
+# ends_with | starts_with | contains | regex
+# β compare entPhysicalName
+# serial_matches_device β compare entPhysicalSerialNum to the NetBox device's
+# own serial number (no pattern needed)
+#
+# Import via: LibreNMS Plugin β Settings β Inventory Ignore Rules β Import
+#
+# Additional fields:
+# require_serial_match_parent:
+# true β (name-based rules only) only apply if the item's serial matches
+# any ancestor entity's serial in the ENTITY-MIB tree
+# false β apply unconditionally on name match alone
+# enabled: true | false
+# description: optional notes
+#
+# Serial-match ancestor walk (name-based rules only):
+# When require_serial_match_parent is true the plugin walks up the ENTITY-MIB
+# ancestor chain until it finds a non-empty serial. If that serial equals the
+# item's serial the rule fires. This handles multi-level hierarchies, e.g.
+# Cisco IOS-XR: IDPROM β Mother Board [empty serial] β RP module.
+#
+# Both rules below are also created automatically by migration 0010. Import them
+# only if you wiped the table or need to replicate settings across instances.
+
+# βββ Cisco IOS-XR β IDPROM entries (action=skip) βββββββββββββββββββββββββββββ
+# IOS-XR reports each hardware component's EEPROM as a child entity whose name
+# ends in "-IDPROM". These share the same model+serial as the parent and are not
+# installable hardware.
+#
+# Hierarchy example (Cisco 8201-SYS):
+# 0/RP0/CPU0 (serial FOC2418NHRK)
+# βββ 0/RP0/CPU0-Mother Board (serial empty)
+# βββ 0/RP0/CPU0-Base Board IDPROM (serial FOC2418NHRK) β SKIP
+# Optics0/0/0/0 (serial SN123)
+# βββ Optics0/0/0/0-IDPROM (serial SN123) β SKIP
+
+- name: "Cisco IOS-XR IDPROM entries"
+ match_type: ends_with
+ pattern: "IDPROM"
+ action: skip
+ require_serial_match_parent: true
+ enabled: true
+ description: >
+ Cisco IOS-XR reports every hardware component's EEPROM as a child entity
+ whose entPhysicalName ends in "IDPROM". These entries duplicate the parent
+ module's serial number and are not real installable modules.
+ The serial-match guard ensures only genuine EEPROM duplicates are skipped β
+ a module whose name happens to end in "IDPROM" but has a different serial
+ will not be filtered.
+
+# βββ Fixed-chassis embedded RP (action=transparent) ββββββββββββββββββββββββββ
+# Fixed-form routers (e.g. Cisco 8201-SYS, 8101-32FH, Juniper PTX10001-36MR)
+# report their built-in RP/system-board as an ENTITY-MIB module whose serial
+# number equals the chassis/device serial. The RP is NOT a removable FRU β
+# it IS the device itself. Marking it "transparent" hides the RP row but lets
+# its ENTITY-MIB children (transceivers, fans, PSUs) fall through to device-
+# level bay matching.
+#
+# Detection signal: entPhysicalSerialNum == NetBox Device.serial
+#
+# Hierarchy for Cisco 8201-SYS (device serial = FOC2418NHRK):
+# Rack 0-Control Card Slot 0 (container)
+# βββ 0/RP0/CPU0 (serial FOC2418NHRK) β TRANSPARENT (= device serial)
+# βββ Optics Controller containers
+# βββ 0/RP0/CPU0-QSFP bay N
+# βββ Optics0/0/0/N (transceiver) β becomes device-level bay match
+#
+# The 8201-SYS device type should have device-level bays for:
+# Optics0/0/0/0β23 (400GE QSFP-DD)
+# HundredGigE0/0/0/24β35 (100GE QSFP28)
+# 0/FT0β4 (fans)
+# 0/PM0β1 (PSUs)
+# NO bay for 0/RP0/CPU0 β the RP is the device, not a pluggable module.
+
+- name: "Embedded RP / fixed-chassis system board"
+ match_type: serial_matches_device
+ pattern: ""
+ action: transparent
+ require_serial_match_parent: false
+ enabled: true
+ description: >
+ Fixed-form routers report the built-in RP as an ENTITY-MIB module whose
+ serial number equals the device's own serial. Marking it transparent hides
+ the RP row in the sync table while promoting its children (transceivers,
+ fans, PSUs) to device-level bay matching. No pattern is needed β detection
+ is purely serial-based.
diff --git a/contrib/module_bay_mappings.yaml b/contrib/module_bay_mappings.yaml
new file mode 100644
index 0000000000..27c5a86df0
--- /dev/null
+++ b/contrib/module_bay_mappings.yaml
@@ -0,0 +1,221 @@
+# Module Bay Mappings - Map LibreNMS inventory container names to NetBox module bay names
+#
+# These mappings replace heuristic matching between LibreNMS inventory and NetBox module bays.
+# Import via: LibreNMS Plugin β Module Bay Mappings β Import
+#
+# Fields:
+# librenms_name: LibreNMS entPhysicalName or container name (exact match or regex)
+# librenms_class: Optional entPhysicalClass filter (powerSupply, fan, module, etc.)
+# Leave empty for class-independent mappings
+# netbox_bay_name: Target NetBox module bay name (supports \1, \2 backreferences with regex)
+# is_regex: Set to true to treat librenms_name as a Python regex pattern
+# manufacturer: Optional NetBox Manufacturer name to scope this mapping to a single
+# vendor (matches the device's device_type.manufacturer). Leave empty
+# for vendor-independent mappings; manufacturer-scoped rows win over
+# global ones for matching devices.
+# description: Optional description
+#
+# Regex patterns use Python re.fullmatch() β the pattern must match the entire string.
+# Backreferences (\1, \2) in netbox_bay_name reference capture groups in the pattern.
+
+# βββ Regex Patterns ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# These patterns replace many individual exact-match entries.
+
+# Arcos/UfiSpace: sfpN β Transceiver N (covers sfp0 through sfp53+)
+- librenms_name: "^sfp(\\d+)$"
+ netbox_bay_name: "Transceiver \\1"
+ is_regex: true
+ description: "Arcos sfpN β Transceiver N"
+
+# Cisco X2: Port Container slot/port β X2 Port port
+- librenms_name: "^Port Container (\\d+)/(\\d+)$"
+ netbox_bay_name: "X2 Port \\2"
+ is_regex: true
+ description: "Cisco X2 Port Container β X2 Port N"
+
+# Cisco modules: Linecard/Supervisor(slot N) β Slot N
+- librenms_name: "^Linecard\\(slot (\\d+)\\)$"
+ librenms_class: "module"
+ netbox_bay_name: "Slot \\1"
+ is_regex: true
+ description: "Cisco Linecard slot β Slot N"
+- librenms_name: "^Supervisor\\(slot (\\d+)\\)$"
+ librenms_class: "module"
+ netbox_bay_name: "Slot \\1"
+ is_regex: true
+ description: "Cisco Supervisor slot β Slot N"
+
+# Generic power supplies and fans
+- librenms_name: "^Power Supply (\\d+)$"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PS\\1"
+ is_regex: true
+ description: "Power Supply N β PSN"
+- librenms_name: "^FanTray (\\d+)$"
+ librenms_class: "fan"
+ netbox_bay_name: "Fan Tray \\1"
+ is_regex: true
+ description: "FanTray N β Fan Tray N"
+
+# Nokia 7750 SR chassis fans and power modules
+- librenms_name: "^Chassis 1 Fan (\\d+)$"
+ librenms_class: "fan"
+ netbox_bay_name: "Fan \\1"
+ is_regex: true
+ description: "Nokia chassis fan β Fan N"
+- librenms_name: "^Chassis 1 PowShelf 1 PM (\\d+)$"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PM \\1"
+ is_regex: true
+ description: "Nokia power module β PM N"
+
+# Nokia MDA and XIOM sub-module bays
+# Bay names resolve from {module}/N templates: IOM Slot 1 pos=1 β bay {module}/1 = 1/1
+- librenms_name: "^MDA (\\d+)/(\\d+)$"
+ librenms_class: "mdaModule"
+ netbox_bay_name: "\\1/\\2"
+ is_regex: true
+ description: "Nokia MDA N/M β N/M (matches {module}/M on IOM)"
+- librenms_name: "^XIOM (\\d+)/x(\\d+)$"
+ librenms_class: "xioModule"
+ netbox_bay_name: "\\1/x\\2"
+ is_regex: true
+ description: "Nokia XIOM N/xM β N/xM (matches {module}/xM on IOM)"
+- librenms_name: "^MDA (\\d+)/x(\\d+)/(\\d+)$"
+ librenms_class: "mdaModule"
+ netbox_bay_name: "x\\2/\\3"
+ is_regex: true
+ description: "Nokia MDA in XIOM N/xP/Q β xP/Q (matches {module}/Q on XIOM)"
+
+# Nokia transceiver connector bays
+# LibreNMS ifName "1/1/c1" (slot/mda/connector) β NetBox bay "1/c1"
+# ({module} on MDA resolves to position, stripping the slot prefix)
+- librenms_name: "^(\\d+)/(\\d+)/(c\\d+)$"
+ librenms_class: "port"
+ netbox_bay_name: "\\2/\\3"
+ is_regex: true
+ description: "Nokia transceiver slot/mda/cN β mda-pos/cN"
+# LibreNMS ifName "2/x1/1/c2" (slot/xiom/mda/connector) β NetBox bay "1/c2"
+- librenms_name: "^(\\d+)/x(\\d+)/(\\d+)/(c\\d+)$"
+ librenms_class: "port"
+ netbox_bay_name: "\\3/\\4"
+ is_regex: true
+ description: "Nokia XIOM transceiver slot/xiom/mda/cN β mda-pos/cN"
+
+# Juniper MX transceiver bays
+# LibreNMS entPhysicalDescr format: "SFP+-10G-SR @ {fpc}/{pic}/{port}"
+# NetBox MPC-3D-16XGE-SFPP bay format: "Transceiver {pic}/{port}"
+- librenms_name: "^[^@]+ @ \\d+/(\\d+)/(\\d+)$"
+ librenms_class: "port"
+ netbox_bay_name: "Transceiver \\1/\\2"
+ is_regex: true
+ manufacturer: "Juniper"
+ description: "Juniper MX SFP+ @ fpc/pic/port β Transceiver pic/port (vendor-scoped to avoid clashes with other 3-segment naming schemes)"
+
+# βββ Exact Match Entries βββββββββββββββββββββββββββββββββββββββββββββββββββββ
+# These are for special cases where names don't follow a regex pattern.
+
+# Nokia CPM slots
+- librenms_name: "Slot A"
+ librenms_class: "cpmModule"
+ netbox_bay_name: "Slot A"
+ description: "Nokia CPM slot A"
+- librenms_name: "Slot B"
+ librenms_class: "cpmModule"
+ netbox_bay_name: "Slot B"
+ description: "Nokia CPM slot B"
+- librenms_name: "SR-7s 2 CPM mini"
+ librenms_class: "cpmCarrier"
+ netbox_bay_name: "CMA"
+ description: "Nokia CMA2-7s CPM carrier bracket"
+
+# Juniper fixed-form devices
+- librenms_name: "PSM 0"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PSU 0"
+ description: "Juniper PSU slot 0"
+- librenms_name: "PSM 1"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PSU 1"
+ description: "Juniper PSU slot 1"
+
+# Juniper chassis devices (PTX10008 etc.): PSM β PEM
+# Regex runs after exact matches, so PSM 0/1 β PSU 0/1 above takes priority for ACX
+- librenms_name: "^PSM (\\d+)$"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PEM \\1"
+ is_regex: true
+ description: "Juniper chassis PSM N β PEM N"
+
+# Juniper FPC container: "FPC: @ N/*/*" β FPC N
+- librenms_name: "^FPC: .+ @ (\\d+)/\\*/\\*$"
+ librenms_class: "container"
+ netbox_bay_name: "FPC \\1"
+ is_regex: true
+ description: "Juniper FPC container description β FPC N"
+
+# Juniper transceivers: " @ slot/pic/port" description β Transceiver slot/pic/port
+- librenms_name: "^.+ @ (\\d+/\\d+/\\d+)$"
+ librenms_class: "port"
+ netbox_bay_name: "Transceiver \\1"
+ is_regex: true
+ description: "Juniper transceiver description β Transceiver slot/pic/port"
+
+# Juniper fan trays: "Fan Tray N" β "Fan N" (ACX7100, etc.)
+# Runs after exact match, so "Fan Tray 0" β "Fan Tray" (ACX7024) still works
+- librenms_name: "^Fan Tray (\\d+)$"
+ librenms_class: "fan"
+ netbox_bay_name: "Fan \\1"
+ is_regex: true
+ description: "Juniper Fan Tray N β Fan N (ACX7100 etc.)"
+
+# Juniper MX304: PEM β PSU (MX304 bays are named PSU, not PEM)
+- librenms_name: "PEM 0"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PSU 0"
+ description: "Juniper MX304 PEM 0 β PSU 0"
+- librenms_name: "PEM 1"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PSU 1"
+ description: "Juniper MX304 PEM 1 β PSU 1"
+
+- librenms_name: "Fan Tray 0"
+ librenms_class: "fan"
+ netbox_bay_name: "Fan Tray"
+ description: "Juniper single fan tray (ACX7024)"
+
+# Juniper PTX10008: SIB β CB (Switch Interface Board β Component Board slot)
+- librenms_name: "SIB 0"
+ librenms_class: "container"
+ netbox_bay_name: "CB 0"
+ description: "Juniper PTX10008 SIB 0 β CB 0"
+- librenms_name: "SIB 1"
+ librenms_class: "container"
+ netbox_bay_name: "CB 1"
+ description: "Juniper PTX10008 SIB 1 β CB 1"
+- librenms_name: "SIB 2"
+ librenms_class: "container"
+ netbox_bay_name: "CB 2"
+ description: "Juniper PTX10008 SIB 2 β CB 2"
+- librenms_name: "SIB 3"
+ librenms_class: "container"
+ netbox_bay_name: "CB 3"
+ description: "Juniper PTX10008 SIB 3 β CB 3"
+- librenms_name: "SIB 4"
+ librenms_class: "container"
+ netbox_bay_name: "CB 4"
+ description: "Juniper PTX10008 SIB 4 β CB 4"
+- librenms_name: "SIB 5"
+ librenms_class: "container"
+ netbox_bay_name: "CB 5"
+ description: "Juniper PTX10008 SIB 5 β CB 5"
+
+# Arcos power supplies
+- librenms_name: "psu0"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PSU 0"
+ description: "Arcos PSU slot 0"
+- librenms_name: "psu1"
+ librenms_class: "powerSupply"
+ netbox_bay_name: "PSU 1"
+ description: "Arcos PSU slot 1"
diff --git a/contrib/module_type_mappings.yaml b/contrib/module_type_mappings.yaml
new file mode 100644
index 0000000000..c0346aba0e
--- /dev/null
+++ b/contrib/module_type_mappings.yaml
@@ -0,0 +1,339 @@
+# Module Type Mappings
+#
+# Maps LibreNMS inventory model names (entPhysicalModelName) to NetBox module types.
+# Import via: LibreNMS Plugin > Module Type Mappings > Import
+#
+# Fields:
+# librenms_model β Model name from LibreNMS SNMP inventory
+# manufacturer β Optional NetBox Manufacturer name; leave blank for vendor-agnostic
+# mappings, set when the same model string is reused across vendors.
+# netbox_module_type β NetBox ModuleType (matched by model name or ID)
+# description β Optional note
+#
+# These mappings are checked FIRST. If no mapping exists, the plugin falls back
+# to exact model name and part_number matching against NetBox module types.
+
+# βββ Cisco Catalyst 4900M ββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "WS-X4908-10GE"
+ netbox_module_type: "WS-X4908-10GE"
+ description: "Cisco 8-port 10G X2 line card"
+
+- librenms_model: "WS-X4992"
+ netbox_module_type: "WS-X4992"
+ description: "Cisco 48-port 10/100/1000 line card"
+
+- librenms_model: "PWR-C49M-1000AC"
+ netbox_module_type: "PWR-C49M-1000AC"
+ description: "Cisco 1000W AC power supply"
+
+- librenms_model: "CVR-X2-SFP"
+ netbox_module_type: "CVR-X2-SFP"
+ description: "Cisco X2-to-SFP converter"
+
+# βββ Juniper Backplane βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "710-017414"
+ netbox_module_type: "MX480-CHASSIS-BP"
+ description: "Juniper MX480 backplane (matched by part number)"
+
+- librenms_model: "CHAS-BP-MX480-S"
+ netbox_module_type: "MX480-CHASSIS-BP"
+ description: "Juniper MX480 backplane (matched by name)"
+
+# βββ Juniper FPC / Line Card Mappings ββββββββββββββββββββββββββββββββββββββββ
+# Juniper FPCs use 750-xxxxxx part numbers as entPhysicalModelName.
+
+- librenms_model: "750-018124"
+ netbox_module_type: "DPCE-R-4XGE-XFP"
+ description: "Juniper DPCE 4-port 10G XFP DPC"
+
+- librenms_model: "750-022765"
+ netbox_module_type: "DPCE-R-20GE-2XGE"
+ description: "Juniper DPCE 20x1G + 2x10G combo DPC"
+
+- librenms_model: "750-028467"
+ netbox_module_type: "MPC-3D-16XGE-SFPP"
+ description: "Juniper MPC 16-port 10G SFP+"
+
+- librenms_model: "750-056519"
+ netbox_module_type: "MPC7E-MRATE"
+ description: "Juniper MPC7E 12-port QSFP+/QSFP28 multirate"
+
+- librenms_model: "750-062581"
+ netbox_module_type: "MPC-3D-16XGE-SFPP"
+ description: "Juniper MPC 16-port 10G SFP+ (variant PN)"
+
+# βββ Juniper Power Supply Mappings βββββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "740-029970"
+ netbox_module_type: "PWR-MX480-2520-AC"
+ description: "Juniper MX480 2520W AC PSU"
+
+- librenms_model: "740-063046"
+ netbox_module_type: "PWR-MX480-2520-AC"
+ description: "Juniper MX480 2520W AC PSU (variant PN)"
+
+- librenms_model: "740-027760"
+ netbox_module_type: "PWR-MX960-4100-AC"
+ description: "Juniper MX960 4100W AC PSU"
+
+- librenms_model: "740-110419"
+ netbox_module_type: "JNP-PWR2200-AC"
+ description: "Juniper MX304 2200W AC PSU"
+
+# Removed: JPSU-1600W-1UACAFO β exact model match, no mapping needed
+
+# βββ Juniper Fan Tray Mappings βββββββββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "740-031521"
+ netbox_module_type: "FFANTRAY-MX960-HC"
+ description: "Juniper MX960 high-capacity fan tray"
+
+- librenms_model: "760-126744"
+ netbox_module_type: "JNP-FAN-2RU"
+ description: "Juniper MX304 2RU fan tray"
+
+# Removed: JNP7100-FAN1RU-AO β exact model match, no mapping needed
+
+# βββ Nokia 7750 SR-7s Module Mappings ββββββββββββββββββββββββββββββββββββββββ
+# Nokia 3HE part numbers are handled by NormalizationRule:
+# 1. Strip extra text (e.g. "3HE10550AARA01 NOK IPU3BFUEAA" β "3HE10550AARA01")
+# 2. Strip revision suffix (e.g. "3HE10550AARA01" β "3HE10550AA")
+# The normalized value matches the part_number field on NetBox ModuleTypes.
+# No explicit Nokia mappings are needed.
+
+# βββ Transceiver Mappings: Juniper Part Numbers βββββββββββββββββββββββββββββ
+# Juniper-qualified optics use 740-xxxxxx part numbers regardless of OEM vendor.
+
+- librenms_model: "740-013111"
+ netbox_module_type: "SFP-1G-T"
+ description: "Juniper SFP 1000BASE-T copper"
+
+- librenms_model: "740-021308"
+ netbox_module_type: "SFP-10G-SR"
+ description: "Juniper SFP+ 10G-SR"
+
+- librenms_model: "740-031850"
+ netbox_module_type: "SFP-1G-LX"
+ description: "Juniper SFP 1000BASE-LX 10km"
+
+- librenms_model: "740-031980"
+ netbox_module_type: "SFP-10G-SR"
+ description: "Juniper SFP+ 10G-SR"
+
+- librenms_model: "740-031981"
+ netbox_module_type: "SFP-10G-LR"
+ description: "Juniper SFP+ 10G-LR"
+
+- librenms_model: "740-047682"
+ netbox_module_type: "CFP-100G-LR4"
+ description: "Juniper CFP 100G-LR4"
+
+- librenms_model: "740-054050"
+ netbox_module_type: "QSFP-4X10G-LR"
+ description: "Juniper QSFP+ 4x10G-LR"
+
+- librenms_model: "740-054053"
+ netbox_module_type: "QSFP-4X10G-SR"
+ description: "Juniper QSFP+ 4x10G-SR"
+
+- librenms_model: "740-058732"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "Juniper QSFP28 100G-LR4"
+
+- librenms_model: "740-061405"
+ netbox_module_type: "QSFP-100G-SR4"
+ description: "Juniper QSFP28 100G-SR4"
+
+- librenms_model: "740-061409"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "Juniper QSFP28 100G-LR4"
+
+- librenms_model: "740-079871"
+ netbox_module_type: "QSFP-DD-2X100G-LR4"
+ description: "Juniper QSFP-DD 2x100G-LR4"
+
+- librenms_model: "740-082823"
+ netbox_module_type: "QSFP-DD-400G-LR8"
+ description: "Juniper QSFP-DD 400G-LR8"
+
+- librenms_model: "740-085349"
+ netbox_module_type: "QSFP-DD-400G-FR4"
+ description: "Juniper QSFP-DD 400G-FR4"
+
+- librenms_model: "740-085351"
+ netbox_module_type: "QSFP-DD-400G-DR4"
+ description: "Juniper QSFP-DD 400G-DR4"
+
+- librenms_model: "740-096176"
+ netbox_module_type: "QSFP-DD-400G-LR4"
+ description: "Juniper QSFP-DD 400G-LR4 (10km variant)"
+
+- librenms_model: "740-131169"
+ netbox_module_type: "QSFP-DD-400G-ZR-M"
+ description: "Juniper QSFP-DD 400G-ZR-M"
+
+- librenms_model: "740-151745"
+ netbox_module_type: "QSFP-DD-400G-ZR-M-HP"
+ description: "Juniper QSFP-DD 400G-ZR-M high-power"
+
+- librenms_model: "740-172665"
+ netbox_module_type: "QSFP-100G-ZR"
+ description: "Juniper QSFP28 100G-ZR"
+
+# βββ Transceiver Mappings: Finisar / II-VI / Coherent ββββββββββββββββββββββββ
+# These are BASE part numbers (after normalization strips customer suffixes).
+# See contrib/normalization_rules.yaml for the Finisar suffix-stripping rule.
+
+- librenms_model: "FTLC1154RDPL"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "Finisar QSFP28 100G-LR4"
+
+- librenms_model: "FTLC1151RDPL"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "Finisar QSFP28 100G-LR4 (variant)"
+
+- librenms_model: "FTLX1474D3BCL"
+ netbox_module_type: "SFP-10G-LR"
+ description: "Finisar SFP+ 10G-LR"
+
+- librenms_model: "FTCD3323R1PCL"
+ netbox_module_type: "QSFP-DD-400G-ZR-M"
+ description: "Finisar/II-VI QSFP-DD 400G-ZR-M coherent"
+
+- librenms_model: "FTLC9152RGPL"
+ netbox_module_type: "QSFP-100G-SWDM4"
+ description: "Finisar QSFP28 100G-SWDM4"
+
+# βββ Transceiver Mappings: Cisco / Cisco-branded OEM βββββββββββββββββββββββββ
+
+- librenms_model: "X2-10GB-LR"
+ netbox_module_type: "X2-10GB-LR"
+ description: "Cisco X2 10G-LR"
+
+- librenms_model: "X2-10GB-SR"
+ netbox_module_type: "X2-10GB-SR"
+ description: "Cisco X2 10G-SR"
+
+- librenms_model: "GLC-T"
+ netbox_module_type: "SFP-1G-T"
+ description: "Cisco SFP 1000BASE-T copper"
+
+- librenms_model: "GLC-TE"
+ netbox_module_type: "SFP-1G-T"
+ description: "Cisco SFP 1000BASE-T copper (extended temp)"
+
+- librenms_model: "SPP5200LR-C5"
+ netbox_module_type: "SFP-10G-LR"
+ description: "Cisco-branded Sumitomo SFP+ 10G-LR"
+
+- librenms_model: "SPP5310LR-C5"
+ netbox_module_type: "SFP-10G-LR"
+ description: "Cisco-branded Sumitomo SFP+ 10G-LR"
+
+- librenms_model: "SFBR-709SMZ-CS1"
+ netbox_module_type: "SFP-10G-SR"
+ description: "Cisco-branded Avago/Broadcom SFP+ 10G-SR"
+
+- librenms_model: "DP04QSDD-HE0"
+ netbox_module_type: "QSFP-DD-400G-ZR+"
+ description: "Cisco/Acacia QSFP-DD 400G-ZR+ coherent"
+
+- librenms_model: "QDD-400G-ZRP-S"
+ netbox_module_type: "QSFP-DD-400G-ZR+"
+ description: "Cisco QSFP-DD 400G-ZR+"
+
+- librenms_model: "QDD-400G-ZR4-S"
+ netbox_module_type: "QSFP-DD-400G-ZR"
+ description: "Cisco QSFP-DD 400G-ZR"
+
+# βββ Transceiver Mappings: Ciena βββββββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "180-3530-900"
+ netbox_module_type: "QSFP-DD-400G-ZR"
+ description: "Ciena WaveLogic 5 Nano QSFP-DD 400ZR"
+
+- librenms_model: "176-3360-900"
+ netbox_module_type: "QSFP-DD-400G-ZR-M"
+ description: "Ciena QSFP-DD 400G-ZR-M coherent"
+
+- librenms_model: "176-3530-901"
+ netbox_module_type: "QSFP-DD-400G-ZR"
+ description: "Ciena QSFP-DD 400G-ZR coherent"
+
+- librenms_model: "176-3590-900"
+ netbox_module_type: "QSFP-DD-400G-ZR-M"
+ description: "Ciena QSFP-DD 400G-ZR-M coherent"
+
+# βββ Transceiver Mappings: T1 Nexus βββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "T1-QDD-400G-LR4"
+ netbox_module_type: "QSFP-DD-400G-LR4"
+ description: "T1 Nexus QSFP-DD 400G-LR4"
+
+- librenms_model: "T1-QDD-400G-FR4"
+ netbox_module_type: "QSFP-DD-400G-FR4"
+ description: "T1 Nexus QSFP-DD 400G-FR4"
+
+- librenms_model: "T1-QSFP28-LR4"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "T1 Nexus QSFP28 100G-LR4"
+
+- librenms_model: "100G-LR4_A3"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "T1 Nexus QSFP28 100G-LR4 (rev A3)"
+
+# βββ Transceiver Mappings: Innolight ββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "T-DQ4CNT-NCN"
+ netbox_module_type: "QSFP-DD-400G-FR4"
+ description: "Innolight QSFP-DD 400G-FR4"
+
+# βββ Transceiver Mappings: FS.com ββββββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "Q28-PC03"
+ netbox_module_type: "QSFP28-100G-CU3M"
+ description: "FS.com QSFP28 100G passive DAC 3m"
+
+# βββ Transceiver Mappings: ProLabs βββββββββββββββββββββββββββββββββββββββββββ
+
+- librenms_model: "Q28LR431-10-IN"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "ProLabs QSFP28 100G-LR4 10km"
+
+# βββ Transceiver Mappings: Arcos Fixed-Port Part Numbers βββββββββββββββββββββ
+
+- librenms_model: "SP7041-TE"
+ netbox_module_type: "SFP-1G-T"
+ description: "SFP 1000BASE-T copper (Arcos platform)"
+
+# βββ Transceiver Mappings: LeGrand Innolight βββββββββββββββββββββββββββββββββ
+
+- librenms_model: "LGI-FTLC9152RGPL"
+ netbox_module_type: "QSFP-100G-SWDM4"
+ description: "LeGrand-branded Finisar QSFP28 100G-SWDM4"
+
+# βββ Transceiver Mappings: Additional Finisar Variants ββββββββββββββββββββββ
+# Some transceivers have customer-code suffixes that normalization may not handle.
+# Add direct mappings as fallback.
+
+- librenms_model: "FTLC1151RDPL-CN"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "Finisar QSFP28 100G-LR4 (CN customer code)"
+
+- librenms_model: "FTLC1154RDPL-A5"
+ netbox_module_type: "QSFP-100G-LR4"
+ description: "Finisar QSFP28 100G-LR4 (A5 customer code)"
+
+# βββ Unknown / Unidentified Part Numbers βββββββββββββββββββββββββββββββββββββ
+# When LibreNMS reports an opaque vendor part code (no recognisable prefix), the
+# safest pattern is a *manufacturer-scoped* mapping so the same code in a different
+# vendor's inventory doesn't accidentally collide. Leave the example below
+# commented-out as a template β uncomment and fill in your manufacturer/module type
+# only after confirming the part code on real hardware.
+#
+# - librenms_model: "1F3QAA"
+# manufacturer: "Finisar" # scope to the actual vendor that ships this code
+# netbox_module_type: "QSFP-100G-LR4"
+# description: "Vendor-specific QSFP28 100G mapping β verify before importing"
diff --git a/contrib/normalization_rules.yaml b/contrib/normalization_rules.yaml
new file mode 100644
index 0000000000..6938fad17a
--- /dev/null
+++ b/contrib/normalization_rules.yaml
@@ -0,0 +1,61 @@
+# Normalization Rules β Examples
+#
+# Regex-based string transformations applied before module type, device type,
+# or module bay matching. Rules run in priority order (lower first); each
+# rule's output feeds the next.
+#
+# Import via: LibreNMS β Normalization Rules β Import β YAML
+#
+# Fields:
+# scope β module_type, device_type, or module_bay
+# manufacturer β Optional manufacturer name (must exist in NetBox).
+# When set, the rule only fires for that manufacturer.
+# match_pattern β Python regex (re.sub pattern)
+# replacement β Replacement string (supports \1, \2 back-references)
+# priority β Lower values run first (default 100)
+# description β Optional note
+
+# ββ Nokia revision suffix stripping ββββββββββββββββββββββββββββββββββββββββββ
+# Nokia ENTITY-MIB reports module/transceiver models with 4-char revision
+# suffixes (e.g. 3HE16474AARA01). NetBox module types use the base part
+# number (3HE16474AA). This rule strips the suffix before matching.
+#
+# Captures the 10-char base (3HE + 5 digits + 2 quality-tier letters),
+# discards the 2-letter revision code + 2-digit build number.
+- scope: module_type
+ manufacturer: Nokia
+ match_pattern: "^(3HE[0-9]{5}[A-Z]{2})[A-Z]{2}\\d{2}$"
+ replacement: "\\1"
+ priority: 100
+ description: "Strip Nokia revision suffixes (e.g. RA01, RB01, RG01) from ENTITY-MIB model strings"
+
+# ββ Finisar / II-VI / Coherent suffix stripping βββββββββββββββββββββββββββββ
+# Finisar part numbers have customer-specific suffixes after a hyphen:
+# FTLC1154RDPL-A5 (original Finisar)
+# FTLC1154RDPL-C (Prolabs compatible)
+# FTLX1474D3BCL-C1 (Cisco-coded Finisar)
+# This rule strips everything after the last hyphen for FT... models.
+- scope: module_type
+ match_pattern: "^(FT[A-Z0-9]+)-[A-Z0-9]+$"
+ replacement: "\\1"
+ priority: 100
+ description: "Strip Finisar/II-VI customer suffixes (-A5, -C, -CN, -C1, etc.)"
+
+# ββ Prolabs LGI- prefix stripping βββββββββββββββββββββββββββββββββββββββββββ
+# Prolabs-compatible optics sometimes prepend LGI- to the OEM part number:
+# LGI-FTLC9152RGPL β FTLC9152RGPL
+- scope: module_type
+ match_pattern: "^LGI-(.+)$"
+ replacement: "\\1"
+ priority: 50
+ description: "Strip Prolabs LGI- prefix from OEM part numbers"
+
+# ββ Nokia transceiver model field cleanup ββββββββββββββββββββββββββββββββββββ
+# Nokia transceiver API sometimes returns model strings with trailing vendor
+# info: "3HE10550AARA01 NOK IPU3BFUEAA" β extract just the part number.
+- scope: module_type
+ manufacturer: Nokia
+ match_pattern: "^(3HE[A-Z0-9]+)\\s+.*$"
+ replacement: "\\1"
+ priority: 50
+ description: "Extract Nokia part number from transceiver model field (strip trailing vendor/oui info)"
diff --git a/contrib/platform_mappings.yaml b/contrib/platform_mappings.yaml
new file mode 100644
index 0000000000..f599a6150e
--- /dev/null
+++ b/contrib/platform_mappings.yaml
@@ -0,0 +1,36 @@
+# Platform Mappings β Examples
+#
+# Maps LibreNMS OS strings to NetBox Platforms.
+# The librenms_os value is matched exactly (case-insensitive) against the
+# LibreNMS `os` field returned for each device.
+#
+# Import via: LibreNMS β Platform Mappings β Import β YAML
+#
+# Fields:
+# librenms_os β LibreNMS OS identifier (e.g. "ios", "nxos", "junos")
+# netbox_platform β NetBox Platform name (must exist in NetBox)
+
+# ββ Cisco βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+- librenms_os: ios
+ netbox_platform: Cisco IOS
+
+- librenms_os: iosxe
+ netbox_platform: Cisco IOS-XE
+
+- librenms_os: iosxr
+ netbox_platform: Cisco IOS-XR
+
+- librenms_os: nxos
+ netbox_platform: Cisco NX-OS
+
+# ββ Juniper βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+- librenms_os: junos
+ netbox_platform: Juniper Junos
+
+# ββ Arista ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+- librenms_os: eos
+ netbox_platform: Arista EOS
+
+# ββ Linux / generic βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+- librenms_os: linux
+ netbox_platform: Linux
diff --git a/docs/README.md b/docs/README.md
index 4acfead73f..216d76f8e1 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -22,6 +22,17 @@ Search and import devices from LibreNMS into NetBox with comprehensive validatio
See the [Device Import Guide](librenms_import/overview.md) for detailed usage instructions.
+### Module / Inventory Sync
+
+Synchronize physical inventory data from LibreNMS (via ENTITY-MIB) to NetBox installed modules:
+
+* Compare LibreNMS inventory items (line-cards, transceivers, fans, PSUs) against NetBox module bays
+* Install, update, or skip individual modules directly from the sync table
+* Rich mapping system: ModuleTypeMapping, ModuleBayMapping (with regex support), NormalizationRules, InventoryIgnoreRules, CarrierAutoInstallRules
+* Virtual Chassis aware β inventory rows distributed across correct VC members
+
+See the [Module Sync Guide](usage_tips/module_sync.md) and [Mapping Rules Guide](usage_tips/mapping_rules.md) for details.
+
### Device Field Sync
Synchronize device information from LibreNMS to NetBox. The following device fields can be synchronized:
@@ -29,7 +40,7 @@ Synchronize device information from LibreNMS to NetBox. The following device fie
* Device Name (with naming preference support)
* Serial Number (including virtual chassis members)
* Device Type
-* Platform
+* Platform (via [Platform Mappings](usage_tips/mapping_rules.md#platform-mappings))
### Interface Sync
diff --git a/docs/development/testing.md b/docs/development/testing.md
index 1e1290a25f..d680b030a9 100644
--- a/docs/development/testing.md
+++ b/docs/development/testing.md
@@ -65,6 +65,8 @@ The test suite covers all major plugin functionality. Tests are organized by the
| [test_integration_sync.py](../../netbox_librenms_plugin/tests/test_integration_sync.py) | Integration testsβAPI client against local mock HTTP server |
| [test_integration_virtual_chassis.py](../../netbox_librenms_plugin/tests/test_integration_virtual_chassis.py) | Integration testsβVC detection, negative cache, multi-server cache isolation |
| [test_view_wiring.py](../../netbox_librenms_plugin/tests/test_view_wiring.py) | Smoke testsβview class MRO, mixin wiring, permission contracts, and template syntax |
+| [test_platform_mapping.py](../../netbox_librenms_plugin/tests/test_platform_mapping.py) | PlatformMapping modelβclean validation, YAML serialization, table/form/filterset, and find_matching_platform integration |
+| [test_module_replace.py](../../netbox_librenms_plugin/tests/test_module_replace.py) | Module replacementβmodule swapping, bay reindexing, and replacement validation |
Supporting files:
diff --git a/docs/feature_list.md b/docs/feature_list.md
index 0abaad7b8b..1c4cb5ff48 100644
--- a/docs/feature_list.md
+++ b/docs/feature_list.md
@@ -3,12 +3,33 @@
* Search and discover devices from LibreNMS using flexible filters
* Validate device prerequisites before import (Site, Device Type, Device Role)
* Import devices as physical Devices or Virtual Machines
-* Smart matching for Sites, Device Types, and Platforms
+* Smart matching for Sites, Device Types, and Platforms (via [mapping rules](usage_tips/mapping_rules.md))
+* Unified Platform creation modal β same experience on import page and device sync page
* Bulk import support
* Automatic Virtual Chassis creation for stackable devices
* Background job processing for large device sets
* Duplicate detection to prevent re-importing existing devices
+### [Module / Inventory Sync](usage_tips/module_sync.md)
+
+* Compare LibreNMS ENTITY-MIB inventory to NetBox module bays and installed modules
+* Install, update, or skip modules directly from the sync table
+* Match statuses: Matched, No Bay, No Type, Name Conflict, Not Installed
+* Inline modal to create missing ModuleBayTemplate, ModuleTypeMapping, or ModuleBayMapping without leaving the page
+* Carrier Auto-Install suggestion for chassis that omit holder modules from SNMP
+
+### [Mapping Rules](usage_tips/mapping_rules.md)
+
+* **Platform Mappings** β LibreNMS OS string to NetBox Platform
+* **Device Type Mappings** β LibreNMS hardware string to NetBox DeviceType
+* **Module Type Mappings** β LibreNMS entPhysicalModelName to NetBox ModuleType (with manufacturer scoping)
+* **Module Bay Mappings** β LibreNMS entPhysicalName to NetBox bay name (exact or regex, manufacturer scoping)
+* **Normalization Rules** β regex-based string transformation before matching (strips vendor suffixes etc.)
+* **Inventory Ignore Rules** β skip or make-transparent phantom EEPROM/IDPROM entities
+* **Carrier Auto-Install Rules** β suggest carrier module installation for vendors that omit them from SNMP
+* Bulk YAML import/export for all mapping types
+* Vendor-contributed example rules in `contrib/`
+
### Plugin Settings
* Multi-server LibreNMS configuration support
diff --git a/docs/img/Netbox-librenms-plugin-device-sync-fields.png b/docs/img/Netbox-librenms-plugin-device-sync-fields.png
new file mode 100644
index 0000000000..c30281afed
Binary files /dev/null and b/docs/img/Netbox-librenms-plugin-device-sync-fields.png differ
diff --git a/docs/img/Netbox-librenms-plugin-import-page.png b/docs/img/Netbox-librenms-plugin-import-page.png
new file mode 100644
index 0000000000..40822c8c5c
Binary files /dev/null and b/docs/img/Netbox-librenms-plugin-import-page.png differ
diff --git a/docs/img/Netbox-librenms-plugin-module-sync-tab.png b/docs/img/Netbox-librenms-plugin-module-sync-tab.png
new file mode 100644
index 0000000000..e76058a18b
Binary files /dev/null and b/docs/img/Netbox-librenms-plugin-module-sync-tab.png differ
diff --git a/docs/img/carrier_auto_install_rules/list.png b/docs/img/carrier_auto_install_rules/list.png
new file mode 100644
index 0000000000..939b364d9b
Binary files /dev/null and b/docs/img/carrier_auto_install_rules/list.png differ
diff --git a/docs/img/device_type_mappings/list.png b/docs/img/device_type_mappings/list.png
new file mode 100644
index 0000000000..6daa368904
Binary files /dev/null and b/docs/img/device_type_mappings/list.png differ
diff --git a/docs/img/inventory_ignore_rules/list.png b/docs/img/inventory_ignore_rules/list.png
new file mode 100644
index 0000000000..1d959be036
Binary files /dev/null and b/docs/img/inventory_ignore_rules/list.png differ
diff --git a/docs/img/module_bay_mappings/list.png b/docs/img/module_bay_mappings/list.png
new file mode 100644
index 0000000000..ac611cc84a
Binary files /dev/null and b/docs/img/module_bay_mappings/list.png differ
diff --git a/docs/img/module_type_mappings/add.png b/docs/img/module_type_mappings/add.png
new file mode 100644
index 0000000000..a2a98698ae
Binary files /dev/null and b/docs/img/module_type_mappings/add.png differ
diff --git a/docs/img/module_type_mappings/list.png b/docs/img/module_type_mappings/list.png
new file mode 100644
index 0000000000..0fb061a42c
Binary files /dev/null and b/docs/img/module_type_mappings/list.png differ
diff --git a/docs/img/normalization_rules/add.png b/docs/img/normalization_rules/add.png
new file mode 100644
index 0000000000..48167aa405
Binary files /dev/null and b/docs/img/normalization_rules/add.png differ
diff --git a/docs/img/normalization_rules/list.png b/docs/img/normalization_rules/list.png
new file mode 100644
index 0000000000..1e9ae0a80c
Binary files /dev/null and b/docs/img/normalization_rules/list.png differ
diff --git a/docs/img/platform_mappings/add.png b/docs/img/platform_mappings/add.png
new file mode 100644
index 0000000000..bb8140559c
Binary files /dev/null and b/docs/img/platform_mappings/add.png differ
diff --git a/docs/img/platform_mappings/list.png b/docs/img/platform_mappings/list.png
new file mode 100644
index 0000000000..21d59aa183
Binary files /dev/null and b/docs/img/platform_mappings/list.png differ
diff --git a/docs/librenms_import/validation.md b/docs/librenms_import/validation.md
index efd08257f3..4dd9158aad 100644
--- a/docs/librenms_import/validation.md
+++ b/docs/librenms_import/validation.md
@@ -22,15 +22,15 @@ Click the validation details button to review what's missing and select values f
### Import as Device
- **Site** (required) - Auto-matched from LibreNMS location
-- **Device Type** (required) - Auto-matched from LibreNMS hardware string
+- **Device Type** (required) - Auto-matched from LibreNMS hardware string, or via [Device Type Mapping](../usage_tips/mapping_rules.md#device-type-mappings)
- **Device Role** (required) - Must be selected manually
-- **Platform** (optional) - Auto-matched from LibreNMS OS
+- **Platform** (optional) - Auto-matched from LibreNMS OS via [Platform Mapping](../usage_tips/mapping_rules.md#platform-mappings). If no mapping exists and the platform is not found, a **Create Platform** button opens a modal to create a new NetBox Platform and mapping in one step.
- **Rack** (optional) - Available if Site has racks
### Import as Virtual Machine
- **Cluster** (required) - Must be selected manually
-- **Platform** (optional) - Auto-matched from LibreNMS OS
+- **Platform** (optional) - Auto-matched from LibreNMS OS via [Platform Mapping](../usage_tips/mapping_rules.md#platform-mappings). The same **Create Platform** modal is available if needed.
## Virtual Chassis Detection
diff --git a/docs/usage_tips/README.md b/docs/usage_tips/README.md
index 43527cf3f4..dde4d70062 100644
--- a/docs/usage_tips/README.md
+++ b/docs/usage_tips/README.md
@@ -11,11 +11,21 @@
- Create specific mappings for your network equipment types
- Pay attention to speed-based mappings for accurate interface types
-3. [Multi Server Configuration](multi_server_configuration.md)
+3. [Configure Platform Mappings](mapping_rules.md#platform-mappings) (optional)
+ - Map LibreNMS OS strings to NetBox Platform objects
+ - Ensures correct platform assignment during device import and sync
+
+4. [Multi Server Configuration](multi_server_configuration.md)
- Configure multiple LibreNMS instances in your NetBox configuration
- Switch between different LibreNMS servers through the web interface
- Maintain backward compatibility with single-server configurations
+## Module Sync
+
+[Module Sync Guide](module_sync.md) - Synchronize physical inventory from LibreNMS to NetBox modules
+
+[Mapping Rules Guide](mapping_rules.md) - Configure all mapping types (Platform, Device Type, Module Type, Module Bay, Normalization, Ignore, Carrier)
+
## Device Import
[Device Import Guide](../librenms_import/overview.md) - Import devices from LibreNMS into NetBox
diff --git a/docs/usage_tips/custom_field.md b/docs/usage_tips/custom_field.md
index 2571d1f143..0295a1e052 100644
--- a/docs/usage_tips/custom_field.md
+++ b/docs/usage_tips/custom_field.md
@@ -21,9 +21,9 @@ For the Interface object, the plugin will automatically populate the LibreNMS ID
## Manual Custom Field Setup
!!! note
- On 0.4.4+, rerun migrations first (`manage.py migrate`). If you need to recreate the field manually on current releases, use the JSON schema below. Pre-0.4.2 releases used an Integer field β do not use Integer for new entries.
+ On 0.4.4+, rerun migrations first (`manage.py migrate`). If you need to recreate the field manually on current releases, use the JSON schema below. Pre-0.4.4 releases used an Integer field β do not use Integer for new entries.
-Follow these steps to create the `librenms_id` custom field in NetBox:
+If the field was not created automatically (fallback): follow these steps to create the `librenms_id` custom field in NetBox:
1. **Navigate to Custom Fields:**
diff --git a/docs/usage_tips/mapping_rules.md b/docs/usage_tips/mapping_rules.md
new file mode 100644
index 0000000000..2079089f3d
--- /dev/null
+++ b/docs/usage_tips/mapping_rules.md
@@ -0,0 +1,229 @@
+# Mapping Rules
+
+Mapping rules are the configuration layer that connects LibreNMS identifiers to NetBox objects. Without them the plugin relies on exact-string matching; with them you can cover vendor naming variations, OS string aliases, model-number differences, and bay naming schemes across your entire fleet.
+
+All mapping types live under **Plugins > LibreNMS > Mappings** in the NetBox menu and support individual creation, editing, deletion, and bulk YAML import/export.
+
+---
+
+## Platform Mappings
+
+Maps a LibreNMS OS string (e.g. `junos`, `eos`, `ios`) to a NetBox Platform object.
+
+**Used by:**
+- Device Field Sync β platform sync on the device LibreNMS-Sync page
+- Device Import β platform auto-match when importing devices from LibreNMS
+
+Matching is case-insensitive. The plugin first tries an exact `Platform.name` match against existing NetBox Platforms; if none (or ambiguous), it falls back to `PlatformMapping`. If neither produces a unique result, the field is left empty.
+
+**YAML format:**
+
+```yaml
+- librenms_os: junos
+ netbox_platform: JunOS
+ description: "Juniper JunOS"
+```
+
+**Screenshot:**
+
+
+
+---
+
+## Device Type Mappings
+
+Maps a LibreNMS hardware string (e.g. `Juniper MX480 Internet Backbone Router`) to a NetBox DeviceType object.
+
+**Used by:**
+- Device Import β device type auto-match when importing
+
+Matching is case-insensitive and exact: the LibreNMS hardware string must equal the DeviceTypeMapping's `librenms_hardware` value (or, if no mapping matches, the DeviceType's `part_number` or `model`). The plugin does not perform partial or containment matching.
+
+**YAML format:**
+
+```yaml
+- librenms_hardware: "Juniper MX480 Internet Backbone Router"
+ netbox_device_type: MX480
+ description: "Juniper MX480"
+```
+
+**Screenshot:**
+
+
+
+---
+
+## Module Type Mappings
+
+Maps a LibreNMS `entPhysicalModelName` string (e.g. `SFP-1G-T`, `3HE16474AA`) to a NetBox ModuleType object.
+
+**Used by:**
+- Module Sync β determines which NetBox ModuleType to install or match
+
+Optionally scoped to a **Manufacturer**: when both a manufacturer-scoped and a global mapping exist for the same model string, the manufacturer-scoped row wins for devices from that vendor.
+
+**YAML format:**
+
+```yaml
+- librenms_model: SFP-1G-T
+ manufacturer: ""
+ netbox_module_type: SFP-1G-T
+ description: "1G copper SFP"
+
+- librenms_model: 3HE16474AA
+ manufacturer: Nokia
+ netbox_module_type: "3HE16474AA"
+ description: "Nokia CPM"
+```
+
+**Screenshot:**
+
+
+
+---
+
+## Module Bay Mappings
+
+Maps a LibreNMS `entPhysicalName` string (e.g. `Power Supply 1`) to a NetBox module bay name (e.g. `PSU1`).
+
+**Used by:**
+- Module Sync β resolves which NetBox module bay a LibreNMS inventory row corresponds to
+
+Supports:
+- **Exact match** β simple string substitution
+- **Regex match** β `librenms_name` is a Python regex; `netbox_bay_name` can use backreferences (`\1`, `\2`, β¦)
+- **Class filter** β optional `librenms_class` field (e.g. `powerSupply`, `fan`) to restrict the mapping to items of that ENTITY-MIB class
+- **Manufacturer scoping** β vendor-scoped mapping wins over a global one when both match
+
+**YAML format:**
+
+```yaml
+- librenms_name: "Power Supply 1"
+ librenms_class: powerSupply
+ netbox_bay_name: PSU1
+ is_regex: false
+ manufacturer: ""
+ description: ""
+
+- librenms_name: "^FPC(\\d+)$"
+ librenms_class: module
+ netbox_bay_name: "FPC\\1"
+ is_regex: true
+ manufacturer: Juniper
+ description: "FPC slot regex"
+```
+
+**Screenshot:**
+
+
+
+---
+
+## Normalization Rules
+
+Regex-based string transformations applied *before* ModuleTypeMapping or ModuleBayMapping lookups. Rules are chained in priority order (lower number = runs first); each rule transforms the output of the previous.
+
+**Scopes:**
+- `module_type` β normalizes `entPhysicalModelName` before ModuleTypeMapping lookup
+- `device_type` β normalizes LibreNMS hardware string before DeviceTypeMapping lookup
+- `module_bay` β normalizes `entPhysicalName` before ModuleBayMapping lookup
+
+Useful for stripping vendor revision suffixes so a single ModuleTypeMapping entry covers all hardware revisions.
+
+**Example:** strip Nokia revision suffixes
+
+```text
+scope: module_type
+manufacturer: Nokia
+match_pattern: ^(3HE\w{5}[A-Z]{2})[A-Z]{2}\d{2}$
+replacement: \1
+Result: 3HE16474AARA01 -> 3HE16474AA
+```
+
+**YAML format:**
+
+```yaml
+- scope: module_type
+ manufacturer: Nokia
+ match_pattern: "^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$"
+ replacement: "\\1"
+ priority: 10
+ description: "Strip Nokia revision suffix"
+```
+
+**Screenshot:**
+
+
+
+---
+
+## Inventory Ignore Rules
+
+Filter or reclassify specific ENTITY-MIB inventory items that would otherwise appear as spurious rows in the Module Sync table.
+
+**Actions:**
+- **Skip** β removes the matched item from the sync table entirely. Use for phantom EEPROM/IDPROM child entities that some vendors (Cisco IOS-XR) report with the same model and serial as the parent.
+- **Transparent** β hides the row but *promotes* its children to device-level bay matching. Use for fixed-chassis devices (e.g. Cisco 8201-SYS) where the system board entity is the device itself and its children (transceivers, fans, PSUs) should be matched directly against device-level bays.
+
+**Match types:**
+- `ends_with` / `starts_with` / `contains` β compare `entPhysicalName` (case-insensitive)
+- `regex` β Python regex against `entPhysicalName`
+- `serial_matches_device` β matches when `entPhysicalSerialNum` equals the NetBox device's own serial (no pattern required)
+
+The **Require serial match parent** option (recommended) adds a safety net: the name-based rule only fires when the item's serial number also matches an ancestor entity's serial.
+
+**YAML format:**
+
+```yaml
+- name: "Cisco IOS-XR IDPROM phantom"
+ match_type: ends_with
+ pattern: "IDPROM"
+ action: skip
+ require_serial_match_parent: true
+ enabled: true
+ description: "Remove IDPROM phantoms from Cisco IOS-XR inventory"
+```
+
+**Screenshot:**
+
+
+
+---
+
+## Carrier Auto-Install Rules
+
+Some chassis report child components (CPMs, MDAs, mezzanines) without the intermediate carrier/holder module that must first exist in NetBox for those children to have a bay to live in. A Carrier Auto-Install Rule tells the plugin: "for this manufacturer / device type, when you see an orphan component of this class and name pattern, suggest installing this ModuleType into the matching empty bay."
+
+Rules are **suggest-only** β no module is installed automatically. The user clicks an **Install Carrier** button that appears in the Module Sync table when a rule fires.
+
+**Fields:**
+- `manufacturer` β optional; scope to one vendor
+- `device_type_pattern` β optional regex (fullmatch) on DeviceType model name
+- `librenms_child_class` β exact `entPhysicalClass` of the orphan (e.g. `cpmModule`)
+- `librenms_child_name_pattern` β regex (fullmatch) on the orphan's `entPhysicalName`
+- `netbox_bay_name_pattern` β regex (fullmatch) on empty chassis-level bay name(s) to offer as install targets
+- `carrier_module_type` β the ModuleType to suggest installing
+
+**YAML format:**
+
+```yaml
+- manufacturer: Nokia
+ device_type_pattern: ".*SR-s.*"
+ librenms_child_class: cpmModule
+ librenms_child_name_pattern: "^Slot [AB]$"
+ netbox_bay_name_pattern: "^CMA$"
+ carrier_module_type: "Nokia CMA Carrier"
+ description: "Nokia 7750 SR-s CMA carrier suggestion"
+```
+
+**Screenshot:**
+
+
+
+---
+
+## Bulk Import / Export
+
+All mapping types support NetBox's standard bulk YAML import. Click the **Import** button next to any mapping list. You can also export existing mappings as YAML for backup or cross-environment migration using the **Export** action on the list page.
+
+Pre-built example rules for common vendor patterns are available in [`contrib/`](../../contrib/) in the plugin repository. Pull requests with additional examples or corrections are welcome.
diff --git a/docs/usage_tips/module_sync.md b/docs/usage_tips/module_sync.md
new file mode 100644
index 0000000000..7205a8e20c
--- /dev/null
+++ b/docs/usage_tips/module_sync.md
@@ -0,0 +1,56 @@
+# Module Sync
+
+The Module Sync tab lets you reconcile LibreNMS physical inventory (ENTITY-MIB, plus a transceiver API source for vendors that don't expose SFPs via ENTITY-MIB) with the installed modules recorded in NetBox. It appears as a **Modules** tab on the LibreNMS Sync page for every Device and Virtual Chassis.
+
+## How It Works
+
+When you open the Modules tab, the plugin fetches the ENTITY-MIB inventory tree from LibreNMS, merges in transceiver data from the LibreNMS transceiver API (used for vendors that don't expose SFPs via ENTITY-MIB), and compares each line-card, transceiver, fan, power supply, and other physical component against the NetBox module bays and installed modules for that device.
+
+Each row in the table shows:
+
+- **LibreNMS Name** β the `entPhysicalName` value reported by the device
+- **LibreNMS Model** β the `entPhysicalModelName` value (used for type matching)
+- **Serial** β `entPhysicalSerialNum`
+- **Status** β the result of matching (see below)
+- **NetBox Bay** β the module bay the component was matched to
+- **NetBox Module** β the installed module in that bay (if any)
+- **Actions** β buttons to install, update, or map the component
+
+## Match Statuses
+
+| Status | Meaning |
+|--------|---------|
+| **Matched** | LibreNMS component matches an installed NetBox module (same bay, same or mapped type) |
+| **No Bay** | A bay mapping was found but no matching module bay exists on this device |
+| **No Type** | The bay was found but no ModuleTypeMapping exists for the LibreNMS model |
+| **Name Conflict** | The resolved bay name conflicts with an existing module in another bay |
+| **Not Installed** | A matching bay exists but no module is installed yet |
+
+## Taking Action
+
+- **Install** β installs a new module into the matched bay using the mapped ModuleType
+- **Update** β updates the serial number or module type of an existing installed module
+- **Add Mapping** β opens the ModuleBayMapping or ModuleTypeMapping creation modal directly from the row, so you can resolve "No Bay" or "No Type" statuses without leaving the page
+- **Add Bay Template** β if the device type is missing a module bay template, this button creates it inline
+
+## Carrier Modules
+
+Some chassis (e.g. Nokia 7750 SR-s) report child components (CPMs, MDAs) without the intermediate carrier/holder module that must first be installed in NetBox before the children become visible. When a CarrierAutoInstallRule matches, a **Suggest Carrier** button appears β clicking it installs the carrier module into the appropriate empty bay, after which the children can be synced normally.
+
+## Virtual Chassis Support
+
+For Virtual Chassis devices, the Modules tab automatically distributes inventory rows across the correct VC member based on the component's position in the ENTITY-MIB tree. Each row shows the member hostname to make it clear which physical switch a component belongs to.
+
+## Screenshot
+
+
+
+## Related Configuration
+
+Before using Module Sync you will typically need to configure one or more of the following mapping types (see [Mapping Rules](mapping_rules.md)):
+
+- **ModuleTypeMapping** β maps LibreNMS model strings (e.g. `SFP-1G-T`) to NetBox ModuleType objects
+- **ModuleBayMapping** β maps LibreNMS bay names (e.g. `Power Supply 1`) to NetBox bay names (e.g. `PSU1`), with optional regex and manufacturer scoping
+- **NormalizationRule** β strips vendor suffixes from model strings before matching (e.g. `3HE16474AARA01` β `3HE16474AA`)
+- **InventoryIgnoreRule** β skips or makes transparent phantom EEPROM/IDPROM entities that some vendors (Cisco IOS-XR) report
+- **CarrierAutoInstallRule** β suggests installing carrier modules for vendors that omit them from SNMP reporting
diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py
index 6bcd0aef20..fe576d712a 100644
--- a/netbox_librenms_plugin/api/serializers.py
+++ b/netbox_librenms_plugin/api/serializers.py
@@ -1,6 +1,15 @@
from netbox.api.serializers import NetBoxModelSerializer
-from netbox_librenms_plugin.models import InterfaceTypeMapping
+from netbox_librenms_plugin.models import (
+ CarrierAutoInstallRule,
+ DeviceTypeMapping,
+ InterfaceTypeMapping,
+ InventoryIgnoreRule,
+ ModuleBayMapping,
+ ModuleTypeMapping,
+ NormalizationRule,
+ PlatformMapping,
+)
class InterfaceTypeMappingSerializer(NetBoxModelSerializer):
@@ -11,3 +20,112 @@ class Meta:
model = InterfaceTypeMapping
fields = ["id", "librenms_type", "librenms_speed", "netbox_type", "description"]
+
+
+class DeviceTypeMappingSerializer(NetBoxModelSerializer):
+ """Serialize DeviceTypeMapping model for REST API."""
+
+ class Meta:
+ """Meta options for DeviceTypeMappingSerializer."""
+
+ model = DeviceTypeMapping
+ fields = ["id", "librenms_hardware", "netbox_device_type", "description"]
+
+
+class ModuleTypeMappingSerializer(NetBoxModelSerializer):
+ """Serialize ModuleTypeMapping model for REST API."""
+
+ class Meta:
+ """Meta options for ModuleTypeMappingSerializer."""
+
+ model = ModuleTypeMapping
+ fields = ["id", "librenms_model", "manufacturer", "netbox_module_type", "description"]
+
+
+class ModuleBayMappingSerializer(NetBoxModelSerializer):
+ """Serialize ModuleBayMapping model for REST API."""
+
+ class Meta:
+ """Meta options for ModuleBayMappingSerializer."""
+
+ model = ModuleBayMapping
+ fields = [
+ "id",
+ "librenms_name",
+ "librenms_class",
+ "netbox_bay_name",
+ "is_regex",
+ "manufacturer",
+ "description",
+ ]
+
+
+class NormalizationRuleSerializer(NetBoxModelSerializer):
+ """Serialize NormalizationRule model for REST API."""
+
+ class Meta:
+ """Meta options for NormalizationRuleSerializer."""
+
+ model = NormalizationRule
+ fields = [
+ "id",
+ "scope",
+ "manufacturer",
+ "match_pattern",
+ "replacement",
+ "priority",
+ "description",
+ ]
+
+
+class InventoryIgnoreRuleSerializer(NetBoxModelSerializer):
+ """Serialize InventoryIgnoreRule model for REST API."""
+
+ class Meta:
+ """Meta options for InventoryIgnoreRuleSerializer."""
+
+ model = InventoryIgnoreRule
+ fields = [
+ "id",
+ "name",
+ "match_type",
+ "pattern",
+ "action",
+ "require_serial_match_parent",
+ "enabled",
+ "description",
+ ]
+
+
+class PlatformMappingSerializer(NetBoxModelSerializer):
+ """Serialize PlatformMapping model for REST API."""
+
+ class Meta:
+ """Meta options for PlatformMappingSerializer."""
+
+ model = PlatformMapping
+ fields = [
+ "id",
+ "librenms_os",
+ "netbox_platform",
+ "description",
+ ]
+
+
+class CarrierAutoInstallRuleSerializer(NetBoxModelSerializer):
+ """Serialize CarrierAutoInstallRule model for REST API."""
+
+ class Meta:
+ """Meta options for CarrierAutoInstallRuleSerializer."""
+
+ model = CarrierAutoInstallRule
+ fields = [
+ "id",
+ "manufacturer",
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ "carrier_module_type",
+ "description",
+ ]
diff --git a/netbox_librenms_plugin/api/urls.py b/netbox_librenms_plugin/api/urls.py
index 230aa078d0..de5f82b4aa 100644
--- a/netbox_librenms_plugin/api/urls.py
+++ b/netbox_librenms_plugin/api/urls.py
@@ -7,6 +7,13 @@
router = NetBoxRouter()
router.register("interface-type-mappings", views.InterfaceTypeMappingViewSet)
+router.register("device-type-mappings", views.DeviceTypeMappingViewSet)
+router.register("module-type-mappings", views.ModuleTypeMappingViewSet)
+router.register("module-bay-mappings", views.ModuleBayMappingViewSet)
+router.register("normalization-rules", views.NormalizationRuleViewSet)
+router.register("inventory-ignore-rules", views.InventoryIgnoreRuleViewSet)
+router.register("platform-mappings", views.PlatformMappingViewSet)
+router.register("carrier-auto-install-rules", views.CarrierAutoInstallRuleViewSet)
urlpatterns = [
path("jobs//sync-status/", views.sync_job_status, name="sync_job_status"),
diff --git a/netbox_librenms_plugin/api/views.py b/netbox_librenms_plugin/api/views.py
index a5d440b8fb..a73fb8069f 100644
--- a/netbox_librenms_plugin/api/views.py
+++ b/netbox_librenms_plugin/api/views.py
@@ -12,14 +12,43 @@
from rq.job import Job as RQJob
from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN
-from netbox_librenms_plugin.filters import InterfaceTypeMappingFilterSet
+from netbox_librenms_plugin.filters import (
+ CarrierAutoInstallRuleFilterSet,
+ DeviceTypeMappingFilterSet,
+ InterfaceTypeMappingFilterSet,
+ InventoryIgnoreRuleFilterSet,
+ ModuleBayMappingFilterSet,
+ ModuleTypeMappingFilterSet,
+ NormalizationRuleFilterSet,
+ PlatformMappingFilterSet,
+)
from netbox_librenms_plugin.jobs import FilterDevicesJob, ImportDevicesJob
-from netbox_librenms_plugin.models import InterfaceTypeMapping
-
-from .serializers import InterfaceTypeMappingSerializer
+from netbox_librenms_plugin.models import (
+ CarrierAutoInstallRule,
+ DeviceTypeMapping,
+ InterfaceTypeMapping,
+ InventoryIgnoreRule,
+ ModuleBayMapping,
+ ModuleTypeMapping,
+ NormalizationRule,
+ PlatformMapping,
+)
+
+from .serializers import (
+ CarrierAutoInstallRuleSerializer,
+ DeviceTypeMappingSerializer,
+ InterfaceTypeMappingSerializer,
+ InventoryIgnoreRuleSerializer,
+ ModuleBayMappingSerializer,
+ ModuleTypeMappingSerializer,
+ NormalizationRuleSerializer,
+ PlatformMappingSerializer,
+)
logger = logging.getLogger(__name__)
+_LIBRENMS_JOB_NAMES = (FilterDevicesJob.Meta.name, ImportDevicesJob.Meta.name)
+
class LibreNMSPluginPermission(BasePermission):
"""
@@ -45,6 +74,76 @@ class InterfaceTypeMappingViewSet(NetBoxModelViewSet):
serializer_class = InterfaceTypeMappingSerializer
+class DeviceTypeMappingViewSet(NetBoxModelViewSet):
+ """API viewset for DeviceTypeMapping CRUD operations."""
+
+ permission_classes = [LibreNMSPluginPermission]
+ filterset_class = DeviceTypeMappingFilterSet
+
+ queryset = DeviceTypeMapping.objects.select_related("netbox_device_type")
+ serializer_class = DeviceTypeMappingSerializer
+
+
+class ModuleTypeMappingViewSet(NetBoxModelViewSet):
+ """API viewset for ModuleTypeMapping CRUD operations."""
+
+ permission_classes = [LibreNMSPluginPermission]
+ filterset_class = ModuleTypeMappingFilterSet
+
+ queryset = ModuleTypeMapping.objects.select_related("netbox_module_type", "manufacturer")
+ serializer_class = ModuleTypeMappingSerializer
+
+
+class ModuleBayMappingViewSet(NetBoxModelViewSet):
+ """API viewset for ModuleBayMapping CRUD operations."""
+
+ permission_classes = [LibreNMSPluginPermission]
+ filterset_class = ModuleBayMappingFilterSet
+
+ queryset = ModuleBayMapping.objects.select_related("manufacturer")
+ serializer_class = ModuleBayMappingSerializer
+
+
+class NormalizationRuleViewSet(NetBoxModelViewSet):
+ """API viewset for NormalizationRule CRUD operations."""
+
+ permission_classes = [LibreNMSPluginPermission]
+ filterset_class = NormalizationRuleFilterSet
+
+ queryset = NormalizationRule.objects.select_related("manufacturer")
+ serializer_class = NormalizationRuleSerializer
+
+
+class InventoryIgnoreRuleViewSet(NetBoxModelViewSet):
+ """API viewset for InventoryIgnoreRule CRUD operations."""
+
+ permission_classes = [LibreNMSPluginPermission]
+ filterset_class = InventoryIgnoreRuleFilterSet
+
+ queryset = InventoryIgnoreRule.objects.all()
+ serializer_class = InventoryIgnoreRuleSerializer
+
+
+class PlatformMappingViewSet(NetBoxModelViewSet):
+ """API viewset for PlatformMapping CRUD operations."""
+
+ permission_classes = [LibreNMSPluginPermission]
+ filterset_class = PlatformMappingFilterSet
+
+ queryset = PlatformMapping.objects.select_related("netbox_platform")
+ serializer_class = PlatformMappingSerializer
+
+
+class CarrierAutoInstallRuleViewSet(NetBoxModelViewSet):
+ """API viewset for CarrierAutoInstallRule CRUD operations."""
+
+ permission_classes = [LibreNMSPluginPermission]
+ filterset_class = CarrierAutoInstallRuleFilterSet
+
+ queryset = CarrierAutoInstallRule.objects.select_related("manufacturer", "carrier_module_type")
+ serializer_class = CarrierAutoInstallRuleSerializer
+
+
@api_view(["POST"])
@permission_classes([LibreNMSPluginPermission])
def sync_job_status(request, job_pk):
@@ -63,20 +162,26 @@ def sync_job_status(request, job_pk):
Returns:
JsonResponse with updated status
"""
- _LIBRENMS_JOB_NAMES = (FilterDevicesJob.Meta.name, ImportDevicesJob.Meta.name)
try:
job = Job.objects.get(pk=job_pk, user=request.user, name__in=_LIBRENMS_JOB_NAMES)
except Job.DoesNotExist:
return JsonResponse({"error": "Job not found"}, status=404)
# Get RQ job status
- queue = get_queue("default")
try:
+ queue = get_queue("default")
rq_job = RQJob.fetch(str(job.job_id), connection=queue.connection)
rq_status = rq_job.get_status()
- # If RQ job is stopped or failed, update database
+ # If RQ job is stopped or failed, update database (but never overwrite terminal states)
if rq_job.is_stopped or rq_job.is_failed:
+ terminal_states = {
+ JobStatusChoices.STATUS_COMPLETED,
+ JobStatusChoices.STATUS_FAILED,
+ JobStatusChoices.STATUS_ERRORED,
+ }
+ if job.status in terminal_states:
+ return JsonResponse({"status": "no_change", "db_status": job.status, "rq_status": rq_status})
job.status = JobStatusChoices.STATUS_FAILED
if not job.completed:
job.completed = timezone.now()
diff --git a/netbox_librenms_plugin/constants.py b/netbox_librenms_plugin/constants.py
index 4e542f9d15..4b73a0fc4d 100644
--- a/netbox_librenms_plugin/constants.py
+++ b/netbox_librenms_plugin/constants.py
@@ -1,6 +1,30 @@
+import re
+
# Plugin permissions (from LibreNMSSettings model)
PERM_VIEW_PLUGIN = "netbox_librenms_plugin.view_librenmssettings"
PERM_CHANGE_PLUGIN = "netbox_librenms_plugin.change_librenmssettings"
# LibreNMS VLAN state values
LIBRENMS_VLAN_STATE_ACTIVE = 1
+
+# OOB management controller detection
+OOB_TYPE_PATTERN = re.compile(r"\b(idrac|ilo|ipmi|bmc|drac)", re.IGNORECASE)
+OOB_TYPES = ("idrac", "ilo", "ipmi", "bmc", "drac")
+
+
+def normalize_oob_type(os_str: str, hardware_str: str = "") -> str | None:
+ """
+ Extract and normalize the OOB controller type from LibreNMS os/hardware strings.
+
+ Returns the canonical lowercase token (one of OOB_TYPES) or None if no match.
+
+ Examples:
+ normalize_oob_type("drac9", "iDRAC9") β "drac"
+ normalize_oob_type("ilo", "") β "ilo"
+ normalize_oob_type("ubuntu", "") β None
+ """
+ for text in (os_str or "", hardware_str or ""):
+ m = OOB_TYPE_PATTERN.search(text)
+ if m:
+ return m.group(1).lower()
+ return None
diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py
index 9ec162a64c..1edb57773c 100644
--- a/netbox_librenms_plugin/filters.py
+++ b/netbox_librenms_plugin/filters.py
@@ -1,13 +1,145 @@
import django_filters
+from dcim.models import Manufacturer
-from .models import InterfaceTypeMapping
+from .models import (
+ CarrierAutoInstallRule,
+ DeviceTypeMapping,
+ InterfaceTypeMapping,
+ InventoryIgnoreRule,
+ ModuleBayMapping,
+ ModuleTypeMapping,
+ NormalizationRule,
+ PlatformMapping,
+)
class InterfaceTypeMappingFilterSet(django_filters.FilterSet):
"""Filter set for InterfaceTypeMapping model."""
+ # Explicit declarations ensure filter names match form field names.
+ # Dict-style fields = {"field": ["icontains"]} generates librenms_type__icontains,
+ # but the filter form submits librenms_type β causing silent filter failures.
+ librenms_type = django_filters.CharFilter(lookup_expr="icontains")
+ description = django_filters.CharFilter(lookup_expr="icontains")
+
class Meta:
"""Meta options for InterfaceTypeMappingFilterSet."""
model = InterfaceTypeMapping
fields = ["librenms_type", "librenms_speed", "netbox_type", "description"]
+
+
+class DeviceTypeMappingFilterSet(django_filters.FilterSet):
+ """Filter set for DeviceTypeMapping model."""
+
+ librenms_hardware = django_filters.CharFilter(lookup_expr="icontains")
+ description = django_filters.CharFilter(lookup_expr="icontains")
+
+ class Meta:
+ """Meta options for DeviceTypeMappingFilterSet."""
+
+ model = DeviceTypeMapping
+ fields = ["librenms_hardware", "description"]
+
+
+class ModuleTypeMappingFilterSet(django_filters.FilterSet):
+ """Filter set for ModuleTypeMapping model."""
+
+ librenms_model = django_filters.CharFilter(lookup_expr="icontains")
+ description = django_filters.CharFilter(lookup_expr="icontains")
+ manufacturer_id = django_filters.ModelChoiceFilter(
+ field_name="manufacturer",
+ queryset=Manufacturer.objects.all(),
+ label="Manufacturer",
+ )
+
+ class Meta:
+ """Meta options for ModuleTypeMappingFilterSet."""
+
+ model = ModuleTypeMapping
+ fields = ["librenms_model", "description", "manufacturer_id"]
+
+
+class ModuleBayMappingFilterSet(django_filters.FilterSet):
+ """Filter set for ModuleBayMapping model."""
+
+ librenms_name = django_filters.CharFilter(lookup_expr="icontains")
+ librenms_class = django_filters.CharFilter(lookup_expr="icontains")
+ netbox_bay_name = django_filters.CharFilter(lookup_expr="icontains")
+ manufacturer_id = django_filters.ModelChoiceFilter(
+ field_name="manufacturer",
+ queryset=Manufacturer.objects.all(),
+ label="Manufacturer",
+ )
+
+ class Meta:
+ """Meta options for ModuleBayMappingFilterSet."""
+
+ model = ModuleBayMapping
+ fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "manufacturer_id"]
+
+
+class NormalizationRuleFilterSet(django_filters.FilterSet):
+ """Filter set for NormalizationRule model."""
+
+ # DynamicModelChoiceField submits manufacturer_id; use a ModelChoiceFilter
+ # with field_name="manufacturer" so the filterset resolves it to the FK.
+ manufacturer_id = django_filters.ModelChoiceFilter(
+ field_name="manufacturer",
+ queryset=Manufacturer.objects.all(),
+ label="Manufacturer",
+ )
+
+ class Meta:
+ """Meta options for NormalizationRuleFilterSet."""
+
+ model = NormalizationRule
+ fields = ["scope", "manufacturer_id"]
+
+
+class InventoryIgnoreRuleFilterSet(django_filters.FilterSet):
+ """Filter set for InventoryIgnoreRule model."""
+
+ class Meta:
+ """Meta options for InventoryIgnoreRuleFilterSet."""
+
+ model = InventoryIgnoreRule
+ fields = ["match_type", "action", "enabled"]
+
+
+class PlatformMappingFilterSet(django_filters.FilterSet):
+ """Filter set for PlatformMapping model."""
+
+ librenms_os = django_filters.CharFilter(lookup_expr="icontains")
+ description = django_filters.CharFilter(lookup_expr="icontains")
+
+ class Meta:
+ """Meta options for PlatformMappingFilterSet."""
+
+ model = PlatformMapping
+ fields = ["librenms_os", "description"]
+
+
+class CarrierAutoInstallRuleFilterSet(django_filters.FilterSet):
+ """Filter set for CarrierAutoInstallRule model."""
+
+ manufacturer_id = django_filters.ModelChoiceFilter(
+ field_name="manufacturer",
+ queryset=Manufacturer.objects.all(),
+ label="Manufacturer",
+ )
+ librenms_child_class = django_filters.CharFilter(lookup_expr="icontains")
+ librenms_child_name_pattern = django_filters.CharFilter(lookup_expr="icontains")
+ netbox_bay_name_pattern = django_filters.CharFilter(lookup_expr="icontains")
+ description = django_filters.CharFilter(lookup_expr="icontains")
+
+ class Meta:
+ """Meta options for CarrierAutoInstallRuleFilterSet."""
+
+ model = CarrierAutoInstallRule
+ fields = [
+ "manufacturer_id",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ ]
diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py
index da1417b715..e144da69e7 100644
--- a/netbox_librenms_plugin/forms.py
+++ b/netbox_librenms_plugin/forms.py
@@ -1,8 +1,9 @@
# forms.py
import logging
+import re
from dcim.choices import InterfaceTypeChoices
-from dcim.models import Device, DeviceRole, DeviceType, Location, Rack, Site
+from dcim.models import Device, DeviceRole, DeviceType, Location, Manufacturer, ModuleType, Platform, Rack, Site
from django import forms
from django.db.models import Case, IntegerField, Value, When
from django.http import QueryDict
@@ -13,10 +14,25 @@
NetBoxModelImportForm,
)
from netbox.plugins import get_plugin_config
-from utilities.forms.fields import CSVChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.fields import (
+ CSVChoiceField,
+ CSVModelChoiceField,
+ DynamicModelChoiceField,
+ DynamicModelMultipleChoiceField,
+)
from virtualization.models import Cluster, VirtualMachine
-from .models import InterfaceTypeMapping, LibreNMSSettings
+from .models import (
+ CarrierAutoInstallRule,
+ DeviceTypeMapping,
+ InterfaceTypeMapping,
+ InventoryIgnoreRule,
+ LibreNMSSettings,
+ ModuleBayMapping,
+ ModuleTypeMapping,
+ NormalizationRule,
+ PlatformMapping,
+)
logger = logging.getLogger(__name__)
@@ -51,14 +67,27 @@ def _get_librenms_server_choices():
def _get_librenms_poller_group_choices():
"""
Helper function to get poller group choices from LibreNMS API.
- Shared between AddToLIbreSNMPV1V2 and AddToLIbreSNMPV3 forms.
+ Shared between AddToLibreSNMPV1V2 and AddToLibreSNMPV3 forms (via BaseSNMPForm).
+ Results are cached to avoid repeated API calls on every form instantiation.
"""
+ from django.core.cache import cache
+
from .librenms_api import LibreNMSAPI
choices = [("0", "Default (0)")]
try:
api = LibreNMSAPI()
+ except Exception:
+ logger.exception("Failed to initialize LibreNMSAPI; using default poller group choices")
+ return choices
+
+ cache_key = f"librenms_poller_group_choices_{api.server_key}"
+ cached_choices = cache.get(cache_key)
+ if cached_choices is not None:
+ return cached_choices
+
+ try:
success, poller_groups = api.get_poller_groups()
if success:
@@ -73,6 +102,8 @@ def _get_librenms_poller_group_choices():
else:
label = f"{group_name} ({group_id})"
choices.append((group_id, label))
+
+ cache.set(cache_key, choices, timeout=api.cache_timeout)
except Exception:
logger.exception("Failed to fetch LibreNMS poller groups; using default choices")
@@ -131,12 +162,24 @@ class ImportSettingsForm(NetBoxModelForm):
help_text="Remove domain suffix from device names during import",
)
+ auto_create_ipam_default = forms.BooleanField(
+ label="Auto-create IPAM entries",
+ required=False,
+ widget=forms.CheckboxInput(attrs={"class": "form-check-input"}),
+ help_text=(
+ "When enabled, missing IP addresses reported by LibreNMS are auto-created "
+ "as global /32 (IPv4) or /128 (IPv6) IPAM records during initial import, "
+ "OOB-attach, and promote-to-host actions. Existing IPAM records are always reused."
+ ),
+ )
+
class Meta:
model = LibreNMSSettings
fields = [
"vc_member_name_pattern",
"use_sysname_default",
"strip_domain_default",
+ "auto_create_ipam_default",
]
def clean_vc_member_name_pattern(self):
@@ -153,8 +196,6 @@ def clean_vc_member_name_pattern(self):
return pattern
# Check for valid placeholder names using regex
- import re
-
valid_placeholders = {"position", "serial"}
found_placeholders = set(re.findall(r"\{(\w+)\}", pattern))
invalid_placeholders = found_placeholders - valid_placeholders
@@ -261,11 +302,424 @@ class InterfaceTypeMappingFilterForm(NetBoxModelFilterSetForm):
model = InterfaceTypeMapping
-class AddToLIbreSNMPV1V2(forms.Form):
+class DeviceTypeMappingForm(NetBoxModelForm):
+ """Form for creating and editing device type mappings between LibreNMS and NetBox."""
+
+ netbox_device_type = DynamicModelChoiceField(
+ queryset=DeviceType.objects.all(),
+ label="NetBox Device Type",
+ )
+
+ class Meta:
+ """Meta options for DeviceTypeMappingForm."""
+
+ model = DeviceTypeMapping
+ fields = ["librenms_hardware", "netbox_device_type", "description"]
+
+
+class DeviceTypeMappingImportForm(NetBoxModelImportForm):
+ """Form for bulk importing device type mappings."""
+
+ manufacturer = CSVModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name="name",
+ required=False,
+ help_text="Manufacturer name β required when the model name is not unique across manufacturers",
+ )
+ netbox_device_type = CSVModelChoiceField(
+ queryset=DeviceType.objects.all(),
+ to_field_name="model",
+ help_text="NetBox device type model name",
+ )
+
+ class Meta:
+ """Meta options for DeviceTypeMappingImportForm."""
+
+ model = DeviceTypeMapping
+ fields = ["librenms_hardware", "manufacturer", "netbox_device_type", "description"]
+
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+ if data:
+ mfr_val = (data.get("manufacturer") or "").strip()
+ if mfr_val:
+ mfr_field = self.fields["manufacturer"]
+ params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val}
+ self.fields["netbox_device_type"].queryset = DeviceType.objects.filter(**params)
+
+
+class DeviceTypeMappingFilterForm(NetBoxModelFilterSetForm):
+ """Form for filtering device type mappings."""
+
+ librenms_hardware = forms.CharField(required=False, label="LibreNMS Hardware")
+ description = forms.CharField(
+ required=False,
+ label="Description",
+ help_text="Filter by description (partial match)",
+ )
+
+ model = DeviceTypeMapping
+
+
+class ModuleTypeMappingForm(NetBoxModelForm):
+ """Form for creating and editing module type mappings between LibreNMS and NetBox."""
+
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ help_text="Optional: scope this mapping to a single manufacturer.",
+ )
+ netbox_module_type = DynamicModelChoiceField(
+ queryset=ModuleType.objects.all(),
+ label="NetBox Module Type",
+ query_params={"manufacturer_id": "$manufacturer"},
+ )
+
+ class Meta:
+ """Meta options for ModuleTypeMappingForm."""
+
+ model = ModuleTypeMapping
+ fields = ["librenms_model", "manufacturer", "netbox_module_type", "description"]
+
+
+class ModuleTypeMappingImportForm(NetBoxModelImportForm):
+ """Form for bulk importing module type mappings."""
+
+ manufacturer = CSVModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name="name",
+ required=False,
+ help_text="Manufacturer name β required when the model name is not unique across manufacturers",
+ )
+ netbox_module_type = CSVModelChoiceField(
+ queryset=ModuleType.objects.all(),
+ to_field_name="model",
+ help_text="NetBox module type model name",
+ )
+
+ class Meta:
+ """Meta options for ModuleTypeMappingImportForm."""
+
+ model = ModuleTypeMapping
+ fields = ["librenms_model", "manufacturer", "netbox_module_type", "description"]
+
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+ if data:
+ mfr_val = (data.get("manufacturer") or "").strip()
+ if mfr_val:
+ mfr_field = self.fields["manufacturer"]
+ params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val}
+ self.fields["netbox_module_type"].queryset = ModuleType.objects.filter(**params)
+
+
+class ModuleTypeMappingFilterForm(NetBoxModelFilterSetForm):
+ """Form for filtering module type mappings."""
+
+ librenms_model = forms.CharField(required=False, label="LibreNMS Model")
+ manufacturer_id = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ label="Manufacturer",
+ )
+ description = forms.CharField(
+ required=False,
+ label="Description",
+ help_text="Filter by description (partial match)",
+ )
+
+ model = ModuleTypeMapping
+
+
+class ModuleBayMappingForm(NetBoxModelForm):
+ """Form for creating and editing module bay mappings between LibreNMS and NetBox."""
+
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ help_text="Optional: scope this mapping to a single manufacturer.",
+ )
+
+ class Meta:
+ """Meta options for ModuleBayMappingForm."""
+
+ model = ModuleBayMapping
+ fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "manufacturer", "description"]
+
+
+class ModuleBayMappingImportForm(NetBoxModelImportForm):
+ """Form for bulk importing module bay mappings."""
+
+ manufacturer = CSVModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name="name",
+ required=False,
+ help_text="Optional manufacturer name (leave blank for vendor-agnostic mappings).",
+ )
+
+ class Meta:
+ """Meta options for ModuleBayMappingImportForm."""
+
+ model = ModuleBayMapping
+ fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "manufacturer", "description"]
+
+
+class ModuleBayMappingFilterForm(NetBoxModelFilterSetForm):
+ """Form for filtering module bay mappings."""
+
+ librenms_name = forms.CharField(required=False, label="LibreNMS Name")
+ librenms_class = forms.CharField(required=False, label="LibreNMS Class")
+ netbox_bay_name = forms.CharField(required=False, label="NetBox Bay Name")
+ is_regex = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(choices=[("", "---------"), ("true", "Yes"), ("false", "No")]),
+ label="Regex",
+ )
+ manufacturer_id = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ label="Manufacturer",
+ )
+
+ model = ModuleBayMapping
+
+
+class CarrierAutoInstallRuleForm(NetBoxModelForm):
+ """Form for creating and editing carrier auto-install rules."""
+
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ help_text="Optional: scope this rule to a single manufacturer.",
+ )
+ carrier_module_type = DynamicModelChoiceField(
+ queryset=ModuleType.objects.all(),
+ label="Carrier Module Type",
+ query_params={"manufacturer_id": "$manufacturer"},
+ )
+
+ class Meta:
+ """Meta options for CarrierAutoInstallRuleForm."""
+
+ model = CarrierAutoInstallRule
+ fields = [
+ "manufacturer",
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ "carrier_module_type",
+ "description",
+ ]
+
+
+class CarrierAutoInstallRuleImportForm(NetBoxModelImportForm):
+ """Form for bulk importing carrier auto-install rules."""
+
+ manufacturer = CSVModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name="name",
+ required=False,
+ help_text="Manufacturer name (optional β leave blank for vendor-agnostic rules).",
+ )
+ carrier_module_type = CSVModelChoiceField(
+ queryset=ModuleType.objects.all(),
+ to_field_name="model",
+ help_text="NetBox ModuleType model name to install.",
+ )
+
+ class Meta:
+ """Meta options for CarrierAutoInstallRuleImportForm."""
+
+ model = CarrierAutoInstallRule
+ fields = [
+ "manufacturer",
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ "carrier_module_type",
+ "description",
+ ]
+
+ def __init__(self, data=None, *args, **kwargs):
+ super().__init__(data, *args, **kwargs)
+ if data:
+ mfr_val = (data.get("manufacturer") or "").strip()
+ if mfr_val:
+ mfr_field = self.fields["manufacturer"]
+ params = {f"manufacturer__{mfr_field.to_field_name}": mfr_val}
+ self.fields["carrier_module_type"].queryset = ModuleType.objects.filter(**params)
+
+
+class CarrierAutoInstallRuleFilterForm(NetBoxModelFilterSetForm):
+ """Form for filtering carrier auto-install rules."""
+
+ manufacturer_id = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ label="Manufacturer",
+ )
+ librenms_child_class = forms.CharField(required=False, label="LibreNMS Child Class")
+ librenms_child_name_pattern = forms.CharField(required=False, label="LibreNMS Child Name Pattern")
+ netbox_bay_name_pattern = forms.CharField(required=False, label="NetBox Bay Name Pattern")
+ description = forms.CharField(required=False, label="Description")
+
+ model = CarrierAutoInstallRule
+
+
+class NormalizationRuleForm(NetBoxModelForm):
+ """Form for creating and editing normalization rules."""
+
+ manufacturer = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ help_text="Optional: scope this rule to a specific manufacturer",
+ )
+
+ class Meta:
+ """Meta options for NormalizationRuleForm."""
+
+ model = NormalizationRule
+ fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"]
+
+
+class NormalizationRuleImportForm(NetBoxModelImportForm):
+ """Form for bulk importing normalization rules."""
+
+ scope = CSVChoiceField(
+ choices=NormalizationRule.SCOPE_CHOICES,
+ help_text="Scope: module_type, device_type, or module_bay",
+ )
+ manufacturer = CSVModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ to_field_name="name",
+ required=False,
+ help_text="Optional manufacturer name (must already exist in NetBox)",
+ )
+
+ class Meta:
+ """Meta options for NormalizationRuleImportForm."""
+
+ model = NormalizationRule
+ fields = ["scope", "manufacturer", "match_pattern", "replacement", "priority", "description"]
+
+
+class NormalizationRuleFilterForm(NetBoxModelFilterSetForm):
+ """Form for filtering normalization rules."""
+
+ scope = forms.ChoiceField(
+ required=False,
+ choices=[("", "---------")] + NormalizationRule.SCOPE_CHOICES,
+ label="Scope",
+ )
+ manufacturer_id = DynamicModelChoiceField(
+ queryset=Manufacturer.objects.all(),
+ required=False,
+ label="Manufacturer",
+ )
+
+ model = NormalizationRule
+
+
+class InventoryIgnoreRuleForm(NetBoxModelForm):
+ """Form for creating and editing inventory ignore rules."""
+
+ class Meta:
+ """Meta options for InventoryIgnoreRuleForm."""
+
+ model = InventoryIgnoreRule
+ fields = ["name", "match_type", "pattern", "action", "require_serial_match_parent", "enabled", "description"]
+
+
+class InventoryIgnoreRuleImportForm(NetBoxModelImportForm):
+ """Form for bulk importing inventory ignore rules."""
+
+ match_type = CSVChoiceField(
+ choices=InventoryIgnoreRule.MATCH_TYPE_CHOICES,
+ help_text="Match type: ends_with, starts_with, contains, regex, or serial_matches_device",
+ )
+ action = CSVChoiceField(
+ choices=InventoryIgnoreRule.ACTION_CHOICES,
+ help_text="Action: skip (remove from table) or transparent (hide row, promote children)",
+ )
+
+ class Meta:
+ """Meta options for InventoryIgnoreRuleImportForm."""
+
+ model = InventoryIgnoreRule
+ fields = ["name", "match_type", "pattern", "action", "require_serial_match_parent", "enabled", "description"]
+
+
+class InventoryIgnoreRuleFilterForm(NetBoxModelFilterSetForm):
+ """Form for filtering inventory ignore rules."""
+
+ match_type = forms.ChoiceField(
+ required=False,
+ choices=[("", "---------")] + InventoryIgnoreRule.MATCH_TYPE_CHOICES,
+ label="Match Type",
+ )
+ action = forms.ChoiceField(
+ required=False,
+ choices=[("", "---------")] + InventoryIgnoreRule.ACTION_CHOICES,
+ label="Action",
+ )
+ enabled = forms.NullBooleanField(
+ required=False,
+ widget=forms.Select(choices=[("", "---------"), ("true", "Yes"), ("false", "No")]),
+ label="Enabled",
+ )
+
+ model = InventoryIgnoreRule
+
+
+class PlatformMappingForm(NetBoxModelForm):
+ """Form for creating and editing platform mappings between LibreNMS and NetBox."""
+
+ netbox_platform = DynamicModelChoiceField(
+ queryset=Platform.objects.all(),
+ label="NetBox Platform",
+ )
+
+ class Meta:
+ """Meta options for PlatformMappingForm."""
+
+ model = PlatformMapping
+ fields = ["librenms_os", "netbox_platform", "description"]
+
+
+class PlatformMappingImportForm(NetBoxModelImportForm):
+ """Form for bulk importing platform mappings."""
+
+ netbox_platform = CSVModelChoiceField(
+ queryset=Platform.objects.all(),
+ to_field_name="name",
+ help_text="NetBox platform name",
+ )
+
+ class Meta:
+ """Meta options for PlatformMappingImportForm."""
+
+ model = PlatformMapping
+ fields = ["librenms_os", "netbox_platform", "description"]
+
+
+class PlatformMappingFilterForm(NetBoxModelFilterSetForm):
+ """Form for filtering platform mappings."""
+
+ librenms_os = forms.CharField(required=False, label="LibreNMS OS")
+ description = forms.CharField(
+ required=False,
+ label="Description",
+ help_text="Filter by description (partial match)",
+ )
+
+ model = PlatformMapping
+
+
+class BaseSNMPForm(forms.Form):
"""
- Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication.
- Collects hostname/IP and SNMP community string information.
- The SNMP version (v1 or v2c) is selected via a toggle button in the template.
+ Base form with fields shared by both SNMPv1/v2c and SNMPv3 LibreNMS device forms.
"""
hostname = forms.CharField(
@@ -273,7 +727,6 @@ class AddToLIbreSNMPV1V2(forms.Form):
max_length=255,
required=True,
)
- community = forms.CharField(label="SNMP Community", max_length=255, required=True)
port = forms.IntegerField(
label="SNMP Port",
required=False,
@@ -320,17 +773,31 @@ def __init__(self, *args, **kwargs):
self.fields["poller_group"].choices = _get_librenms_poller_group_choices()
-class AddToLIbreSNMPV3(forms.Form):
+class AddToLibreSNMPV1V2(BaseSNMPForm):
"""
- Form for adding devices to LibreNMS using SNMPv3 authentication.
- Provides comprehensive SNMPv3 configuration options including authentication and encryption settings.
+ Form for adding devices to LibreNMS using SNMPv1 or SNMPv2c authentication.
+ Collects hostname/IP and SNMP community string information.
+ The SNMP version (v1 or v2c) is selected via a toggle button in the template.
"""
- hostname = forms.CharField(
- label="Hostname/IP",
+ community = forms.CharField(
+ label="SNMP Community",
max_length=255,
required=True,
+ widget=forms.PasswordInput(),
)
+
+
+# Backwards-compatible alias β remove once all references are updated.
+AddToLIbreSNMPV1V2 = AddToLibreSNMPV1V2
+
+
+class AddToLibreSNMPV3(BaseSNMPForm):
+ """
+ Form for adding devices to LibreNMS using SNMPv3 authentication.
+ Provides comprehensive SNMPv3 configuration options including authentication and encryption settings.
+ """
+
snmp_version = forms.CharField(widget=forms.HiddenInput(), initial="v3")
authlevel = forms.ChoiceField(
label="Auth Level",
@@ -345,8 +812,8 @@ class AddToLIbreSNMPV3(forms.Form):
authpass = forms.CharField(
label="Auth Password",
max_length=255,
- required=True,
- widget=forms.PasswordInput(render_value=True),
+ required=False,
+ widget=forms.PasswordInput(),
)
authalgo = forms.ChoiceField(
label="Auth Algorithm",
@@ -358,63 +825,41 @@ class AddToLIbreSNMPV3(forms.Form):
("SHA-384", "SHA-384"),
("SHA-512", "SHA-512"),
],
- required=True,
+ required=False,
)
cryptopass = forms.CharField(
label="Crypto Password",
max_length=255,
- required=True,
- widget=forms.PasswordInput(render_value=True),
+ required=False,
+ widget=forms.PasswordInput(),
)
cryptoalgo = forms.ChoiceField(
label="Crypto Algorithm",
choices=[("AES", "AES"), ("DES", "DES")],
- required=True,
- )
- port = forms.IntegerField(
- label="SNMP Port",
required=False,
- help_text="Leave blank to use default SNMP port (161)",
- widget=forms.NumberInput(attrs={"placeholder": "161"}),
- )
- transport = forms.ChoiceField(
- label="Transport",
- choices=[
- ("udp", "UDP"),
- ("tcp", "TCP"),
- ("udp6", "UDP6"),
- ("tcp6", "TCP6"),
- ],
- required=False,
- initial="udp",
- )
- port_association_mode = forms.ChoiceField(
- label="Port Association Mode",
- choices=[
- ("ifIndex", "ifIndex"),
- ("ifName", "ifName"),
- ("ifDescr", "ifDescr"),
- ("ifAlias", "ifAlias"),
- ],
- required=False,
- initial="ifIndex",
- help_text="Method to identify ports",
- )
- poller_group = forms.ChoiceField(
- label="Poller Group",
- required=False,
- help_text="Poller group for distributed poller setup",
- )
- force_add = forms.BooleanField(
- label="Force Add",
- required=False,
- initial=False,
- help_text="Skip duplicate device and SNMP reachability checks (hostname must still be unique)",
)
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
- self.fields["poller_group"].choices = _get_librenms_poller_group_choices()
+ def clean(self):
+ cleaned = super().clean()
+ authlevel = cleaned.get("authlevel")
+
+ if authlevel in ("authNoPriv", "authPriv"):
+ if not cleaned.get("authpass"):
+ self.add_error("authpass", "Auth password is required for this auth level.")
+ if not cleaned.get("authalgo"):
+ self.add_error("authalgo", "Auth algorithm is required for this auth level.")
+
+ if authlevel == "authPriv":
+ if not cleaned.get("cryptopass"):
+ self.add_error("cryptopass", "Crypto password is required for authPriv.")
+ if not cleaned.get("cryptoalgo"):
+ self.add_error("cryptoalgo", "Crypto algorithm is required for authPriv.")
+
+ return cleaned
+
+
+# Backwards-compatible alias β remove once all references are updated.
+AddToLIbreSNMPV3 = AddToLibreSNMPV3
class DeviceStatusFilterForm(NetBoxModelFilterSetForm):
diff --git a/netbox_librenms_plugin/import_utils/__init__.py b/netbox_librenms_plugin/import_utils/__init__.py
index 81c24025ea..385c7ff402 100644
--- a/netbox_librenms_plugin/import_utils/__init__.py
+++ b/netbox_librenms_plugin/import_utils/__init__.py
@@ -33,6 +33,8 @@
import_single_device,
validate_device_for_import,
)
+from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip # noqa: F401
+from .collisions import detect_bulk_collisions # noqa: F401
from .filters import ( # noqa: F401
_apply_client_filters,
get_device_count_for_filters,
diff --git a/netbox_librenms_plugin/import_utils/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py
index 66e741b1d6..7804acd77b 100644
--- a/netbox_librenms_plugin/import_utils/bulk_import.py
+++ b/netbox_librenms_plugin/import_utils/bulk_import.py
@@ -200,6 +200,7 @@ def bulk_import_devices_shared(
"device_id": device_id,
"device": result["device"],
"message": result["message"],
+ "created_ips": result.get("created_ips", []),
}
)
# Log progress after each successful import
@@ -228,7 +229,7 @@ def bulk_import_devices_shared(
for m in vc_data.get("members", [])
)
if member_parts:
- fingerprint = hashlib.md5(",".join(member_parts).encode()).hexdigest()[:12]
+ fingerprint = hashlib.sha256(",".join(member_parts).encode()).hexdigest()[:12]
vc_domain = f"librenms-stack-{fingerprint}"
else:
vc_domain = f"librenms-{device_id}"
@@ -357,7 +358,11 @@ def _refresh_existing_device(validation: dict, libre_device: dict = None, server
if hasattr(refreshed, "role") and refreshed.role:
apply_role_to_validation(validation, refreshed.role, is_vm=bool(validation.get("import_as_vm")))
elif not validation.get("import_as_vm"):
- validation["device_role"] = {"found": False, "role": None}
+ validation["device_role"] = {
+ "found": False,
+ "role": None,
+ "available_roles": validation.get("device_role", {}).get("available_roles", []),
+ }
remove_validation_issue(validation, "role")
recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm")))
# Re-assert non-importable state: recalculate bases can_import on
@@ -374,7 +379,11 @@ def _refresh_existing_device(validation: dict, libre_device: dict = None, server
# Guard: VMs don't use device_role for readiness, so preserve any
# user-selected role rather than silently dropping it.
if not validation.get("import_as_vm"):
- validation["device_role"] = {"found": False, "role": None}
+ validation["device_role"] = {
+ "found": False,
+ "role": None,
+ "available_roles": validation.get("device_role", {}).get("available_roles", []),
+ }
recalculate_validation_status(validation, is_vm=bool(validation.get("import_as_vm")))
except Exception as e:
existing_id = getattr(existing, "pk", "unknown") if existing else "none"
@@ -446,8 +455,16 @@ def _lookup_in_model(m):
if not actual_is_vm and hasattr(new_device, "role") and new_device.role:
apply_role_to_validation(validation, new_device.role, is_vm=False)
elif not actual_is_vm:
- validation["device_role"] = {"found": False, "role": None}
+ validation["device_role"] = {
+ "found": False,
+ "role": None,
+ "available_roles": validation.get("device_role", {}).get("available_roles", []),
+ }
recalculate_validation_status(validation, is_vm=actual_is_vm)
+ # Re-assert non-importable: recalculate sets can_import from issues list,
+ # but a late-found existing match must never be import-ready.
+ validation["can_import"] = False
+ validation["is_ready"] = False
except Exception as e:
logger.error(f"Failed to check for newly imported device: {e}")
diff --git a/netbox_librenms_plugin/import_utils/cache.py b/netbox_librenms_plugin/import_utils/cache.py
index b6860a3fea..3728e6a9ba 100644
--- a/netbox_librenms_plugin/import_utils/cache.py
+++ b/netbox_librenms_plugin/import_utils/cache.py
@@ -179,7 +179,7 @@ def get_validated_device_cache_key(
Example:
>>> key = get_validated_device_cache_key('default', {'location': 'NYC'}, 123, True)
>>> key
- 'validated_device_default_e3b0c44298fc1c14_123_vc'
+ 'validated_device_default_154d56ec9289d49c_123_vc_sysname=True_strip=False'
"""
# Sort filters for a deterministic, cross-process stable hash; None values are excluded
# (consistent with get_cache_metadata_key).
@@ -190,7 +190,7 @@ def get_validated_device_cache_key(
)
-def get_import_device_cache_key(device_id: int | str, server_key: str = "default") -> str:
+def get_import_device_cache_key(device_id: int | str, server_key: str) -> str:
"""
Generate cache key for raw LibreNMS device data.
@@ -200,7 +200,7 @@ def get_import_device_cache_key(device_id: int | str, server_key: str = "default
Args:
device_id: LibreNMS device ID
- server_key: LibreNMS server identifier for multi-server setups. Defaults to "default" for backward compatibility.
+ server_key: LibreNMS server identifier for multi-server setups. Required.
Returns:
str: Cache key for the device data
diff --git a/netbox_librenms_plugin/import_utils/collisions.py b/netbox_librenms_plugin/import_utils/collisions.py
new file mode 100644
index 0000000000..8de5505cd6
--- /dev/null
+++ b/netbox_librenms_plugin/import_utils/collisions.py
@@ -0,0 +1,159 @@
+"""
+Bulk-import collision detection.
+
+When a user selects multiple LibreNMS devices for bulk import, two or more
+rows in the same batch may resolve to the *same* NetBox device β for
+example, one row would be linked as the host and another as the OOB
+controller, or two rows both want to promote to the same existing host.
+Importing all of them blindly would race for the same custom-field slot
+and produce inconsistent state.
+
+`detect_bulk_collisions` walks the per-row validation results and groups
+rows that target the same NetBox device pk so the bulk-confirm view can
+block the import and let the user adjust their selection.
+"""
+
+from __future__ import annotations
+
+
+def _candidate_pks_for_row(validation: dict) -> list[tuple[int, str, str]]:
+ """Return [(nb_device_pk, nb_device_name, role)] candidates for a single row.
+
+ ``role`` is a short human-readable label describing how this LibreNMS
+ row would touch the NetBox device:
+
+ * ``"host"`` β the device is the existing NetBox device this row would
+ update / link to (``existing_device``).
+ * ``"oob"`` β this LibreNMS row would be installed as the OOB
+ controller of the NetBox device (``oob_candidate.device``).
+ * ``"merge_host_named"`` / ``"merge_oob_named"`` β the row would feed
+ a Stage-2 merge of two NetBox devices.
+ * ``"promote_target"`` β the row should be promoted to host of an
+ existing NetBox device that currently only has an OOB link
+ (``promote_to_host``).
+
+ Duplicate ``(pk, role)`` tuples are de-duplicated; a single row may
+ legitimately surface the same pk under different roles.
+ """
+ candidates: list[tuple[int, str, str]] = []
+ seen: set[tuple[int, str]] = set()
+
+ def _add(pk, name, role):
+ try:
+ pk_int = int(pk)
+ except (TypeError, ValueError):
+ return
+ key = (pk_int, role)
+ if key in seen:
+ return
+ seen.add(key)
+ candidates.append((pk_int, str(name or f"device-{pk_int}"), role))
+
+ existing = validation.get("existing_device")
+ if existing is not None and getattr(existing, "pk", None) is not None:
+ _add(existing.pk, getattr(existing, "name", None), "host")
+
+ oob_candidate = validation.get("oob_candidate") or {}
+ oob_device = oob_candidate.get("device") if isinstance(oob_candidate, dict) else None
+ if oob_device is not None and getattr(oob_device, "pk", None) is not None:
+ _add(oob_device.pk, getattr(oob_device, "name", None), "oob")
+
+ merge = validation.get("merge_candidates") or {}
+ if isinstance(merge, dict):
+ for slot, role in (("host_named", "merge_host_named"), ("oob_named", "merge_oob_named")):
+ entry = merge.get(slot) or {}
+ pk = entry.get("pk") if isinstance(entry, dict) else None
+ name = entry.get("name") if isinstance(entry, dict) else None
+ if pk is not None:
+ _add(pk, name, role)
+
+ promote = validation.get("promote_to_host") or {}
+ if isinstance(promote, dict):
+ target = promote.get("existing_device")
+ if target is not None and getattr(target, "pk", None) is not None:
+ _add(target.pk, getattr(target, "name", None), "promote_target")
+ target_pk = promote.get("existing_device_pk")
+ if target_pk is not None:
+ _add(target_pk, promote.get("existing_device_name"), "promote_target")
+
+ return candidates
+
+
+def detect_bulk_collisions(devices: list[dict]) -> list[dict]:
+ """Find groups of LibreNMS rows in *devices* that resolve to the same NetBox device.
+
+ *devices* matches the list assembled by ``BulkImportConfirmView`` β
+ each item is a dict with at least ``device_id``, ``device_name`` and
+ ``validation`` keys.
+
+ Returns a list of collision groups (one per offending NetBox pk),
+ sorted by ``nb_device_pk`` for stable rendering. Each group:
+
+ .. code-block:: python
+
+ {
+ "nb_device_pk": int,
+ "nb_device_name": str,
+ "librenms_rows": [
+ {"device_id": int, "hostname": str, "role": str},
+ ...
+ ],
+ }
+
+ Rows are de-duplicated by ``device_id`` within a group (same LibreNMS
+ row touching the same NetBox device under multiple roles only appears
+ once, with all matching role labels joined by ``", "``).
+
+ A group is only emitted when at least two distinct LibreNMS
+ ``device_id`` values target the same NetBox pk.
+ """
+ by_nb_pk: dict[int, dict] = {}
+
+ for entry in devices or []:
+ validation = entry.get("validation") or {}
+ try:
+ libre_id = int(entry.get("device_id"))
+ except (TypeError, ValueError):
+ continue
+ hostname = entry.get("device_name") or f"device-{libre_id}"
+
+ for nb_pk, nb_name, role in _candidate_pks_for_row(validation):
+ bucket = by_nb_pk.setdefault(
+ nb_pk,
+ {"nb_device_pk": nb_pk, "nb_device_name": nb_name, "_rows": {}},
+ )
+ # Keep the first non-default name we see β rows often disagree
+ # on the cached display string, but the underlying pk is the
+ # source of truth.
+ if bucket["nb_device_name"].startswith("device-") and not nb_name.startswith("device-"):
+ bucket["nb_device_name"] = nb_name
+
+ row = bucket["_rows"].setdefault(
+ libre_id,
+ {"device_id": libre_id, "hostname": hostname, "roles": []},
+ )
+ if role not in row["roles"]:
+ row["roles"].append(role)
+
+ collisions: list[dict] = []
+ for nb_pk in sorted(by_nb_pk.keys()):
+ bucket = by_nb_pk[nb_pk]
+ rows = list(bucket["_rows"].values())
+ if len(rows) < 2:
+ continue
+ rows.sort(key=lambda r: r["device_id"])
+ collisions.append(
+ {
+ "nb_device_pk": bucket["nb_device_pk"],
+ "nb_device_name": bucket["nb_device_name"],
+ "librenms_rows": [
+ {
+ "device_id": r["device_id"],
+ "hostname": r["hostname"],
+ "role": ", ".join(r["roles"]),
+ }
+ for r in rows
+ ],
+ }
+ )
+ return collisions
diff --git a/netbox_librenms_plugin/import_utils/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py
index 152414998b..9b17b9841a 100644
--- a/netbox_librenms_plugin/import_utils/device_operations.py
+++ b/netbox_librenms_plugin/import_utils/device_operations.py
@@ -12,12 +12,15 @@
from ..librenms_api import LibreNMSAPI
from ..utils import (
+ coerce_librenms_id,
find_by_librenms_id,
find_matching_platform,
find_matching_site,
+ get_librenms_oob,
match_librenms_hardware_to_device_type,
set_librenms_device_id,
)
+from ..constants import OOB_TYPE_PATTERN, normalize_oob_type
from .cache import get_import_device_cache_key
from .virtual_chassis import (
_generate_vc_member_name,
@@ -29,6 +32,51 @@
logger = logging.getLogger(__name__)
+def _detect_oob_type_from_name(name):
+ """Return canonical OOB type token (idrac/ilo/ipmi/bmc/drac) found in *name*, or None."""
+ if not name:
+ return None
+ m = OOB_TYPE_PATTERN.search(name)
+ return m.group(1).lower() if m else None
+
+
+def _describe_existing_librenms_link(obj, server_key):
+ """
+ Describe the current LibreNMS linkage on a NetBox object.
+
+ Returns a dict ``{"host_id": int|None, "oob_id": int|None, "oob_type": str|None}``
+ summarising the ``librenms_id`` custom field for *server_key*. Always returns a
+ dict (with all-None values if nothing is linked) so callers can treat it as a
+ plain status object. Tolerates legacy bare-int and dict-form custom field values.
+ """
+ info = {"host_id": None, "oob_id": None, "oob_type": None}
+ cf_value = obj.cf.get("librenms_id") if hasattr(obj, "cf") else None
+ # Legacy bare-int OR string-digit (pre-JSON format).
+ if not isinstance(cf_value, dict):
+ info["host_id"] = coerce_librenms_id(cf_value)
+ return info
+ entry = cf_value.get(server_key)
+ # Per-server simple form: legacy bare-int or string-digit under the server key.
+ if not isinstance(entry, dict):
+ info["host_id"] = coerce_librenms_id(entry)
+ return info
+ # New dict-form: {"id": , "oob": {"id": , "type": , ...}}.
+ # Inner ids are always written as ints by set_librenms_device_id, so a strict
+ # int check is sufficient here β no string-digit fallback needed.
+ host_id = entry.get("id")
+ if isinstance(host_id, int) and not isinstance(host_id, bool):
+ info["host_id"] = host_id
+ oob = entry.get("oob")
+ if isinstance(oob, dict):
+ oob_id = oob.get("id")
+ if isinstance(oob_id, int) and not isinstance(oob_id, bool):
+ info["oob_id"] = oob_id
+ oob_type = oob.get("type")
+ if isinstance(oob_type, str) and oob_type:
+ info["oob_type"] = oob_type
+ return info
+
+
def _try_chassis_device_type_match(api, device_id):
"""
Attempt device type matching using chassis inventory fields.
@@ -206,10 +254,14 @@ def validate_device_for_import(
"resolved_name": None, # Final device name after applying user preferences
"existing_device": None,
"existing_match_type": None, # Track how existing device was matched
- "serial_action": None, # None, "link", "conflict", "update_serial", "hostname_differs"
+ "serial_action": None, # None, "link", "conflict", "update_serial", "hostname_differs", "oob_candidate", "promote_to_host", "merge_netbox_devices"
"serial_confirmed": False, # True when librenms_id match and serial matches
"serial_duplicate": False, # True when incoming serial is already on a different device
"librenms_id_needs_migration": False, # True when existing device has legacy bare-int ID
+ "oob_candidate": None, # dict {device, type, version, ip} when oob_candidate detected
+ "promote_to_host": None, # dict {existing_libre_id, existing_oob_type} when incoming should become the host
+ "existing_librenms_link": None, # dict {host_id, oob_id, oob_type} describing existing device's current LibreNMS linkage
+ "merge_candidates": None, # dict {host_named: {pk,name,librenms_link}, oob_named: {pk,name,librenms_link}} when two NB devices look like the same physical box
"name_matches": False, # True when existing device name matches LibreNMS sysName
"name_sync_available": False, # True when existing device name differs from sysName
"suggested_name": None, # sysName to suggest when name_sync_available is True
@@ -325,6 +377,15 @@ def validate_device_for_import(
result["existing_match_type"] = "librenms_id"
result["can_import"] = False
+ # If the match was via the OOB sub-key, mark it so the UI shows no duplicate warning.
+ _existing_oob = get_librenms_oob(existing_device, server_key=server_key)
+ if _existing_oob and _existing_oob.get("id") == librenms_id:
+ result["existing_match_type"] = "librenms_oob"
+
+ # Surface the full host/OOB linkage so the import table can render
+ # both halves of an existing pair with consistent paired styling.
+ result["existing_librenms_link"] = _describe_existing_librenms_link(existing_device, server_key)
+
# Detect legacy bare-integer or string-digit format so UI can offer a migration action.
# Direct access needed to detect legacy format for migration prompt:
# LibreNMSAPI.get_librenms_id() returns an int in both formats, so only the
@@ -450,18 +511,180 @@ def validate_device_for_import(
result["existing_match_type"] = "serial"
result["can_import"] = False
- if existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower():
- result["warnings"].append(
- f"Device with same serial and hostname exists as '{existing_by_serial.name}' "
- f"(not linked to LibreNMS)"
+ # Capture existing device's current LibreNMS linkage so the UI can
+ # present accurate state (NOT just "not linked to LibreNMS").
+ existing_link = _describe_existing_librenms_link(existing_by_serial, server_key)
+ result["existing_librenms_link"] = existing_link
+
+ # Compute both possible roles for the incoming LibreNMS device against
+ # the existing NetBox device, then pick a heuristic default. The UI
+ # offers a manual toggle whenever both roles are feasible so the user
+ # can override the heuristic (e.g. mark a "linux"-OS device as OOB or
+ # demote an apparent host into the OOB slot).
+ oob_type_from_libre = normalize_oob_type(
+ libre_device.get("os", ""),
+ libre_device.get("hardware", ""),
+ )
+ existing_oob = get_librenms_oob(existing_by_serial, server_key=server_key)
+
+ # Only treat this as a possible host/OOB chassis-pair situation when
+ # there is a real ambiguity: either the existing NetBox device's name
+ # differs from the incoming LibreNMS hostname (so they likely represent
+ # two sides of one physical box), or the existing device is already
+ # linked to a different LibreNMS id. When names match exactly and the
+ # existing has no link, the user almost certainly just wants to link.
+ names_match = bool(
+ existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower()
+ )
+ already_linked_elsewhere = bool(
+ existing_link
+ and existing_link["host_id"]
+ and existing_link["host_id"] != libre_device.get("device_id")
+ )
+ chassis_pair_likely = (not names_match) or already_linked_elsewhere
+
+ oob_possible = chassis_pair_likely and existing_oob is None
+ host_possible = chassis_pair_likely and bool(
+ existing_link
+ and existing_link["host_id"]
+ and existing_link["host_id"] != libre_device.get("device_id")
+ and not existing_link.get("oob_id")
+ )
+ existing_oob_from_name = _detect_oob_type_from_name(existing_by_serial.name)
+
+ if oob_possible:
+ inferred_oob_type = (
+ oob_type_from_libre
+ or _detect_oob_type_from_name(
+ libre_device.get("hostname") or libre_device.get("sysName") or ""
+ )
+ or "oob"
)
+ result["oob_candidate"] = {
+ "device": existing_by_serial,
+ "type": inferred_oob_type,
+ "version": libre_device.get("version") or None,
+ "ip": libre_device.get("ip") or None,
+ }
+ if host_possible:
+ result["promote_to_host"] = {
+ "existing_libre_id": existing_link["host_id"],
+ "existing_oob_type": existing_oob_from_name or "oob",
+ }
+
+ # Heuristic default: incoming-OS clearly OOB -> oob; otherwise if the
+ # existing device's NAME suggests it is the OOB and a host link can be
+ # demoted, offer promote; otherwise fall back to whichever is feasible.
+ if oob_type_from_libre and oob_possible:
+ result["serial_action"] = "oob_candidate"
+ elif host_possible and existing_oob_from_name:
+ result["serial_action"] = "promote_to_host"
+ elif oob_possible and host_possible:
+ # Both feasible but neither heuristic matches strongly --
+ # default to oob_candidate (least-destructive), let the user flip.
+ result["serial_action"] = "oob_candidate"
+ elif oob_possible:
+ result["serial_action"] = "oob_candidate"
+ elif host_possible:
+ result["serial_action"] = "promote_to_host"
+
+ # Surface the toggle availability for the template. When True the
+ # validation modal renders a Host/OOB radio next to the action button.
+ result["serial_role_choice_available"] = oob_possible and host_possible
+
+ if oob_type_from_libre and not oob_possible:
+ # OOB-typed incoming but existing already has an OOB linked --
+ # inform without blocking. No actionable button in this branch.
result["serial_action"] = "link"
- else:
result["warnings"].append(
- f"Device with same serial ({serial}) exists as '{existing_by_serial.name}' "
- f"but hostname differs (LibreNMS: '{hostname}'). Device may have been reinstalled."
+ f"Device '{existing_by_serial.name}' already has an OOB controller linked. "
+ f"Re-import will update the existing OOB entry."
+ )
+ elif not oob_possible and not host_possible:
+ # Neither role is feasible -- fall back to legacy hostname/serial
+ # warning behaviour so the user still sees a useful message.
+ if existing_by_serial.name and existing_by_serial.name.lower() == hostname.lower():
+ if existing_link and existing_link["host_id"]:
+ result["warnings"].append(
+ f"Device with same serial and hostname exists as '{existing_by_serial.name}' "
+ f"(currently linked to LibreNMS device #{existing_link['host_id']})"
+ )
+ else:
+ result["warnings"].append(
+ f"Device with same serial and hostname exists as '{existing_by_serial.name}' "
+ f"(not linked to LibreNMS)"
+ )
+ result["serial_action"] = "link"
+ else:
+ result["warnings"].append(
+ f"Device with same serial ({serial}) exists as '{existing_by_serial.name}' "
+ f"but hostname differs (LibreNMS: '{hostname}'). Device may have been reinstalled."
+ )
+ result["serial_action"] = "hostname_differs"
+
+ # Refresh local variable to reflect any VM-mode adjustments made during detection
+ # (e.g. existing VM found by hostname sets result["import_as_vm"] = True).
+ # Must happen before the merge-candidates block below so a VM hostname-match
+ # doesn't fall through to Device-only merge logic.
+ import_as_vm = result["import_as_vm"]
+
+ # Stage 2 β merge-candidates detection.
+ # When the hostname-matched device and the serial-matched device are
+ # DIFFERENT NetBox objects, the two probably represent the same
+ # physical box (host + OOB) imported as separate entries. Surface
+ # this as a merge action instead of silently picking one.
+ try:
+ _serial_for_pair = (libre_device.get("serial") or "").strip()
+ if (
+ _serial_for_pair
+ and _serial_for_pair != "-"
+ and not import_as_vm
+ and result.get("existing_device") is not None
+ and result.get("existing_match_type") in ("hostname", "serial")
+ ):
+ _hostname_match = (
+ result["existing_device"] if result.get("existing_match_type") == "hostname" else None
+ )
+ _serial_match = result["existing_device"] if result.get("existing_match_type") == "serial" else None
+ # Whichever path landed first, look the other one up too.
+ if _hostname_match and not _serial_match:
+ _serial_match = (
+ Device.objects.filter(serial=_serial_for_pair).exclude(pk=_hostname_match.pk).first()
+ )
+ elif _serial_match and not _hostname_match and hostname:
+ _hostname_match = (
+ Device.objects.filter(name__iexact=hostname).exclude(pk=_serial_match.pk).first()
+ )
+
+ if _hostname_match and _serial_match and _hostname_match.pk != _serial_match.pk:
+ host_link = _describe_existing_librenms_link(_hostname_match, server_key)
+ oob_link = _describe_existing_librenms_link(_serial_match, server_key)
+ # Conservative guard: at least one side must already be linked,
+ # otherwise this is more likely two unrelated devices that share
+ # serial data by coincidence (test fixtures, mis-keyed assets).
+ if (host_link and host_link["host_id"]) or (oob_link and oob_link["host_id"]):
+ result["serial_action"] = "merge_netbox_devices"
+ result["merge_candidates"] = {
+ "host_named": {
+ "pk": _hostname_match.pk,
+ "name": _hostname_match.name,
+ "librenms_link": host_link,
+ },
+ "oob_named": {
+ "pk": _serial_match.pk,
+ "name": _serial_match.name,
+ "librenms_link": oob_link,
+ },
+ }
+ result["can_import"] = False
+ result["warnings"].append(
+ f"Two NetBox devices appear to represent this physical box: "
+ f"'{_hostname_match.name}' (matches LibreNMS hostname) and "
+ f"'{_serial_match.name}' (matches chassis serial). "
+ f"Choose which one to keep and merge the other into it."
)
- result["serial_action"] = "hostname_differs"
+ except Exception: # pragma: no cover - defensive: never break validation
+ logger.exception("merge-candidate detection failed")
# Check by primary IP (weaker match, IP could be reassigned) - only for devices
if not result["existing_device"]:
@@ -477,16 +700,43 @@ def validate_device_for_import(
else None
)
if device:
- result["existing_device"] = device
- result["existing_match_type"] = "primary_ip"
- result["warnings"].append(
- f"IP address {primary_ip} already assigned to device '{device.name}' (not linked to LibreNMS)"
+ # Check if this is an OOB candidate via the IP path.
+ # The OOB controller's IP may already be the device's oob_ip, or the
+ # LibreNMS device may identify itself as an OOB type (iDRAC/iLO/etc.).
+ oob_type = normalize_oob_type(
+ libre_device.get("os", ""),
+ libre_device.get("hardware", ""),
)
- result["can_import"] = False
-
- # Refresh local variable to reflect any VM-mode adjustments made during detection
- # (e.g. existing VM found by hostname sets result["import_as_vm"] = True)
- import_as_vm = result["import_as_vm"]
+ is_oob_ip = device.oob_ip_id is not None and existing_ip.pk == device.oob_ip_id
+ has_primary_ip = bool(device.primary_ip4_id or device.primary_ip6_id)
+ if oob_type and (is_oob_ip or not has_primary_ip):
+ existing_oob = get_librenms_oob(device, server_key=server_key)
+ if existing_oob is None:
+ result["existing_device"] = device
+ result["existing_match_type"] = "primary_ip"
+ result["serial_action"] = "oob_candidate"
+ result["oob_candidate"] = {
+ "device": device,
+ "type": oob_type,
+ "version": libre_device.get("version") or None,
+ "ip": libre_device.get("ip") or None,
+ }
+ result["can_import"] = False
+ else:
+ result["existing_device"] = device
+ result["existing_match_type"] = "primary_ip"
+ result["warnings"].append(
+ f"IP address {primary_ip} already assigned to device '{device.name}' "
+ f"(OOB already linked)"
+ )
+ result["can_import"] = False
+ else:
+ result["existing_device"] = device
+ result["existing_match_type"] = "primary_ip"
+ result["warnings"].append(
+ f"IP address {primary_ip} already assigned to device '{device.name}' (not linked to LibreNMS)"
+ )
+ result["can_import"] = False
# Validate based on import type (Device or VM)
if import_as_vm:
@@ -617,7 +867,18 @@ def validate_device_for_import(
result["platform"] = platform_match
if not platform_match["found"] and os:
- result["warnings"].append(f"No matching platform found for OS: '{os}'")
+ if platform_match.get("match_type") == "ambiguous":
+ ambiguity_source = platform_match.get("ambiguity_source", "mapping")
+ if ambiguity_source == "platform":
+ result["warnings"].append(
+ f"Multiple Platforms match OS: '{os}' β resolve the duplicate Platform names in NetBox"
+ )
+ else:
+ result["warnings"].append(
+ f"Multiple platform mappings found for OS: '{os}' β resolve the conflict in Platform Mappings"
+ )
+ else:
+ result["warnings"].append(f"No matching platform found for OS: '{os}'")
# 6. Additional validations
if not hostname:
@@ -743,6 +1004,7 @@ def import_single_device(
"""
try:
api = LibreNMSAPI(server_key=server_key)
+ created_ips: list[str] = []
# Use pre-fetched device data if provided, otherwise fetch from API
if libre_device is None:
@@ -883,6 +1145,24 @@ def import_single_device(
device.full_clean()
device.save()
+ # Pre-create the LibreNMS-known IP in IPAM (global /32 or /128)
+ # so the user can later attach it to an interface and assign as
+ # primary_ip4/6. We do not auto-set primary_ip4 here because
+ # NetBox's Device.clean() requires the IP be assigned to one of
+ # the device's interfaces, which doesn't exist on a fresh import.
+ primary_ip = libre_device.get("ip")
+ if primary_ip:
+ from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip
+
+ _opts = sync_options or {}
+ if "auto_create_ipam" in _opts:
+ _auto_create = bool(_opts.get("auto_create_ipam"))
+ else:
+ _auto_create = auto_create_ipam_enabled()
+ _ip, was_created = get_or_create_global_ip(primary_ip, auto_create=_auto_create)
+ if was_created and _ip is not None:
+ created_ips.append(str(_ip.address.ip))
+
# Sync additional data based on options
sync_options = sync_options or {}
synced = {"interfaces": 0, "cables": 0, "ip_addresses": 0}
@@ -912,6 +1192,7 @@ def import_single_device(
"message": f"Successfully imported device: {device.name}",
"error": None,
"synced": synced,
+ "created_ips": created_ips,
}
except Exception as e:
diff --git a/netbox_librenms_plugin/import_utils/ip_helpers.py b/netbox_librenms_plugin/import_utils/ip_helpers.py
new file mode 100644
index 0000000000..eec404813b
--- /dev/null
+++ b/netbox_librenms_plugin/import_utils/ip_helpers.py
@@ -0,0 +1,111 @@
+"""
+Helpers for ensuring LibreNMS-known IP addresses exist in NetBox IPAM.
+
+When the plugin links a LibreNMS device to a NetBox device (during initial
+import, OOB attach, or promote-to-host), the IP that LibreNMS reaches the
+device on may not yet exist in NetBox. To let users later assign that IP as
+``primary_ip4`` / ``primary_ip6`` / ``oob_ip``, we first need an
+``IPAddress`` record. This module centralises that logic so all import
+paths behave the same way.
+
+The helper never overwrites an existing IPAM record: if an ``IPAddress``
+already exists for the host (matched via ``net_host`` so any prefix length
+is acceptable), it is returned as-is. Only when no record exists is a new
+``/32`` (IPv4) or ``/128`` (IPv6) entry created in the global scope.
+"""
+
+from __future__ import annotations
+
+import logging
+from ipaddress import ip_address as _ipaddr_parse
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING: # pragma: no cover - import only for type hints
+ from ipam.models import IPAddress
+
+logger = logging.getLogger(__name__)
+
+
+def auto_create_ipam_enabled() -> bool:
+ """Return the value of the ``auto_create_ipam_default`` plugin setting.
+
+ Defaults to ``False`` if the settings row does not exist or the field is
+ missing (e.g. during a migration). All callers should consult this before
+ asking ``get_or_create_global_ip(..., auto_create=True)`` so the user's
+ opt-in choice on the plugin settings page is honoured.
+ """
+ try:
+ from netbox_librenms_plugin.models import LibreNMSSettings
+
+ settings = LibreNMSSettings.objects.first()
+ return bool(getattr(settings, "auto_create_ipam_default", False)) if settings else False
+ except Exception: # pragma: no cover - defensive (migrations / startup)
+ return False
+
+
+def get_or_create_global_ip(ip_str: str | None, *, auto_create: bool = True) -> "tuple[IPAddress | None, bool]":
+ """Return ``(ipam_record, created)`` for ``ip_str``.
+
+ Creates a ``/32`` (IPv4) or ``/128`` (IPv6) global-scope record if no
+ matching ``IPAddress`` exists (matched via ``net_host`` so any prefix
+ length is acceptable). The returned ``IPAddress`` is unassigned (no
+ ``assigned_object``) and has no VRF. Callers may attach it to an
+ interface or assign it as ``oob_ip`` afterwards.
+
+ The ``created`` flag is ``True`` only when a new IPAM record was
+ inserted by this call, so callers can surface a user-visible toast
+ only on creation (and stay silent when reusing an existing record).
+
+ When ``auto_create`` is ``False``, no new record is ever inserted:
+ only existing records are returned. This lets callers honour the
+ ``auto_create_ipam_default`` plugin setting without having to
+ duplicate the lookup logic.
+
+ Returns ``(None, False)`` if ``ip_str`` is empty, malformed, if
+ ``auto_create=False`` and no record exists, or if creation fails
+ (the failure is logged but never raised, so callers can treat this
+ as best-effort).
+ """
+ if not ip_str:
+ return None, False
+ ip_str = ip_str.strip()
+ if not ip_str:
+ return None, False
+
+ try:
+ parsed = _ipaddr_parse(ip_str)
+ except ValueError:
+ logger.debug("get_or_create_global_ip: invalid IP %r", ip_str)
+ return None, False
+
+ from django.db import IntegrityError
+
+ from ipam.models import IPAddress
+
+ existing = IPAddress.objects.filter(address__net_host=ip_str, vrf__isnull=True).first()
+ if existing is not None:
+ return existing, False
+
+ if not auto_create:
+ return None, False
+
+ mask = "/128" if parsed.version == 6 else "/32"
+ try:
+ return IPAddress.objects.create(address=f"{ip_str}{mask}", status="active"), True
+ except IntegrityError:
+ # Concurrent create won the race; re-query the global record and return it.
+ existing = IPAddress.objects.filter(address__net_host=ip_str, vrf__isnull=True).first()
+ if existing is not None:
+ return existing, False
+ logger.warning(
+ "get_or_create_global_ip: IntegrityError but no global record found for %s",
+ ip_str,
+ )
+ return None, False
+ except Exception: # pragma: no cover - defensive (validation etc.)
+ logger.warning(
+ "get_or_create_global_ip: failed to auto-create %s",
+ ip_str,
+ exc_info=True,
+ )
+ return None, False
diff --git a/netbox_librenms_plugin/import_utils/virtual_chassis.py b/netbox_librenms_plugin/import_utils/virtual_chassis.py
index a1b68ef41f..52b475b21e 100644
--- a/netbox_librenms_plugin/import_utils/virtual_chassis.py
+++ b/netbox_librenms_plugin/import_utils/virtual_chassis.py
@@ -216,6 +216,9 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d
# indexing. Some vendors use 0-based positions (0,1,2,3,4) instead of the
# RFC 2737 standard 1-based (1,2,3,4,5). If any raw position is 0, shift
# all valid positions up by 1 so the resulting set is always 1-based.
+ # Exception: if *every* position is 0 the data is invalid (all members
+ # would collide on the same slot) β skip the shift and fall through to
+ # the per-member idx+1 fallback below.
raw_positions = []
for chassis in chassis_items:
raw = chassis.get("entPhysicalParentRelPos")
@@ -225,7 +228,7 @@ def detect_virtual_chassis_from_inventory(api: LibreNMSAPI, device_id: int) -> d
raw_positions.append(None)
valid_positions = [p for p in raw_positions if p is not None]
- zero_based = bool(valid_positions) and min(valid_positions) == 0
+ zero_based = bool(valid_positions) and min(valid_positions) == 0 and max(valid_positions) > 0
# Identify the master member by matching the LibreNMS device serial
# against the ENTITY-MIB serials. The device-level serial reported by
@@ -592,7 +595,6 @@ def create_virtual_chassis_with_members(
)
members_created += 1
- # Validate member count
# Validate member count β exclude master-slot entries with blank serials
expected_members = len(
[
diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py
index a630b55632..0eef4224eb 100644
--- a/netbox_librenms_plugin/import_utils/vm_operations.py
+++ b/netbox_librenms_plugin/import_utils/vm_operations.py
@@ -22,6 +22,7 @@ def create_vm_from_librenms(
use_sysname: bool = True,
strip_domain: bool = False,
role=None,
+ auto_create_ipam: bool | None = None,
):
"""
Create a NetBox VirtualMachine from LibreNMS device data.
@@ -84,6 +85,22 @@ def create_vm_from_librenms(
set_librenms_device_id(vm, librenms_device_id, server_key)
vm.save()
+ # Pre-create the LibreNMS-known IP in IPAM (global /32 or /128) so
+ # the user can later attach it to a VM interface and assign it as
+ # primary_ip4/6. We do not auto-set primary_ip4 here because
+ # VirtualMachine.clean() requires the IP be assigned to one of the
+ # VM's interfaces, which doesn't exist on a fresh import.
+ primary_ip = libre_device.get("ip")
+ if primary_ip:
+ from .ip_helpers import auto_create_ipam_enabled, get_or_create_global_ip
+
+ _auto_create = auto_create_ipam_enabled() if auto_create_ipam is None else bool(auto_create_ipam)
+ _ip, was_created = get_or_create_global_ip(primary_ip, auto_create=_auto_create)
+ if was_created and _ip is not None:
+ # Stash for caller to surface via Django messages (best-effort;
+ # callers that don't read this attribute simply skip the toast).
+ vm._librenms_created_ips = [str(_ip.address.ip)]
+
logger.info(f"Created VM {vm.name} (ID: {vm.pk}) from LibreNMS device {libre_device['device_id']}")
return vm
@@ -175,6 +192,7 @@ def bulk_import_vms(
# 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
+ auto_create_ipam_opt = sync_options.get("auto_create_ipam") if sync_options else None
validation = validate_device_for_import(
libre_device,
import_as_vm=True,
@@ -239,6 +257,7 @@ def bulk_import_vms(
use_sysname=use_sysname_opt,
strip_domain=strip_domain_opt,
server_key=api.server_key,
+ auto_create_ipam=auto_create_ipam_opt,
)
result["success"].append(
diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py
index 457eea37f7..52bc96bd62 100644
--- a/netbox_librenms_plugin/librenms_api.py
+++ b/netbox_librenms_plugin/librenms_api.py
@@ -234,15 +234,12 @@ def get_librenms_id(self, obj):
def _normalize_librenms_id(value):
"""Coerce a raw LibreNMS ID value to int or None.
- Booleans are rejected because bool is a subclass of int in Python,
- so int(True) silently becomes 1 β a valid-looking device ID.
+ Thin wrapper around :func:`netbox_librenms_plugin.utils.coerce_librenms_id`
+ kept for back-compat with internal callers in this module.
"""
- if value is None or isinstance(value, bool):
- return None
- try:
- return int(value)
- except (ValueError, TypeError):
- return None
+ from netbox_librenms_plugin.utils import coerce_librenms_id
+
+ return coerce_librenms_id(value)
def _get_cache_key(self, obj):
"""
@@ -343,12 +340,11 @@ def get_device_info(self, device_id):
timeout=DEFAULT_API_TIMEOUT,
verify=self.verify_ssl,
)
- if response.status_code == 200:
- device_data = response.json()["devices"][0]
- if not isinstance(device_data, dict):
- return False, None
- return True, device_data
- return False, None
+ response.raise_for_status()
+ device_data = response.json()["devices"][0]
+ if not isinstance(device_data, dict):
+ return False, None
+ return True, device_data
except (requests.exceptions.RequestException, ValueError, IndexError, KeyError, TypeError):
return False, None
@@ -429,16 +425,15 @@ def add_device(self, data):
if data["snmp_version"] in ("v1", "v2c"):
payload["community"] = data["community"]
elif data["snmp_version"] == "v3":
- payload.update(
- {
- "authlevel": data["authlevel"],
- "authname": data["authname"],
- "authpass": data["authpass"],
- "authalgo": data["authalgo"],
- "cryptopass": data["cryptopass"],
- "cryptoalgo": data["cryptoalgo"],
- }
- )
+ payload["authlevel"] = data["authlevel"]
+ payload["authname"] = data["authname"]
+ # Credential keys only apply at the auth levels that use them. Omit
+ # empty values instead of sending empty strings β LibreNMS rejects
+ # those for noAuthNoPriv / authNoPriv add-device requests.
+ for key in ("authpass", "authalgo", "cryptopass", "cryptoalgo"):
+ value = data.get(key)
+ if value:
+ payload[key] = value
try:
response = requests.post(
@@ -711,6 +706,69 @@ def get_device_inventory(self, device_id):
except (requests.exceptions.RequestException, ValueError) as e:
return False, str(e)
+ def get_device_transceivers(self, device_id):
+ """
+ Fetch all transceiver data for a device from LibreNMS.
+
+ Route: /api/v0/devices/{device_id}/transceivers
+
+ This is a separate data source from entity inventory. Some vendors
+ (e.g., Nokia/SROS) don't expose SFPs via ENTITY-MIB but do report
+ them through vendor-specific MIBs which LibreNMS surfaces here.
+
+ Args:
+ device_id: LibreNMS device ID
+
+ Returns:
+ tuple: (success: bool, data: list)
+
+ Example transceiver item:
+ {
+ "port_id": 519,
+ "entity_physical_index": 1610899520,
+ "type": "CFP2/QSFP28",
+ "model": "3HE10550AARA01",
+ "serial": "X42AU0D",
+ "channels": 4,
+ "connector": "LC",
+ "wavelength": 1301,
+ ...
+ }
+ """
+ try:
+ response = requests.get(
+ f"{self.librenms_url}/api/v0/devices/{device_id}/transceivers",
+ headers=self.headers,
+ timeout=DEFAULT_API_TIMEOUT,
+ verify=self.verify_ssl,
+ )
+ response.raise_for_status()
+
+ try:
+ data = response.json()
+ except ValueError:
+ return False, f"Invalid JSON in transceivers response for device {device_id}"
+
+ if not isinstance(data, dict) or "transceivers" not in data:
+ msg = data.get("message") if isinstance(data, dict) else None
+ return False, msg or f"Unexpected transceivers response format for device {device_id}"
+
+ if data.get("status") != "ok":
+ msg = data.get("message") or f"LibreNMS returned status={data.get('status')!r} for device {device_id}"
+ return False, msg
+
+ transceivers = data["transceivers"]
+ if not isinstance(transceivers, list):
+ msg = data.get("message")
+ return False, msg or f"Unexpected transceivers response format for device {device_id}"
+
+ if any(item is None or not isinstance(item, dict) for item in transceivers):
+ return False, f"Malformed transceiver entry in response for device {device_id}"
+
+ return True, transceivers
+ except requests.exceptions.RequestException as e:
+ return False, str(e)
+
def get_poller_groups(self):
"""
Fetch all poller groups from LibreNMS.
@@ -955,14 +1013,15 @@ def get_device_vlans(self, device_id: int) -> tuple[bool, list | str]:
if not isinstance(all_vlans, list):
msg = result.get("message")
return False, msg or "Unexpected response format: missing 'vlans' list"
+ if not all(isinstance(v, dict) for v in all_vlans):
+ return False, "Unexpected response format: invalid item shape in 'vlans'"
# Filter VLANs by device_id since resources endpoint returns all VLANs
- device_vlans = [
- v for v in all_vlans if isinstance(v, dict) and str(v.get("device_id")) == str(device_id)
- ]
+ device_vlans = [v for v in all_vlans if str(v.get("device_id")) == str(device_id)]
return True, device_vlans
if isinstance(result, dict):
return False, result.get("message") or "Unexpected response format"
return False, "Unexpected response format"
+
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return False, "VLANs resource not found"
@@ -1005,20 +1064,21 @@ def get_port_vlan_details(self, port_id: int) -> tuple[bool, dict | str]:
)
response.raise_for_status()
- if response.status_code == 200:
- result = response.json()
- if not isinstance(result, dict):
- return False, "Unexpected response format"
- port_data = result.get("port")
- if not isinstance(port_data, list):
- return False, result.get("message", "Unexpected response format: missing 'port' list")
- if not port_data:
- return False, "Port not found"
- if not isinstance(port_data[0], dict):
- return False, "Unexpected response format: invalid 'port' entry"
- return True, port_data[0]
-
- return False, f"HTTP {response.status_code}"
+ result = response.json()
+ if not isinstance(result, dict):
+ return False, "Unexpected response format"
+ if result.get("status") != "ok":
+ msg = result.get("message") or f"LibreNMS returned status={result.get('status')!r} for port"
+ return False, msg
+ port_data = result.get("port")
+ if not isinstance(port_data, list):
+ return False, result.get("message") or "Unexpected response format: missing 'port' list"
+ if not port_data:
+ return False, "Port not found"
+ if not isinstance(port_data[0], dict):
+ return False, "Unexpected response format: invalid 'port' entry"
+ return True, port_data[0]
+
except requests.exceptions.HTTPError as e:
if e.response.status_code == 404:
return False, "Port not found in LibreNMS"
diff --git a/netbox_librenms_plugin/migrations/0010_inventory_and_mapping_models.py b/netbox_librenms_plugin/migrations/0010_inventory_and_mapping_models.py
new file mode 100644
index 0000000000..9ef60b11db
--- /dev/null
+++ b/netbox_librenms_plugin/migrations/0010_inventory_and_mapping_models.py
@@ -0,0 +1,482 @@
+# Consolidated inventory + mapping models migration.
+#
+# This single migration creates every model and constraint introduced by the
+# inventory-core feature set: DeviceTypeMapping, ModuleTypeMapping,
+# ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, PlatformMapping,
+# CarrierAutoInstallRule, and the wildcard/global uniqueness constraints on
+# the mapping tables. It also seeds two default InventoryIgnoreRule entries
+# that the modules-sync code relies on (Cisco IOS-XR IDPROM duplicates and
+# embedded RP/fixed-chassis system boards).
+#
+# Earlier branches contained six separate migrations (0010..0015) for the same
+# end state; they were squashed before merge because they only ever shipped to
+# the devcontainer, never to a release. If you have an environment with the
+# old 0010..0015 history applied, run:
+#
+# python manage.py migrate netbox_librenms_plugin 0009 --fake
+# python manage.py migrate netbox_librenms_plugin --fake
+#
+# to rewrite the migration history without touching the schema (the end state
+# is identical), then run regular ``migrate`` for any subsequent migrations.
+
+import django.db.models.deletion
+import netbox.models.deletion
+import netbox_librenms_plugin.models
+import taggit.managers
+import utilities.json
+from django.db import migrations, models
+
+
+def _insert_default_inventory_ignore_rules(apps, schema_editor):
+ """Seed the two InventoryIgnoreRules the modules sync expects out of the box."""
+ db_alias = schema_editor.connection.alias
+ InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule")
+ InventoryIgnoreRule.objects.using(db_alias).create(
+ name="Cisco IOS-XR IDPROM entries",
+ match_type="ends_with",
+ pattern="IDPROM",
+ action="skip",
+ require_serial_match_parent=True,
+ enabled=True,
+ description=(
+ "Cisco IOS-XR reports every hardware component's EEPROM as a child entity "
+ 'whose entPhysicalName ends in "IDPROM". These entries duplicate the parent '
+ "module's serial number and are not real installable modules. "
+ "The serial-match guard ensures only genuine EEPROM duplicates are skipped \u2014 "
+ 'a module whose name happens to end in "IDPROM" but has a different serial '
+ "will not be filtered."
+ ),
+ )
+ InventoryIgnoreRule.objects.using(db_alias).create(
+ name="Embedded RP / fixed-chassis system board",
+ match_type="serial_matches_device",
+ pattern="",
+ action="transparent",
+ require_serial_match_parent=False,
+ enabled=True,
+ description=(
+ "Fixed-form routers report the built-in RP as an ENTITY-MIB module whose "
+ "serial number equals the device's own serial. Marking it transparent hides "
+ "the RP row in the sync table while promoting its children (transceivers, "
+ "fans, PSUs) to device-level bay matching. No pattern is needed \u2014 detection "
+ "is purely serial-based."
+ ),
+ )
+
+
+def _delete_default_inventory_ignore_rules(apps, schema_editor):
+ """Reverse the seed by matching each rule on its full signature.
+
+ Filtering by name alone would also remove user-created rules that happen
+ to share the seeded name (``InventoryIgnoreRule.name`` is not unique), so
+ we match each seeded row on the distinctive fields that uniquely identify
+ it as the migration's own insert.
+ """
+ db_alias = schema_editor.connection.alias
+ InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule")
+ seeded = (
+ {
+ "name": "Cisco IOS-XR IDPROM entries",
+ "match_type": "ends_with",
+ "pattern": "IDPROM",
+ "action": "skip",
+ "require_serial_match_parent": True,
+ },
+ {
+ "name": "Embedded RP / fixed-chassis system board",
+ "match_type": "serial_matches_device",
+ "pattern": "",
+ "action": "transparent",
+ "require_serial_match_parent": False,
+ },
+ )
+ for signature in seeded:
+ InventoryIgnoreRule.objects.using(db_alias).filter(**signature).delete()
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ # Pinned to the NetBox 4.2 floor declared by ``min_version`` in
+ # ``netbox_librenms_plugin/__init__.py``. Only ``Manufacturer``,
+ # ``DeviceType``, ``ModuleType``, and ``Platform`` are referenced from
+ # dcim, and only ``Tag``/``TaggedItem`` from extras (via taggit) β all
+ # of which exist in 4.2.x. ``makemigrations`` will try to bump these to
+ # the dev environment's NetBox tip; revert it unless we actually start
+ # depending on a newer field.
+ ("dcim", "0200_populate_mac_addresses"),
+ ("extras", "0122_charfield_null_choices"),
+ ("netbox_librenms_plugin", "0009_convert_librenms_id_to_json"),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="CarrierAutoInstallRule",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ("device_type_pattern", models.CharField(blank=True, max_length=255)),
+ ("librenms_child_class", models.CharField(max_length=50)),
+ ("librenms_child_name_pattern", models.CharField(max_length=255)),
+ ("netbox_bay_name_pattern", models.CharField(max_length=255)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "ordering": ["manufacturer__name", "librenms_child_class", "librenms_child_name_pattern"],
+ },
+ bases=(
+ netbox_librenms_plugin.models.FullCleanOnSaveMixin,
+ netbox.models.deletion.DeleteMixin,
+ models.Model,
+ ),
+ ),
+ migrations.CreateModel(
+ name="DeviceTypeMapping",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ("librenms_hardware", models.CharField(max_length=255, unique=True)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "ordering": ["librenms_hardware"],
+ },
+ bases=(
+ netbox_librenms_plugin.models.FullCleanOnSaveMixin,
+ netbox.models.deletion.DeleteMixin,
+ models.Model,
+ ),
+ ),
+ migrations.CreateModel(
+ name="InventoryIgnoreRule",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ("name", models.CharField(max_length=100)),
+ ("match_type", models.CharField(default="ends_with", max_length=25)),
+ ("pattern", models.CharField(blank=True, max_length=200)),
+ ("action", models.CharField(default="skip", max_length=15)),
+ ("require_serial_match_parent", models.BooleanField(default=True)),
+ ("enabled", models.BooleanField(db_index=True, default=True)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "ordering": ["name", "pk"],
+ },
+ bases=(
+ netbox_librenms_plugin.models.FullCleanOnSaveMixin,
+ netbox.models.deletion.DeleteMixin,
+ models.Model,
+ ),
+ ),
+ migrations.CreateModel(
+ name="ModuleBayMapping",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ("librenms_name", models.CharField(max_length=255)),
+ ("librenms_class", models.CharField(blank=True, max_length=50)),
+ ("netbox_bay_name", models.CharField(max_length=255)),
+ ("is_regex", models.BooleanField(default=False)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "ordering": ["librenms_name"],
+ },
+ bases=(
+ netbox_librenms_plugin.models.FullCleanOnSaveMixin,
+ netbox.models.deletion.DeleteMixin,
+ models.Model,
+ ),
+ ),
+ migrations.CreateModel(
+ name="ModuleTypeMapping",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ("librenms_model", models.CharField(max_length=255)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "ordering": ["librenms_model"],
+ },
+ bases=(
+ netbox_librenms_plugin.models.FullCleanOnSaveMixin,
+ netbox.models.deletion.DeleteMixin,
+ models.Model,
+ ),
+ ),
+ migrations.CreateModel(
+ name="NormalizationRule",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ("scope", models.CharField(db_index=True, max_length=50)),
+ ("match_pattern", models.CharField(max_length=500)),
+ ("replacement", models.CharField(max_length=500)),
+ ("priority", models.PositiveIntegerField(default=100)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "ordering": ["scope", "priority", "pk"],
+ },
+ bases=(
+ netbox_librenms_plugin.models.FullCleanOnSaveMixin,
+ netbox.models.deletion.DeleteMixin,
+ models.Model,
+ ),
+ ),
+ migrations.CreateModel(
+ name="PlatformMapping",
+ fields=[
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False)),
+ ("created", models.DateTimeField(auto_now_add=True, null=True)),
+ ("last_updated", models.DateTimeField(auto_now=True, null=True)),
+ (
+ "custom_field_data",
+ models.JSONField(blank=True, default=dict, encoder=utilities.json.CustomFieldJSONEncoder),
+ ),
+ ("librenms_os", models.CharField(max_length=255, unique=True)),
+ ("description", models.TextField(blank=True)),
+ ],
+ options={
+ "ordering": ["librenms_os"],
+ },
+ bases=(
+ netbox_librenms_plugin.models.FullCleanOnSaveMixin,
+ netbox.models.deletion.DeleteMixin,
+ models.Model,
+ ),
+ ),
+ migrations.AlterModelOptions(
+ name="interfacetypemapping",
+ options={"ordering": ["librenms_type", "librenms_speed"]},
+ ),
+ migrations.AlterUniqueTogether(
+ name="interfacetypemapping",
+ unique_together=set(),
+ ),
+ migrations.AddConstraint(
+ model_name="interfacetypemapping",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("librenms_speed__isnull", False)),
+ fields=("librenms_type", "librenms_speed"),
+ name="unique_interface_type_mapping",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="interfacetypemapping",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("librenms_speed__isnull", True)),
+ fields=("librenms_type",),
+ name="unique_interface_type_mapping_wildcard",
+ ),
+ ),
+ migrations.AddField(
+ model_name="carrierautoinstallrule",
+ name="carrier_module_type",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.PROTECT,
+ related_name="librenms_carrier_install_rules",
+ to="dcim.moduletype",
+ ),
+ ),
+ migrations.AddField(
+ model_name="carrierautoinstallrule",
+ name="manufacturer",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="librenms_carrier_install_rules",
+ to="dcim.manufacturer",
+ ),
+ ),
+ migrations.AddField(
+ model_name="carrierautoinstallrule",
+ name="tags",
+ field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
+ ),
+ migrations.AddField(
+ model_name="devicetypemapping",
+ name="netbox_device_type",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="librenms_device_type_mappings",
+ to="dcim.devicetype",
+ ),
+ ),
+ migrations.AddField(
+ model_name="devicetypemapping",
+ name="tags",
+ field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
+ ),
+ migrations.AddField(
+ model_name="inventoryignorerule",
+ name="tags",
+ field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
+ ),
+ migrations.AddField(
+ model_name="modulebaymapping",
+ name="manufacturer",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="librenms_module_bay_mappings",
+ to="dcim.manufacturer",
+ ),
+ ),
+ migrations.AddField(
+ model_name="modulebaymapping",
+ name="tags",
+ field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
+ ),
+ migrations.AddField(
+ model_name="moduletypemapping",
+ name="manufacturer",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="librenms_module_type_mappings",
+ to="dcim.manufacturer",
+ ),
+ ),
+ migrations.AddField(
+ model_name="moduletypemapping",
+ name="netbox_module_type",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="librenms_module_type_mappings",
+ to="dcim.moduletype",
+ ),
+ ),
+ migrations.AddField(
+ model_name="moduletypemapping",
+ name="tags",
+ field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
+ ),
+ migrations.AddField(
+ model_name="normalizationrule",
+ name="manufacturer",
+ field=models.ForeignKey(
+ blank=True,
+ null=True,
+ on_delete=django.db.models.deletion.SET_NULL,
+ related_name="normalization_rules",
+ to="dcim.manufacturer",
+ ),
+ ),
+ migrations.AddField(
+ model_name="normalizationrule",
+ name="tags",
+ field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
+ ),
+ migrations.AddField(
+ model_name="platformmapping",
+ name="netbox_platform",
+ field=models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="librenms_platform_mappings",
+ to="dcim.platform",
+ ),
+ ),
+ migrations.AddField(
+ model_name="platformmapping",
+ name="tags",
+ field=taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag"),
+ ),
+ migrations.AddConstraint(
+ model_name="carrierautoinstallrule",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("manufacturer__isnull", False)),
+ fields=(
+ "manufacturer",
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ ),
+ name="unique_carrier_auto_install_rule",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="carrierautoinstallrule",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("manufacturer__isnull", True)),
+ fields=(
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ ),
+ name="unique_carrier_auto_install_rule_global",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="modulebaymapping",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("manufacturer__isnull", False)),
+ fields=("librenms_name", "librenms_class", "manufacturer"),
+ name="unique_module_bay_mapping",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="modulebaymapping",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("manufacturer__isnull", True)),
+ fields=("librenms_name", "librenms_class"),
+ name="unique_module_bay_mapping_global",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="moduletypemapping",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("manufacturer__isnull", False)),
+ fields=("librenms_model", "manufacturer"),
+ name="unique_module_type_mapping",
+ ),
+ ),
+ migrations.AddConstraint(
+ model_name="moduletypemapping",
+ constraint=models.UniqueConstraint(
+ condition=models.Q(("manufacturer__isnull", True)),
+ fields=("librenms_model",),
+ name="unique_module_type_mapping_global",
+ ),
+ ),
+ migrations.RunPython(
+ _insert_default_inventory_ignore_rules,
+ reverse_code=_delete_default_inventory_ignore_rules,
+ ),
+ ]
diff --git a/netbox_librenms_plugin/migrations/0011_librenmssettings_auto_create_ipam_default.py b/netbox_librenms_plugin/migrations/0011_librenmssettings_auto_create_ipam_default.py
new file mode 100644
index 0000000000..ec6102dbd7
--- /dev/null
+++ b/netbox_librenms_plugin/migrations/0011_librenmssettings_auto_create_ipam_default.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("netbox_librenms_plugin", "0010_inventory_and_mapping_models"),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name="librenmssettings",
+ name="auto_create_ipam_default",
+ field=models.BooleanField(
+ default=False,
+ help_text=(
+ "When enabled, missing IP addresses reported by LibreNMS are "
+ "auto-created as global /32 (IPv4) or /128 (IPv6) IPAM records "
+ "during initial import, OOB-attach, and promote-to-host actions. "
+ "Existing IPAM records are always reused."
+ ),
+ ),
+ ),
+ ]
diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py
index cd79f47550..8453d7ef49 100644
--- a/netbox_librenms_plugin/models.py
+++ b/netbox_librenms_plugin/models.py
@@ -1,8 +1,53 @@
+import functools
+import logging
+import re
+
+import yaml
from dcim.choices import InterfaceTypeChoices
+from dcim.models import DeviceType, Manufacturer, ModuleType, Platform
+from django.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse
from netbox.models import NetBoxModel
+logger = logging.getLogger(__name__)
+
+
+def _validate_replacement_template(compiled: re.Pattern, replacement: str) -> None:
+ """Verify that *replacement* is a valid back-reference template for *compiled*.
+
+ ``re.sub(pattern, replacement, test_string)`` only evaluates group references
+ when the pattern actually matches the test string. Using the pattern text
+ itself as the test string may silently accept an invalid replacement when the
+ pattern does not match its own source (e.g. ``^(\\d+)$`` never matches the
+ string ``^(\\d+)$``).
+
+ This function constructs a synthetic test pattern with one trivial capture
+ group per group in *compiled* (named groups are preserved so ``\\g``
+ references are validated correctly), guaranteeing a match and ensuring all
+ back-references are exercised.
+
+ Raises ``re.error`` or ``IndexError`` if the replacement is invalid.
+ """
+ n = compiled.groups
+ if n == 0:
+ test_pat = re.compile("a")
+ test_str = "a"
+ else:
+ name_by_pos = {v: k for k, v in compiled.groupindex.items()}
+ parts = [f"(?P<{name_by_pos[i]}>a)" if i in name_by_pos else "(a)" for i in range(1, n + 1)]
+ test_pat = re.compile("".join(parts))
+ test_str = "a" * n
+ test_pat.sub(replacement, test_str)
+
+
+class FullCleanOnSaveMixin:
+ """Mixin that calls full_clean() on every save() so custom clean() logic runs even on programmatic saves."""
+
+ def save(self, *args, **kwargs):
+ self.full_clean()
+ super().save(*args, **kwargs)
+
class LibreNMSSettings(models.Model):
"""
@@ -34,6 +79,19 @@ class LibreNMSSettings(models.Model):
help_text="Remove domain suffix from device names during import",
)
+ auto_create_ipam_default = models.BooleanField(
+ default=False,
+ help_text=(
+ "When enabled, missing IP addresses reported by LibreNMS are auto-created "
+ "as global /32 (IPv4) or /128 (IPv6) IPAM records during initial import, "
+ "OOB-attach, and promote-to-host actions. Existing IPAM records are always reused."
+ ),
+ )
+
+ def save(self, *args, **kwargs):
+ self.pk = 1
+ super().save(*args, **kwargs)
+
class Meta:
"""Meta options for LibreNMSSettings."""
@@ -48,7 +106,7 @@ def __str__(self):
return f"LibreNMS Settings - Server: {self.selected_server}"
-class InterfaceTypeMapping(NetBoxModel):
+class InterfaceTypeMapping(FullCleanOnSaveMixin, NetBoxModel):
"""Map LibreNMS interface types and speeds to NetBox interface types."""
librenms_type = models.CharField(max_length=100)
@@ -63,6 +121,27 @@ class InterfaceTypeMapping(NetBoxModel):
help_text="Optional description or notes about this interface type mapping",
)
+ def clean(self):
+ """Enforce uniqueness for NULL-speed rows (SQL UNIQUE does not cover NULL = NULL)."""
+ from django.core.exceptions import ValidationError
+
+ super().clean()
+ normalized_type = (self.librenms_type or "").strip()
+ if not normalized_type:
+ raise ValidationError({"librenms_type": "LibreNMS type must not be blank or whitespace-only."})
+ self.librenms_type = normalized_type
+ if self.librenms_speed is None:
+ qs = InterfaceTypeMapping.objects.filter(
+ librenms_type=normalized_type,
+ librenms_speed__isnull=True,
+ )
+ if self.pk:
+ qs = qs.exclude(pk=self.pk)
+ if qs.exists():
+ raise ValidationError(
+ {"librenms_type": ("A wildcard (speed = any) mapping for this interface type already exists.")}
+ )
+
def get_absolute_url(self):
"""Return the URL for this mapping's detail page."""
return reverse("plugins:netbox_librenms_plugin:interfacetypemapping_detail", args=[self.pk])
@@ -70,7 +149,789 @@ def get_absolute_url(self):
class Meta:
"""Meta options for InterfaceTypeMapping."""
- unique_together = ["librenms_type", "librenms_speed"]
+ constraints = [
+ models.UniqueConstraint(
+ fields=["librenms_type", "librenms_speed"],
+ condition=models.Q(librenms_speed__isnull=False),
+ name="unique_interface_type_mapping",
+ ),
+ models.UniqueConstraint(
+ fields=["librenms_type"],
+ condition=models.Q(librenms_speed__isnull=True),
+ name="unique_interface_type_mapping_wildcard",
+ ),
+ ]
+ ordering = ["librenms_type", "librenms_speed"]
def __str__(self):
return f"{self.librenms_type} + {self.librenms_speed} -> {self.netbox_type}"
+
+ def to_yaml(self):
+ data = {
+ "librenms_type": self.librenms_type,
+ "librenms_speed": self.librenms_speed,
+ "netbox_type": self.netbox_type,
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
+
+
+class DeviceTypeMapping(FullCleanOnSaveMixin, NetBoxModel):
+ """Map LibreNMS hardware strings to NetBox DeviceType objects."""
+
+ librenms_hardware = models.CharField(
+ max_length=255,
+ unique=True,
+ help_text="Hardware string as reported by LibreNMS (e.g., 'Juniper MX480 Internet Backbone Router')",
+ )
+ netbox_device_type = models.ForeignKey(
+ DeviceType,
+ on_delete=models.CASCADE,
+ related_name="librenms_device_type_mappings",
+ help_text="The NetBox DeviceType this hardware string maps to",
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional description or notes about this mapping",
+ )
+
+ def clean(self):
+ """Normalize librenms_hardware to lowercase so case-variant duplicates are prevented at save time."""
+ super().clean()
+ self.librenms_hardware = (self.librenms_hardware or "").strip().lower()
+ if not self.librenms_hardware:
+ raise ValidationError({"librenms_hardware": "This field may not be blank after normalization."})
+
+ def get_absolute_url(self):
+ """Return the URL for this mapping's detail page."""
+ return reverse("plugins:netbox_librenms_plugin:devicetypemapping_detail", args=[self.pk])
+
+ class Meta:
+ """Meta options for DeviceTypeMapping."""
+
+ ordering = ["librenms_hardware"]
+
+ def __str__(self):
+ return f"{self.librenms_hardware} -> {self.netbox_device_type}"
+
+ def to_yaml(self):
+ data = {
+ "librenms_hardware": self.librenms_hardware,
+ "netbox_device_type": str(self.netbox_device_type),
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
+
+
+class ModuleTypeMapping(FullCleanOnSaveMixin, NetBoxModel):
+ """Map LibreNMS inventory model names to NetBox ModuleType objects."""
+
+ librenms_model = models.CharField(
+ max_length=255,
+ help_text="Model name from LibreNMS inventory (entPhysicalModelName)",
+ )
+ netbox_module_type = models.ForeignKey(
+ ModuleType,
+ on_delete=models.CASCADE,
+ related_name="librenms_module_type_mappings",
+ help_text="The NetBox ModuleType this model name maps to",
+ )
+ manufacturer = models.ForeignKey(
+ Manufacturer,
+ null=True,
+ blank=True,
+ on_delete=models.SET_NULL,
+ related_name="librenms_module_type_mappings",
+ help_text=(
+ "Optional: scope this mapping to one manufacturer (matches the device's "
+ "device_type.manufacturer). Leave blank to apply across vendors. When both a "
+ "manufacturer-scoped and a global mapping exist for the same librenms_model, "
+ "the manufacturer-scoped row wins for devices of that vendor."
+ ),
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional description or notes about this mapping",
+ )
+
+ def clean(self):
+ """Normalize librenms_model so whitespace-padded values don't create duplicate entries."""
+ super().clean()
+ self.librenms_model = (self.librenms_model or "").strip()
+ if not self.librenms_model:
+ raise ValidationError({"librenms_model": "This field may not be blank after normalization."})
+
+ def get_absolute_url(self):
+ """Return the URL for this mapping's detail page."""
+ return reverse("plugins:netbox_librenms_plugin:moduletypemapping_detail", args=[self.pk])
+
+ class Meta:
+ """Meta options for ModuleTypeMapping."""
+
+ ordering = ["librenms_model"]
+ # UniqueConstraint over (librenms_model, manufacturer):
+ # PostgreSQL <13 treats NULL values as distinct in unique indexes, so
+ # the nullable manufacturer FK lets two "global" rows (manufacturer
+ # IS NULL) with the same librenms_model coexist. Splitting into a
+ # conditional pair (NOT NULL + NULL) makes the constraint enforce
+ # uniqueness in both cases on every supported PG version.
+ constraints = [
+ models.UniqueConstraint(
+ fields=["librenms_model", "manufacturer"],
+ condition=models.Q(manufacturer__isnull=False),
+ name="unique_module_type_mapping",
+ ),
+ models.UniqueConstraint(
+ fields=["librenms_model"],
+ condition=models.Q(manufacturer__isnull=True),
+ name="unique_module_type_mapping_global",
+ ),
+ ]
+
+ def __str__(self):
+ mfr = f" ({self.manufacturer.name})" if self.manufacturer_id else ""
+ return f"{self.librenms_model}{mfr} -> {self.netbox_module_type}"
+
+ def to_yaml(self):
+ data = {
+ "librenms_model": self.librenms_model,
+ "manufacturer": self.manufacturer.name if self.manufacturer_id else "",
+ "netbox_module_type": str(self.netbox_module_type),
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
+
+
+class ModuleBayMapping(FullCleanOnSaveMixin, NetBoxModel):
+ """
+ Map LibreNMS inventory names to NetBox module bay names.
+
+ Used when LibreNMS inventory names don't match NetBox bay names exactly.
+ For example: LibreNMS "Power Supply 1" β NetBox "PS1".
+ When is_regex is True, librenms_name is treated as a regex pattern and
+ netbox_bay_name can use backreferences (\\1, \\2, etc.).
+ Mappings are global (not scoped to device type or manufacturer).
+ """
+
+ librenms_name = models.CharField(
+ max_length=255,
+ help_text="Name from LibreNMS inventory (entPhysicalName). "
+ "When 'Use Regex' is enabled, this is a Python regex pattern.",
+ )
+ librenms_class = models.CharField(
+ max_length=50,
+ blank=True,
+ help_text="Optional entPhysicalClass filter (e.g. 'powerSupply', 'fan', 'module')",
+ )
+ netbox_bay_name = models.CharField(
+ max_length=255,
+ help_text="NetBox module bay name to match. With regex, supports backreferences (\\1, \\2, etc.).",
+ )
+ is_regex = models.BooleanField(
+ default=False,
+ help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name",
+ )
+ manufacturer = models.ForeignKey(
+ Manufacturer,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="librenms_module_bay_mappings",
+ help_text="Optional: scope this mapping to one manufacturer (matches the device's "
+ "device_type.manufacturer). Leave blank to apply across vendors. When both a "
+ "vendor-scoped and a global mapping match, the vendor-scoped one wins.",
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional description or notes about this mapping",
+ )
+
+ @functools.cached_property
+ def _compiled_pattern(self):
+ """Compiled regex for is_regex=True mappings; None for exact mappings or invalid patterns."""
+ if not self.is_regex or not self.librenms_name:
+ return None
+ try:
+ return re.compile(self.librenms_name)
+ except re.error:
+ return None
+
+ def clean(self):
+ """Validate that regex patterns compile when is_regex is True."""
+ super().clean()
+ # Invalidate cached compiled pattern so it's recomputed from the new value
+ self.__dict__.pop("_compiled_pattern", None)
+ librenms_name_stripped = self.librenms_name.strip() if self.librenms_name else ""
+ if not librenms_name_stripped:
+ raise ValidationError({"librenms_name": "LibreNMS name pattern must not be empty or whitespace-only."})
+ self.librenms_name = librenms_name_stripped
+ # Strip class too β whitespace-padded values form spurious distinct rows under unique_together.
+ self.librenms_class = self.librenms_class.strip() if self.librenms_class else ""
+ # Strip netbox_bay_name β whitespace-padded values would fail regex substitution.
+ netbox_bay_name_stripped = self.netbox_bay_name.strip() if self.netbox_bay_name else ""
+ if not netbox_bay_name_stripped:
+ raise ValidationError({"netbox_bay_name": "NetBox bay name must not be empty or whitespace-only."})
+ self.netbox_bay_name = netbox_bay_name_stripped
+ if self.is_regex:
+ try:
+ pattern = re.compile(self.librenms_name)
+ except re.error as e:
+ raise ValidationError({"librenms_name": f"Invalid regex: {e}"})
+ try:
+ _validate_replacement_template(pattern, self.netbox_bay_name)
+ except (re.error, IndexError) as e:
+ raise ValidationError({"netbox_bay_name": f"Invalid replacement: {e}"})
+
+ def get_absolute_url(self):
+ """Return the URL for this mapping's detail page."""
+ return reverse("plugins:netbox_librenms_plugin:modulebaymapping_detail", args=[self.pk])
+
+ class Meta:
+ """Meta options for ModuleBayMapping."""
+
+ # Two conditional UniqueConstraints rather than a single
+ # UniqueConstraint over (librenms_name, librenms_class, manufacturer):
+ # PostgreSQL treats NULL β NULL, so a single constraint that includes
+ # the nullable manufacturer FK lets two "global" rows (manufacturer
+ # IS NULL) with otherwise identical fields slip through. The
+ # cleaner ``nulls_distinct=False`` option requires PostgreSQL 15+
+ # (Django 5.2+), but NetBox 4.2 still supports PostgreSQL 12, so we
+ # split into one constraint per branch instead. Issue #71.
+ constraints = [
+ models.UniqueConstraint(
+ fields=["librenms_name", "librenms_class", "manufacturer"],
+ condition=models.Q(manufacturer__isnull=False),
+ name="unique_module_bay_mapping",
+ ),
+ models.UniqueConstraint(
+ fields=["librenms_name", "librenms_class"],
+ condition=models.Q(manufacturer__isnull=True),
+ name="unique_module_bay_mapping_global",
+ ),
+ ]
+ ordering = ["librenms_name"]
+
+ def __str__(self):
+ cls = f" [{self.librenms_class}]" if self.librenms_class else ""
+ mfr = f" ({self.manufacturer.name})" if self.manufacturer_id else ""
+ return f"{self.librenms_name}{cls}{mfr} -> {self.netbox_bay_name}"
+
+ def to_yaml(self):
+ data = {
+ "librenms_name": self.librenms_name,
+ "librenms_class": self.librenms_class,
+ "netbox_bay_name": self.netbox_bay_name,
+ "is_regex": self.is_regex,
+ "manufacturer": self.manufacturer.name if self.manufacturer_id else "",
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
+
+
+class NormalizationRule(FullCleanOnSaveMixin, NetBoxModel):
+ """
+ Regex-based string normalization applied before matching lookups.
+
+ Generic building block: a single rule engine handles normalization
+ for module types, device types, module bays, and future scopes.
+ Rules are applied in priority order; each transforms the string
+ for the next rule in the chain.
+
+ Example β strip Nokia revision suffixes:
+ scope: module_type
+ match_pattern: ^(3HE\\w{5}[A-Z]{2})[A-Z]{2}\\d{2}$
+ replacement: \\1
+ Result: 3HE16474AARA01 β 3HE16474AA
+ """
+
+ SCOPE_MODULE_TYPE = "module_type"
+ SCOPE_DEVICE_TYPE = "device_type"
+ SCOPE_MODULE_BAY = "module_bay"
+
+ SCOPE_CHOICES = [
+ (SCOPE_MODULE_TYPE, "Module Type"),
+ (SCOPE_DEVICE_TYPE, "Device Type"),
+ (SCOPE_MODULE_BAY, "Module Bay"),
+ ]
+
+ scope = models.CharField(
+ max_length=50,
+ choices=SCOPE_CHOICES,
+ db_index=True,
+ help_text="Which matching lookup this rule applies to",
+ )
+ manufacturer = models.ForeignKey(
+ Manufacturer,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="normalization_rules",
+ help_text="Optional: only apply this rule to items from this manufacturer. "
+ "Leave blank for vendor-agnostic rules.",
+ )
+ match_pattern = models.CharField(
+ max_length=500,
+ help_text="Regex pattern to match against input string (Python re syntax)",
+ )
+ replacement = models.CharField(
+ max_length=500,
+ help_text="Replacement string (supports regex back-references \\1, \\2, β¦)",
+ )
+ priority = models.PositiveIntegerField(
+ default=100,
+ help_text="Lower values run first. Rules chain: each transforms the output of the previous.",
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional description or notes about this rule",
+ )
+
+ def clean(self):
+ """Validate that match_pattern compiles as a regex and replacement is a valid template."""
+ super().clean()
+ errors = {}
+ if not self.match_pattern:
+ errors["match_pattern"] = "This field is required."
+ if self.replacement is None:
+ errors["replacement"] = "This field is required."
+ if errors:
+ raise ValidationError(errors)
+ try:
+ compiled = re.compile(self.match_pattern)
+ except re.error as e:
+ raise ValidationError({"match_pattern": f"Invalid regex: {e}"})
+ # Validate the replacement template by running a dummy substitution
+ try:
+ _validate_replacement_template(compiled, self.replacement)
+ except (re.error, IndexError) as e:
+ raise ValidationError({"replacement": f"Invalid replacement template: {e}"})
+
+ def get_absolute_url(self):
+ """Return the URL for this rule's detail page."""
+ return reverse("plugins:netbox_librenms_plugin:normalizationrule_detail", args=[self.pk])
+
+ class Meta:
+ """Meta options for NormalizationRule."""
+
+ ordering = ["scope", "priority", "pk"]
+
+ def __str__(self):
+ return f"[{self.get_scope_display()}] {self.match_pattern} β {self.replacement}"
+
+ def to_yaml(self):
+ data = {
+ "scope": self.scope,
+ "manufacturer": str(self.manufacturer) if self.manufacturer else None,
+ "match_pattern": self.match_pattern,
+ "replacement": self.replacement,
+ "priority": self.priority,
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
+
+
+class InventoryIgnoreRule(FullCleanOnSaveMixin, NetBoxModel):
+ """
+ Rule-based filter for ENTITY-MIB inventory items during module sync.
+
+ Two use-cases are supported, controlled by the ``action`` field:
+
+ **Skip** (``action='skip'``)
+ The matched item is removed from the sync table entirely. Used for
+ phantom EEPROM/IDPROM child entities that Cisco IOS-XR reports with the
+ same model and serial as the real parent module.
+
+ **Transparent** (``action='transparent'``)
+ The matched item's row is hidden, but its ENTITY-MIB children are
+ *promoted* to device-level bay matching instead of being treated as
+ sub-components. Used for fixed-chassis devices (e.g. Cisco 8201-SYS)
+ where the RP/system-board entity is the device itself β it carries the
+ same serial number as the NetBox device, so its children (transceivers,
+ fans, PSUs) should be matched directly against device-level bays.
+
+ Match types:
+ ``ends_with / starts_with / contains / regex``
+ Compare ``entPhysicalName``. Use ``require_serial_match_parent``
+ as a safety net to avoid false positives.
+ ``serial_matches_device``
+ Match when the item's ``entPhysicalSerialNum`` equals the NetBox
+ device's own serial number. No ``pattern`` is required.
+ Pair with ``action='transparent'`` for embedded-RP detection.
+ """
+
+ # --- action ---
+ ACTION_SKIP = "skip"
+ ACTION_TRANSPARENT = "transparent"
+ ACTION_CHOICES = [
+ (ACTION_SKIP, "Skip (remove from table)"),
+ (ACTION_TRANSPARENT, "Transparent (hide row, promote children to device level)"),
+ ]
+
+ # --- match_type ---
+ MATCH_ENDS_WITH = "ends_with"
+ MATCH_STARTS_WITH = "starts_with"
+ MATCH_CONTAINS = "contains"
+ MATCH_REGEX = "regex"
+ MATCH_SERIAL_DEVICE = "serial_matches_device"
+
+ MATCH_TYPE_CHOICES = [
+ (MATCH_ENDS_WITH, "Ends with (entPhysicalName)"),
+ (MATCH_STARTS_WITH, "Starts with (entPhysicalName)"),
+ (MATCH_CONTAINS, "Contains (entPhysicalName)"),
+ (MATCH_REGEX, "Regex (entPhysicalName)"),
+ (MATCH_SERIAL_DEVICE, "Serial matches device (entPhysicalSerialNum = Device.serial)"),
+ ]
+
+ name = models.CharField(
+ max_length=100,
+ help_text="Short descriptive label for this rule",
+ )
+ match_type = models.CharField(
+ max_length=25,
+ choices=MATCH_TYPE_CHOICES,
+ default=MATCH_ENDS_WITH,
+ help_text="How to match the inventory item",
+ )
+ pattern = models.CharField(
+ max_length=200,
+ blank=True,
+ help_text="Pattern to match against entPhysicalName. "
+ "Case-insensitive for ends_with / starts_with / contains; "
+ "Python re syntax for regex. "
+ "Not used for serial_matches_device.",
+ )
+ action = models.CharField(
+ max_length=15,
+ choices=ACTION_CHOICES,
+ default=ACTION_SKIP,
+ help_text="What to do when this rule matches: skip the item entirely, "
+ "or hide its row and promote its children to device-level bay matching.",
+ )
+ require_serial_match_parent = models.BooleanField(
+ default=True,
+ help_text="(Name-based rules only) Only apply this rule if the item's serial "
+ "number matches an ancestor entity's serial number. Recommended to "
+ "prevent false positives. Ignored for serial_matches_device rules.",
+ )
+ enabled = models.BooleanField(
+ default=True,
+ db_index=True,
+ help_text="Uncheck to temporarily disable this rule without deleting it",
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional notes about this rule (vendor, firmware version, etc.)",
+ )
+
+ def clean(self):
+ """Validate pattern/match_type consistency."""
+ super().clean()
+ # Invalidate cached compiled pattern so it's recomputed from the new value
+ self.__dict__.pop("_compiled_pattern", None)
+ pattern_stripped = self.pattern.strip() if self.pattern else ""
+ if self.match_type == self.MATCH_REGEX and pattern_stripped:
+ try:
+ re.compile(pattern_stripped)
+ except re.error as e:
+ raise ValidationError({"pattern": f"Invalid regex: {e}"})
+ if self.match_type != self.MATCH_SERIAL_DEVICE and not pattern_stripped:
+ raise ValidationError({"pattern": "Pattern is required for name-based match types."})
+ # Normalize stored pattern to the stripped form so matches_name() and
+ # clean() always operate on the same string.
+ self.pattern = pattern_stripped
+
+ @functools.cached_property
+ def _compiled_pattern(self):
+ """Compiled regex for MATCH_REGEX rules; None for other match types or invalid patterns."""
+ if self.match_type != self.MATCH_REGEX or not self.pattern:
+ return None
+ try:
+ return re.compile(self.pattern)
+ except re.error:
+ return None
+
+ def matches_name(self, name: str) -> bool:
+ """Return True if *name* matches this rule's pattern/match_type (name-based rules only)."""
+ if not name or self.match_type == self.MATCH_SERIAL_DEVICE:
+ return False
+ if not self.pattern or not self.pattern.strip():
+ return False
+ if self.match_type == self.MATCH_REGEX:
+ compiled = self._compiled_pattern
+ if compiled is None:
+ logger.error(
+ "Invalid regex in InventoryIgnoreRule pk=%s pattern=%r β skipping",
+ self.pk,
+ self.pattern,
+ )
+ return False
+ try:
+ return bool(compiled.search(name))
+ except re.error as exc:
+ logger.error(
+ "Regex error in InventoryIgnoreRule pk=%s pattern=%r name=%r: %s β skipping",
+ self.pk,
+ self.pattern,
+ name,
+ exc,
+ )
+ return False
+ name_up = name.upper()
+ pat = self.pattern.upper()
+ if self.match_type == self.MATCH_ENDS_WITH:
+ return name_up.endswith(pat)
+ if self.match_type == self.MATCH_STARTS_WITH:
+ return name_up.startswith(pat)
+ if self.match_type == self.MATCH_CONTAINS:
+ return pat in name_up
+ return False
+
+ def get_absolute_url(self):
+ """Return the URL for this rule's detail page."""
+ return reverse("plugins:netbox_librenms_plugin:inventoryignorerule_detail", args=[self.pk])
+
+ class Meta:
+ """Meta options for InventoryIgnoreRule."""
+
+ ordering = ["name", "pk"]
+
+ def __str__(self):
+ if self.match_type == self.MATCH_SERIAL_DEVICE:
+ return f"{self.name}: {self.get_match_type_display()}"
+ serial_note = " [serial match]" if self.require_serial_match_parent else ""
+ return f"{self.name}: {self.get_match_type_display()} '{self.pattern}'{serial_note}"
+
+ def to_yaml(self):
+ data = {
+ "name": self.name,
+ "match_type": self.match_type,
+ "pattern": self.pattern,
+ "action": self.action,
+ "require_serial_match_parent": self.require_serial_match_parent,
+ "enabled": self.enabled,
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
+
+
+class PlatformMapping(FullCleanOnSaveMixin, NetBoxModel):
+ """Map LibreNMS OS strings to NetBox Platform objects."""
+
+ librenms_os = models.CharField(
+ max_length=255,
+ unique=True,
+ help_text="OS string as reported by LibreNMS (e.g., 'ios', 'eos', 'junos')",
+ )
+ netbox_platform = models.ForeignKey(
+ Platform,
+ on_delete=models.CASCADE,
+ related_name="librenms_platform_mappings",
+ help_text="The NetBox Platform this OS string maps to",
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional description or notes about this mapping",
+ )
+
+ def clean(self):
+ """Normalize librenms_os to lowercase so case-variant duplicates are prevented at save time."""
+ super().clean()
+ self.librenms_os = (self.librenms_os or "").strip().lower()
+ if not self.librenms_os:
+ raise ValidationError({"librenms_os": "This field may not be blank after normalization."})
+
+ def get_absolute_url(self):
+ """Return the URL for this mapping's detail page."""
+ return reverse("plugins:netbox_librenms_plugin:platformmapping_detail", args=[self.pk])
+
+ class Meta:
+ """Meta options for PlatformMapping."""
+
+ ordering = ["librenms_os"]
+
+ def __str__(self):
+ return f"{self.librenms_os} -> {self.netbox_platform}"
+
+ def to_yaml(self):
+ data = {
+ "librenms_os": self.librenms_os,
+ "netbox_platform": str(self.netbox_platform),
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
+
+
+class CarrierAutoInstallRule(FullCleanOnSaveMixin, NetBoxModel):
+ """
+ User-configurable suggestion rule for missing holder/carrier modules.
+
+ Some chassis (e.g. Nokia 7750 SR-s with CMA controller carriers, mezzanine
+ carriers, line-card cassettes) expose a holder bay at the chassis level
+ whose nested child bays only become available once the carrier ModuleType
+ is installed in NetBox. LibreNMS does not report the carrier itself, only
+ its children (CPMs, MDAs, mezzanines), so they appear as orphan "No Bay"
+ rows. A rule lets the user say: "for Nokia 7750 SR chassis, when LibreNMS
+ reports a cpmModule named '^Slot [AB]$' and the chassis has an empty bay
+ named '^CMA$', suggest installing carrier_module_type into that bay."
+
+ Suggest-only β the user clicks an Install button to apply. No vendor data
+ ships with the plugin; rules are loaded from the UI or contrib YAML.
+ """
+
+ manufacturer = models.ForeignKey(
+ Manufacturer,
+ on_delete=models.SET_NULL,
+ null=True,
+ blank=True,
+ related_name="librenms_carrier_install_rules",
+ help_text="Optional: scope this rule to one manufacturer (matches the device's "
+ "device_type.manufacturer). Leave blank to apply across vendors.",
+ )
+ device_type_pattern = models.CharField(
+ max_length=255,
+ blank=True,
+ help_text="Optional regex (Python re.fullmatch) on the device_type model name. "
+ "Leave blank to apply to all device types of the selected manufacturer.",
+ )
+ librenms_child_class = models.CharField(
+ max_length=50,
+ help_text="Exact entPhysicalClass match for the orphan child reported by LibreNMS "
+ "(e.g. cpmModule, mdaModule, fabricModule).",
+ )
+ librenms_child_name_pattern = models.CharField(
+ max_length=255,
+ help_text="Regex (Python re.fullmatch) on the orphan child's entPhysicalName (e.g. '^Slot [AB]$').",
+ )
+ netbox_bay_name_pattern = models.CharField(
+ max_length=255,
+ help_text="Regex (Python re.fullmatch) on the chassis-level empty module bay name "
+ "where the carrier should be installed (e.g. '^CMA$' or '^Carrier \\d+$'). "
+ "All matching empty bays will be offered as install targets.",
+ )
+ carrier_module_type = models.ForeignKey(
+ ModuleType,
+ on_delete=models.PROTECT,
+ related_name="librenms_carrier_install_rules",
+ help_text="The NetBox ModuleType to suggest installing into the matching empty bay.",
+ )
+ description = models.TextField(
+ blank=True,
+ help_text="Optional notes about this rule.",
+ )
+
+ @functools.cached_property
+ def _compiled_device_type_pattern(self):
+ if not self.device_type_pattern:
+ return None
+ try:
+ return re.compile(self.device_type_pattern)
+ except re.error:
+ return None
+
+ @functools.cached_property
+ def _compiled_child_name_pattern(self):
+ if not self.librenms_child_name_pattern:
+ return None
+ try:
+ return re.compile(self.librenms_child_name_pattern)
+ except re.error:
+ return None
+
+ @functools.cached_property
+ def _compiled_bay_name_pattern(self):
+ if not self.netbox_bay_name_pattern:
+ return None
+ try:
+ return re.compile(self.netbox_bay_name_pattern)
+ except re.error:
+ return None
+
+ def clean(self):
+ super().clean()
+ # Invalidate cached compiled patterns so they recompute from new values.
+ self.__dict__.pop("_compiled_device_type_pattern", None)
+ self.__dict__.pop("_compiled_child_name_pattern", None)
+ self.__dict__.pop("_compiled_bay_name_pattern", None)
+
+ self.device_type_pattern = (self.device_type_pattern or "").strip()
+ self.librenms_child_class = (self.librenms_child_class or "").strip()
+ self.librenms_child_name_pattern = (self.librenms_child_name_pattern or "").strip()
+ self.netbox_bay_name_pattern = (self.netbox_bay_name_pattern or "").strip()
+
+ if not self.librenms_child_class:
+ raise ValidationError({"librenms_child_class": "This field is required."})
+ if not self.librenms_child_name_pattern:
+ raise ValidationError({"librenms_child_name_pattern": "This field is required."})
+ if not self.netbox_bay_name_pattern:
+ raise ValidationError({"netbox_bay_name_pattern": "This field is required."})
+
+ for field, value in (
+ ("device_type_pattern", self.device_type_pattern),
+ ("librenms_child_name_pattern", self.librenms_child_name_pattern),
+ ("netbox_bay_name_pattern", self.netbox_bay_name_pattern),
+ ):
+ if not value:
+ continue
+ try:
+ re.compile(value)
+ except re.error as e:
+ raise ValidationError({field: f"Invalid regex: {e}"})
+
+ def get_absolute_url(self):
+ return reverse(
+ "plugins:netbox_librenms_plugin:carrierautoinstallrule_detail",
+ args=[self.pk],
+ )
+
+ class Meta:
+ """Meta options for CarrierAutoInstallRule."""
+
+ ordering = ["manufacturer__name", "librenms_child_class", "librenms_child_name_pattern"]
+ # See ModuleBayMapping.Meta.constraints for the full rationale: the
+ # nullable manufacturer FK forces a pair of conditional
+ # UniqueConstraints because PostgreSQL 12-14 (still supported by
+ # NetBox 4.2) does not honour ``nulls_distinct=False``. Issue #71.
+ constraints = [
+ models.UniqueConstraint(
+ fields=[
+ "manufacturer",
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ ],
+ condition=models.Q(manufacturer__isnull=False),
+ name="unique_carrier_auto_install_rule",
+ ),
+ models.UniqueConstraint(
+ fields=[
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ ],
+ condition=models.Q(manufacturer__isnull=True),
+ name="unique_carrier_auto_install_rule_global",
+ ),
+ ]
+
+ def __str__(self):
+ scope = self.manufacturer.name if self.manufacturer else "*"
+ if self.device_type_pattern:
+ scope = f"{scope}/{self.device_type_pattern}"
+ return (
+ f"{scope}: {self.librenms_child_class} '{self.librenms_child_name_pattern}'"
+ f" -> install {self.carrier_module_type} into '{self.netbox_bay_name_pattern}'"
+ )
+
+ def to_yaml(self):
+ data = {
+ "manufacturer": self.manufacturer.name if self.manufacturer else "",
+ "device_type_pattern": self.device_type_pattern,
+ "librenms_child_class": self.librenms_child_class,
+ "librenms_child_name_pattern": self.librenms_child_name_pattern,
+ "netbox_bay_name_pattern": self.netbox_bay_name_pattern,
+ "carrier_module_type": str(self.carrier_module_type),
+ "description": self.description,
+ }
+ return yaml.dump(data, sort_keys=False)
diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py
index a08e62740f..7a81a395a6 100644
--- a/netbox_librenms_plugin/navigation.py
+++ b/netbox_librenms_plugin/navigation.py
@@ -1,19 +1,44 @@
from netbox.plugins import PluginMenu, PluginMenuButton, PluginMenuItem
-from netbox_librenms_plugin.constants import PERM_VIEW_PLUGIN
+from netbox_librenms_plugin.constants import PERM_CHANGE_PLUGIN, PERM_VIEW_PLUGIN
menu = PluginMenu(
label="LibreNMS",
icon_class="mdi mdi-network",
groups=(
(
- "Settings",
+ "Import",
(
PluginMenuItem(
- link="plugins:netbox_librenms_plugin:settings",
- link_text="Plugin Settings",
+ link="plugins:netbox_librenms_plugin:librenms_import",
+ link_text="LibreNMS Import",
+ permissions=[PERM_VIEW_PLUGIN],
+ ),
+ ),
+ ),
+ (
+ "Status Check",
+ (
+ PluginMenuItem(
+ link="plugins:netbox_librenms_plugin:site_location_sync",
+ link_text="Site & Location Sync",
permissions=[PERM_VIEW_PLUGIN],
),
+ PluginMenuItem(
+ link="plugins:netbox_librenms_plugin:device_status_list",
+ link_text="Device Status",
+ permissions=[PERM_VIEW_PLUGIN],
+ ),
+ PluginMenuItem(
+ link="plugins:netbox_librenms_plugin:vm_status_list",
+ link_text="VM Status",
+ permissions=[PERM_VIEW_PLUGIN],
+ ),
+ ),
+ ),
+ (
+ "Mappings",
+ (
PluginMenuItem(
link="plugins:netbox_librenms_plugin:interfacetypemapping_list",
link_text="Interface Mappings",
@@ -23,42 +48,157 @@
link="plugins:netbox_librenms_plugin:interfacetypemapping_add",
title="Add",
icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
),
PluginMenuButton(
link="plugins:netbox_librenms_plugin:interfacetypemapping_bulk_import",
title="Import",
icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
),
),
),
- ),
- ),
- (
- "Import",
- (
PluginMenuItem(
- link="plugins:netbox_librenms_plugin:librenms_import",
- link_text="LibreNMS Import",
+ link="plugins:netbox_librenms_plugin:devicetypemapping_list",
+ link_text="Device Type Mappings",
permissions=[PERM_VIEW_PLUGIN],
+ buttons=(
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:devicetypemapping_add",
+ title="Add",
+ icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:devicetypemapping_bulk_import",
+ title="Import",
+ icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ ),
),
- ),
- ),
- (
- "Status Check",
- (
PluginMenuItem(
- link="plugins:netbox_librenms_plugin:site_location_sync",
- link_text="Site & Location Sync",
+ link="plugins:netbox_librenms_plugin:moduletypemapping_list",
+ link_text="Module Type Mappings",
permissions=[PERM_VIEW_PLUGIN],
+ buttons=(
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:moduletypemapping_add",
+ title="Add",
+ icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:moduletypemapping_bulk_import",
+ title="Import",
+ icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ ),
),
PluginMenuItem(
- link="plugins:netbox_librenms_plugin:device_status_list",
- link_text="Device Status",
+ link="plugins:netbox_librenms_plugin:modulebaymapping_list",
+ link_text="Module Bay Mappings",
permissions=[PERM_VIEW_PLUGIN],
+ buttons=(
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:modulebaymapping_add",
+ title="Add",
+ icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:modulebaymapping_bulk_import",
+ title="Import",
+ icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ ),
),
PluginMenuItem(
- link="plugins:netbox_librenms_plugin:vm_status_list",
- link_text="VM Status",
+ link="plugins:netbox_librenms_plugin:platformmapping_list",
+ link_text="Platform Mappings",
+ permissions=[PERM_VIEW_PLUGIN],
+ buttons=(
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:platformmapping_add",
+ title="Add",
+ icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:platformmapping_bulk_import",
+ title="Import",
+ icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ ),
+ ),
+ PluginMenuItem(
+ link="plugins:netbox_librenms_plugin:inventoryignorerule_list",
+ link_text="Inventory Ignore Rules",
+ permissions=[PERM_VIEW_PLUGIN],
+ buttons=(
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:inventoryignorerule_add",
+ title="Add",
+ icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:inventoryignorerule_bulk_import",
+ title="Import",
+ icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ ),
+ ),
+ PluginMenuItem(
+ link="plugins:netbox_librenms_plugin:normalizationrule_list",
+ link_text="Normalization Rules",
+ permissions=[PERM_VIEW_PLUGIN],
+ buttons=(
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:normalizationrule_add",
+ title="Add",
+ icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:normalizationrule_bulk_import",
+ title="Import",
+ icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ ),
+ ),
+ PluginMenuItem(
+ link="plugins:netbox_librenms_plugin:carrierautoinstallrule_list",
+ link_text="Carrier Auto-Install Rules",
+ permissions=[PERM_VIEW_PLUGIN],
+ buttons=(
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:carrierautoinstallrule_add",
+ title="Add",
+ icon_class="mdi mdi-plus-thick",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ PluginMenuButton(
+ link="plugins:netbox_librenms_plugin:carrierautoinstallrule_bulk_import",
+ title="Import",
+ icon_class="mdi mdi-upload",
+ permissions=[PERM_CHANGE_PLUGIN],
+ ),
+ ),
+ ),
+ ),
+ ),
+ (
+ "Settings",
+ (
+ PluginMenuItem(
+ link="plugins:netbox_librenms_plugin:settings",
+ link_text="Plugin Settings",
permissions=[PERM_VIEW_PLUGIN],
),
),
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 68fb8d2641..ab6f0d96a5 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
@@ -234,6 +234,82 @@
return cookieValue;
}
+ // ============================================
+ // ERROR TOAST DISPLAY
+ // ============================================
+
+ /**
+ * Build and show a Bootstrap toast for an HTMX error response.
+ * Appends to NetBox's #django-messages container so it uses the same
+ * styling and stacking as Django messages. Falls back to console.error
+ * if Bootstrap or the container is unavailable.
+ *
+ * Accepts either an XHR (from htmx:responseError) or a plain string message
+ * (from the librenmsError HX-Trigger event dispatched by the server).
+ *
+ * @param {XMLHttpRequest|string|null} source
+ */
+ function showErrorToast(source) {
+ if (!source) {
+ return;
+ }
+ const isXhr = typeof source === 'object' && 'responseText' in source;
+ const container = document.getElementById('django-messages');
+ if (!container || typeof bootstrap === 'undefined' || !bootstrap.Toast) {
+ if (isXhr) {
+ console.error('LibreNMS plugin: server error', source.status, source.responseText);
+ } else {
+ console.error('LibreNMS plugin: server error', source);
+ }
+ return;
+ }
+
+ // Truncate very long error bodies (some Django validation traces are huge).
+ let raw;
+ if (isXhr) {
+ raw = (source.responseText || '').trim();
+ if (!raw) {
+ raw = `Request failed with status ${source.status}`;
+ }
+ } else {
+ raw = String(source).trim() || 'Server error';
+ }
+ const MAX = 600;
+ if (raw.length > MAX) {
+ raw = raw.slice(0, MAX) + '\u2026';
+ }
+
+ const toast = document.createElement('div');
+ toast.className = 'toast toast-dark border-0 shadow-sm';
+ toast.setAttribute('role', 'alert');
+ toast.setAttribute('aria-live', 'assertive');
+ toast.setAttribute('aria-atomic', 'true');
+ toast.setAttribute('data-bs-delay', '12000');
+
+ const header = document.createElement('div');
+ header.className = 'toast-header text-bg-danger';
+ const icon = document.createElement('i');
+ icon.className = 'mdi mdi-alert-circle me-1';
+ header.appendChild(icon);
+ header.appendChild(document.createTextNode(' Error'));
+ const closeBtn = document.createElement('button');
+ closeBtn.type = 'button';
+ closeBtn.className = 'btn-close me-0 m-auto';
+ closeBtn.setAttribute('data-bs-dismiss', 'toast');
+ closeBtn.setAttribute('aria-label', 'Close');
+ header.appendChild(closeBtn);
+
+ const body = document.createElement('div');
+ body.className = 'toast-body';
+ // Use textContent to keep server response untrusted-safe (no HTML injection).
+ body.textContent = raw;
+
+ toast.appendChild(header);
+ toast.appendChild(body);
+ container.appendChild(toast);
+ bootstrap.Toast.getOrCreateInstance(toast).show();
+ }
+
// ============================================
// USER PREFERENCE PERSISTENCE
// ============================================
@@ -275,8 +351,10 @@
function initializeTogglePrefs() {
const sysname = document.getElementById('use-sysname-toggle');
const strip = document.getElementById('strip-domain-toggle');
+ const ipam = document.getElementById('auto-create-ipam-toggle');
if (sysname) sysname.addEventListener('change', function () { savePref('use_sysname', this.checked); });
if (strip) strip.addEventListener('change', function () { savePref('strip_domain', this.checked); });
+ if (ipam) ipam.addEventListener('change', function () { savePref('auto_create_ipam', this.checked); });
}
// ============================================
@@ -1035,7 +1113,14 @@
if (event.target === bulkImportBtn && pendingRowImport) {
restoreSelectionState(pendingRowImport.previousSelections);
pendingRowImport = null;
+ return;
}
+ // Fallback for genuine 5xx / unexpected 4xx responses that bypass
+ // the server-side _htmx_error_response helper (which returns 200 +
+ // an OOB toast for expected validation errors).
+ try {
+ showErrorToast(event.detail && event.detail.xhr);
+ } catch (_) { /* never let toast-rendering break HTMX flow */ }
});
// SessionStorage management for device roles
@@ -1089,9 +1174,13 @@
return;
}
- if (modalContent && modalContent.innerHTML.trim().length === 0 && event.detail.xhr) {
- modalContent.innerHTML = event.detail.xhr.responseText;
- }
+ // NOTE: Do NOT fall back to `modalContent.innerHTML = xhr.responseText`
+ // when the swap leaves the modal empty. That would inject the response
+ // without going through htmx.process(), so any inner forms with hx-post
+ // would not be HTMX-instrumented and would submit natively, navigating
+ // the browser to the raw response (e.g. a 400 with a plain validation
+ // error message). HTMX has already performed the swap; if the response
+ // body was empty, leaving the modal empty is the correct behaviour.
// Initialize Bootstrap tooltips inside the freshly-swapped modal content
if (typeof bootstrap !== 'undefined' && bootstrap.Tooltip) {
@@ -1112,15 +1201,62 @@
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. We also avoid
+ // preventDefault here so form submit buttons that happen to
+ // carry data-bs-dismiss="modal" in nested modals still submit.
+ const nearestModal = dismissTrigger.closest('.modal');
+ if (nearestModal === modalElement) {
+ event.preventDefault();
hideModal(modalElement, fallbackBackdropRef);
}
}
});
+ // Refresh the validation modal in place (used after promote / OOB
+ // attach actions that mutate device link state but should leave the
+ // user inside the modal so they can see the new state). Also closes
+ // any nested modals (e.g. the Promote-to-host pick modal) before
+ // re-fetching so the user sees the refreshed validation directly.
+ document.body.addEventListener('validationRefresh', function (event) {
+ // Close any nested Bootstrap modals currently open inside the
+ // outer validation modal content. `window.bootstrap` is not
+ // exposed by NetBox so we fall back to plain DOM toggling.
+ document.querySelectorAll('#htmx-modal-content .modal.show').forEach(function (nested) {
+ try {
+ if (window.bootstrap) {
+ bootstrap.Modal.getOrCreateInstance(nested).hide();
+ } else {
+ nested.classList.remove('show');
+ nested.style.display = 'none';
+ nested.setAttribute('aria-hidden', 'true');
+ }
+ } catch (err) {
+ // Swallow - we still want to refresh the validation panel.
+ }
+ });
+
+ const deviceId = event.detail && (event.detail.deviceId || event.detail.device_id);
+ if (!deviceId) {
+ return;
+ }
+ const btn = document.querySelector(
+ 'tr#device-row-' + deviceId + ' button[hx-get*="/validation/' + deviceId + '/"]'
+ );
+ if (btn) {
+ // htmx registers a delegated click handler on document, so a
+ // synthetic MouseEvent click on the row's "View details"
+ // button re-triggers the validation GET and swaps the new
+ // content into #htmx-modal-content. We cannot call
+ // `htmx.trigger()` directly because NetBox does not expose
+ // the htmx global to user scripts.
+ btn.dispatchEvent(new MouseEvent('click', {bubbles: true, cancelable: true, view: window}));
+ }
+ });
+
// Handle backdrop clicks for HTMX modal
modalElement?.addEventListener('click', function (event) {
if (event.target === modalElement) {
diff --git a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
index 1521b3d29e..7530fffdac 100644
--- a/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
+++ b/netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js
@@ -102,8 +102,7 @@ function hideModal(el) {
el.style.display = 'none';
el.setAttribute('aria-hidden', 'true');
el.removeAttribute('aria-modal');
- const backdrop = document.querySelector('.modal-backdrop');
- if (backdrop) backdrop.remove();
+ document.querySelectorAll('.modal-backdrop').forEach((backdrop) => backdrop.remove());
document.body.classList.remove('modal-open');
document.body.style.removeProperty('padding-right');
document.body.style.removeProperty('overflow');
@@ -125,6 +124,25 @@ function getCookie(name) {
return cookieValue;
}
+/**
+ * Extract a human-readable error message from a non-2xx fetch Response.
+ * Attempts JSON parse first, checking error/message/detail fields.
+ * Falls back to raw response text. Truncates to 300 characters.
+ * @param {Response} response
+ * @returns {Promise}
+ */
+function fetchErrorMessage(response) {
+ return response.text().then(t => {
+ const ct = (response.headers.get('Content-Type') || '').toLowerCase();
+ let msg = t || `HTTP ${response.status}`;
+ if (ct.includes('application/json')) {
+ try { const d = JSON.parse(t); msg = d.error || d.message || d.detail || msg; } catch (_) {}
+ }
+ if (msg.length > 300) msg = msg.slice(0, 300) + '...';
+ return msg;
+ });
+}
+
/**
* Extract device/VM ID and type from current URL pathname.
* Supports multiple URL patterns:
@@ -248,10 +266,15 @@ function initializeCountdowns() {
if (window.vlanCountdownInterval) {
clearInterval(window.vlanCountdownInterval);
}
+ if (window.moduleCountdownInterval) {
+ clearInterval(window.moduleCountdownInterval);
+ }
+
window.interfaceCountdownInterval = initializeCountdown("countdown-timer");
window.cableCountdownInterval = initializeCountdown("cable-countdown-timer");
window.ipCountdownInterval = initializeCountdown("ip-countdown-timer");
window.vlanCountdownInterval = initializeCountdown("vlan-countdown-timer");
+ window.moduleCountdownInterval = initializeCountdown("module-countdown-timer");
}
// ============================================
@@ -311,6 +334,7 @@ function initializeCheckboxes() {
initializeTableCheckboxes('librenms-ipaddress-table');
initializeTableCheckboxes('librenms-vlan-table');
initializeTableCheckboxes('librenms-port-vlan-table');
+ initializeTableCheckboxes('librenms-module-table');
}
// ============================================
@@ -325,6 +349,7 @@ function initializeVCMemberSelect() {
setTimeout(() => {
const interfaceTable = document.getElementById('librenms-interface-table');
const cableTable = document.getElementById('librenms-cable-table-vc');
+ const moduleTable = document.getElementById('librenms-module-table');
if (interfaceTable) {
// Only target VC member selects, exclude VLAN group selects
@@ -350,6 +375,23 @@ function initializeVCMemberSelect() {
}
});
}
+
+ if (moduleTable) {
+ const moduleSelects = moduleTable.querySelectorAll('.vc-member-select');
+ moduleSelects.forEach(select => {
+ if (select.tomselect && !select.dataset.moduleSelectInitialized) {
+ select.dataset.moduleSelectInitialized = 'true';
+ select.tomselect.on('change', function (value) {
+ handleModuleChange(select, value);
+ });
+ } else if (!select.tomselect && !select.dataset.moduleSelectInitialized) {
+ select.dataset.moduleSelectInitialized = 'true';
+ select.addEventListener('change', function () {
+ handleModuleChange(select, this.value);
+ });
+ }
+ });
+ }
}, TOMSELECT_INIT_DELAY_MS);
}
@@ -563,7 +605,7 @@ function verifyVlanInGroup(select, deviceId, vid, vlanType, groupId) {
})
.then(response => {
if (!response.ok) {
- return response.text().then(t => { throw new Error(t || `HTTP ${response.status}`); });
+ return fetchErrorMessage(response).then(msg => { throw new Error(msg); });
}
return response.json();
})
@@ -745,7 +787,7 @@ function initializeVlanModalSave() {
})
}).then(response => {
if (!response.ok) {
- return response.text().then(t => { throw new Error(`HTTP ${response.status}: ${t}`); });
+ return fetchErrorMessage(response).then(msg => { throw new Error(`HTTP ${response.status}: ${msg}`); });
}
// Apply DOM mutations only after the server has persisted the overrides
applyButtonUpdates();
@@ -819,7 +861,7 @@ function verifyVlanSyncGroup(select, vid, vlanName, groupId) {
})
.then(response => {
if (!response.ok) {
- throw new Error('HTTP ' + response.status);
+ return fetchErrorMessage(response).then(msg => { throw new Error(`HTTP ${response.status}: ${msg}`); });
}
return response.json();
})
@@ -889,7 +931,7 @@ function handleVRFChange(select, value) {
})
.then(response => {
if (!response.ok) {
- return response.text().then(t => { throw new Error(t); });
+ return fetchErrorMessage(response).then(msg => { throw new Error(msg); });
}
return response.json();
})
@@ -931,9 +973,7 @@ function handleInterfaceChange(select, value) {
})
.then(response => {
if (!response.ok) {
- return response.text().then(text => {
- throw new Error(`Server error ${response.status}: ${text}`);
- });
+ return fetchErrorMessage(response).then(msg => { throw new Error(`Server error ${response.status}: ${msg}`); });
}
return response.json();
})
@@ -978,9 +1018,7 @@ function handleCableChange(select, value) {
})
.then(response => {
if (!response.ok) {
- return response.text().then(text => {
- throw new Error(`Server error ${response.status}: ${text}`);
- });
+ return fetchErrorMessage(response).then(msg => { throw new Error(`Server error ${response.status}: ${msg}`); });
}
return response.json();
})
@@ -1001,6 +1039,85 @@ function handleCableChange(select, value) {
});
}
+/**
+ * Handle VC member selection change for module verification.
+ * Fetches recalculated matching status for one module row and updates cells inline.
+ *
+ * @param {HTMLSelectElement} select - VC member dropdown for a module row
+ * @param {string} value - Selected NetBox device ID
+ */
+function handleModuleChange(select, value) {
+ const row = document.querySelector(`tr[data-ent-index="${select.dataset.rowId}"]`);
+ const rowDepth = row?.dataset?.depth || 0;
+
+ // Abort any in-flight verify for this select so a slower earlier response
+ // can't clobber a faster later one when the user changes the dropdown rapidly.
+ if (select._moduleVerifyController) {
+ select._moduleVerifyController.abort();
+ }
+ const controller = new AbortController();
+ select._moduleVerifyController = controller;
+
+ fetch('/plugins/librenms_plugin/verify-module/', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-CSRFToken': document.querySelector('[name=csrfmiddlewaretoken]').value
+ },
+ body: JSON.stringify({
+ device_id: value,
+ ent_physical_index: select.dataset.module,
+ depth: rowDepth,
+ server_key: document.querySelector('input[name="server_key"]')?.value || null
+ }),
+ signal: controller.signal
+ })
+ .then(response => {
+ if (!response.ok) {
+ return fetchErrorMessage(response).then(msg => { throw new Error(`Server error ${response.status}: ${msg}`); });
+ }
+ return response.json();
+ })
+ .then(data => {
+ if (!row || data.status !== 'success' || !data.formatted_row) return;
+
+ const formattedRow = data.formatted_row;
+ const deviceSelCell = row.querySelector('td[data-col="device_selection"]');
+ if (deviceSelCell) {
+ deviceSelCell.innerHTML = formattedRow.device_selection || '';
+ }
+ row.querySelector('td[data-col="name"]').innerHTML = formattedRow.name;
+ row.querySelector('td[data-col="model"]').innerHTML = formattedRow.model;
+ row.querySelector('td[data-col="serial"]').innerHTML = formattedRow.serial;
+ row.querySelector('td[data-col="description"]').innerHTML = formattedRow.description;
+ row.querySelector('td[data-col="item_class"]').innerHTML = formattedRow.item_class;
+ // Replace each cell content if present. Defensive null-checks keep this
+ // resilient if the row markup ever drops one of these data-col cells.
+ const cellMap = {
+ module_bay: formattedRow.module_bay,
+ module_type: formattedRow.module_type,
+ status: formattedRow.status,
+ actions: formattedRow.actions,
+ };
+ for (const [col, html] of Object.entries(cellMap)) {
+ const cell = row.querySelector(`td[data-col="${col}"]`);
+ if (cell) {
+ cell.innerHTML = html;
+ } else {
+ console.warn(`Module row missing data-col="${col}" cell β skipping update`);
+ }
+ }
+
+ // Re-bind listeners because row controls (select/buttons/forms) were replaced.
+ initializeVCMemberSelect();
+ initializeModuleReplaceButtons();
+ })
+ .catch(error => {
+ if (error.name === 'AbortError') return;
+ console.error('Error verifying module:', error.message);
+ });
+}
+
/**
* Initialize bulk VC member assignment functionality.
* Applies selected VC member to all checked interfaces.
@@ -1299,7 +1416,7 @@ function updateInterfaceNameField() {
// Persist to user preferences via API
const savePrefUrl = this.closest('[data-save-pref-url]')?.dataset.savePrefUrl;
if (savePrefUrl) {
- const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken');
+ const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
if (csrfToken) {
fetch(savePrefUrl, {
method: 'POST',
@@ -1425,6 +1542,11 @@ function deleteSelectedInterfaces(selectedCheckboxes) {
}
})
.then(response => {
+ if (!response.ok) {
+ return fetchErrorMessage(response).then(msg => {
+ throw new Error(`HTTP ${response.status} ${response.statusText}: ${msg}`);
+ });
+ }
return response.json();
})
.then(data => {
@@ -1484,15 +1606,18 @@ function initializeSyncFormSpinners() {
button.dataset.spinnerInitialized = 'true';
button.addEventListener('htmx:beforeRequest', function () {
- const originalText = button.textContent.trim();
- button.dataset.originalText = originalText;
+ button.dataset.originalHtml = button.innerHTML;
button.disabled = true;
- button.innerHTML = '' + originalText;
+ const label = button.textContent.trim();
+ const spinner = document.createElement('span');
+ spinner.className = 'spinner-border spinner-border-sm me-2';
+ button.textContent = label;
+ button.insertBefore(spinner, button.firstChild);
});
button.addEventListener('htmx:afterRequest', function () {
button.disabled = false;
- button.innerHTML = button.dataset.originalText || button.textContent;
+ button.innerHTML = button.dataset.originalHtml;
});
});
}
@@ -1520,6 +1645,16 @@ function handleInstallSelectedSubmit() {
hidden.value = cb.value;
hidden.dataset.injectedSelect = '1';
form.appendChild(hidden);
+
+ const selectedDevice = table.querySelector(`#device_selection_${cb.value}`);
+ if (selectedDevice) {
+ const hiddenDevice = document.createElement('input');
+ hiddenDevice.type = 'hidden';
+ hiddenDevice.name = `device_selection_${cb.value}`;
+ hiddenDevice.value = selectedDevice.value;
+ hiddenDevice.dataset.injectedSelect = '1';
+ form.appendChild(hiddenDevice);
+ }
});
}
@@ -1559,11 +1694,13 @@ function initializeModuleReplaceButtons() {
const moduleId = this.dataset.moduleId;
const entIndex = this.dataset.entIndex;
const serverKey = this.dataset.serverKey;
+ const selectedDeviceId = this.dataset.selectedDeviceId;
const params = new URLSearchParams({
module_id: moduleId,
ent_index: entIndex,
server_key: serverKey,
+ selected_device_id: selectedDeviceId,
});
// Show shared HTMX modal with loading state
@@ -1571,7 +1708,7 @@ function initializeModuleReplaceButtons() {
if (modalContent) {
modalContent.innerHTML =
'
' +
- '
Module Mismatch
' +
+ '
Module Mismatch
' +
'' +
'
' +
'
' +
@@ -1583,7 +1720,7 @@ function initializeModuleReplaceButtons() {
showModal(document.getElementById('htmx-modal'));
// Fetch preview content and inject into modal body
- const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || getCookie('csrftoken');
+ const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value;
const fetchHeaders = {};
if (csrfToken) {
fetchHeaders['X-CSRFToken'] = csrfToken;
@@ -1593,14 +1730,17 @@ function initializeModuleReplaceButtons() {
headers: fetchHeaders,
})
.then(response => {
- if (!response.ok) return response.text().then(t => { throw new Error(t); });
+ if (!response.ok) return fetchErrorMessage(response).then(msg => { throw new Error(msg); });
return response.text();
})
.then(html => {
const modalBody = document.getElementById('htmx-modal-body');
if (modalBody) {
modalBody.innerHTML = html;
- htmx.process(modalBody);
+ if (typeof htmx !== 'undefined') {
+ htmx.process(modalBody);
+ }
+ updateHtmxModalLabel();
}
})
.catch(err => {
@@ -1680,15 +1820,30 @@ document.body.addEventListener('htmx:afterSwap', function (event) {
// Update HTMX modal accessible label after content loads so screen readers
// announce the actual dialog title rather than the static "Loading" placeholder.
-document.addEventListener('DOMContentLoaded', function () {
+function updateHtmxModalLabel() {
const htmxModal = document.getElementById('htmx-modal');
- if (htmxModal) {
- htmxModal.addEventListener('htmx:afterSettle', function () {
- const header = htmxModal.querySelector('.modal-title, .modal-header h5, .modal-header h4');
- const label = document.getElementById('htmx-modal-label');
- if (header && label) {
- label.textContent = header.textContent.trim();
- }
- });
+ if (!htmxModal) return;
+ const modalBody = htmxModal.querySelector('#htmx-modal-body') || htmxModal;
+ const header = modalBody.querySelector('.modal-title, .modal-header h5, .modal-header h4');
+ const labelId = htmxModal.getAttribute('aria-labelledby');
+ const label = (labelId && document.getElementById(labelId)) || document.getElementById('htmx-modal-label');
+ if (header && label && header !== label) {
+ label.textContent = header.textContent.trim();
+ }
+}
+
+// Listen at document level so the handler fires regardless of which element
+// HTMX dispatches afterSettle on (swap target or ancestor).
+document.addEventListener('htmx:afterSettle', function (event) {
+ const htmxModal = document.getElementById('htmx-modal');
+ if (htmxModal && (htmxModal === event.target || htmxModal.contains(event.target))) {
+ updateHtmxModalLabel();
+ // Auto-show the shared HTMX modal whenever new content is swapped into
+ // it (e.g. the Add Bay Template flow). Buttons that target
+ // #htmx-modal-content via hx-get no longer need to wire their own
+ // bootstrap.Modal.show() call.
+ if (!htmxModal.classList.contains('show')) {
+ showModal(htmxModal);
+ }
}
});
diff --git a/netbox_librenms_plugin/tables/__init__.py b/netbox_librenms_plugin/tables/__init__.py
index 32bade63f0..17a8fb7da5 100644
--- a/netbox_librenms_plugin/tables/__init__.py
+++ b/netbox_librenms_plugin/tables/__init__.py
@@ -3,18 +3,34 @@
from .interfaces import LibreNMSInterfaceTable, LibreNMSVMInterfaceTable, VCInterfaceTable
from .ipaddresses import IPAddressTable
from .locations import SiteLocationSyncTable
-from .mappings import InterfaceTypeMappingTable
+from .mappings import (
+ CarrierAutoInstallRuleTable,
+ DeviceTypeMappingTable,
+ InterfaceTypeMappingTable,
+ InventoryIgnoreRuleTable,
+ ModuleBayMappingTable,
+ ModuleTypeMappingTable,
+ NormalizationRuleTable,
+ PlatformMappingTable,
+)
from .vlans import LibreNMSVLANTable
from .VM_status import VMStatusTable
__all__ = [
+ "CarrierAutoInstallRuleTable",
"DeviceStatusTable",
+ "DeviceTypeMappingTable",
"InterfaceTypeMappingTable",
+ "InventoryIgnoreRuleTable",
"IPAddressTable",
"LibreNMSCableTable",
"LibreNMSInterfaceTable",
"LibreNMSVLANTable",
"LibreNMSVMInterfaceTable",
+ "ModuleBayMappingTable",
+ "ModuleTypeMappingTable",
+ "NormalizationRuleTable",
+ "PlatformMappingTable",
"SiteLocationSyncTable",
"VCInterfaceTable",
"VMStatusTable",
diff --git a/netbox_librenms_plugin/tables/cables.py b/netbox_librenms_plugin/tables/cables.py
index cad4660b34..5bf3f479cb 100644
--- a/netbox_librenms_plugin/tables/cables.py
+++ b/netbox_librenms_plugin/tables/cables.py
@@ -61,9 +61,14 @@ def render_remote_device(self, value, record):
def render_local_port(self, value, record):
"""Render local port name as a link if URL is available."""
+ oob_badge = (
+ format_html(' OOB')
+ if record.get("_source") == "oob"
+ else ""
+ )
if url := record.get("local_port_url"):
- return format_html('{}', url, value)
- return value
+ return format_html('{}{}', url, value, oob_badge)
+ return format_html("{}{}", value or "", oob_badge)
def render_remote_port(self, value, record):
"""Render remote port name as a link if URL is available."""
diff --git a/netbox_librenms_plugin/tables/device_status.py b/netbox_librenms_plugin/tables/device_status.py
index 995a623ec4..5778f54328 100644
--- a/netbox_librenms_plugin/tables/device_status.py
+++ b/netbox_librenms_plugin/tables/device_status.py
@@ -463,11 +463,34 @@ def render_actions(self, value, record):
match_type = validation.get("existing_match_type", "")
serial_action = validation.get("serial_action")
has_mismatch = validation.get("device_type_mismatch", False)
- has_actions = match_type == "hostname" or (match_type == "serial" and serial_action is not None)
+ is_oob_candidate = serial_action == "oob_candidate"
+ is_oob_linked = match_type == "librenms_oob"
+ has_actions = match_type == "hostname" or (
+ match_type == "serial" and serial_action is not None and not is_oob_candidate
+ )
has_name_sync = validation.get("name_sync_available", False)
has_sync_needed = match_type == "librenms_id" and serial_action in ("update_serial", "conflict")
- if has_mismatch:
+ existing_link = validation.get("existing_librenms_link") or {}
+ paired_oob_id = existing_link.get("oob_id")
+ paired_host_id = existing_link.get("host_id")
+ paired_oob_type = existing_link.get("oob_type") or "OOB"
+
+ if is_oob_candidate:
+ btn_class = "btn-outline-purple"
+ btn_icon = "mdi-chip"
+ btn_label = " OOB"
+ btn_title = "Add as OOB controller"
+ elif is_oob_linked:
+ # This LibreNMS row is the OOB half of an existing pair.
+ btn_class = "btn-outline-info"
+ btn_icon = "mdi-chip"
+ btn_label = " OOB"
+ if paired_host_id is not None:
+ btn_title = f"Linked as OOB controller (paired host: LibreNMS #{int(paired_host_id)})"
+ else:
+ btn_title = "Linked as OOB controller"
+ elif has_mismatch:
btn_class = "btn-outline-danger"
btn_icon = "mdi-alert-circle"
btn_label = " Conflict"
@@ -487,6 +510,21 @@ def render_actions(self, value, record):
btn_icon = "mdi-database-alert"
btn_label = " Legacy ID"
btn_title = "View legacy ID migration details"
+ elif match_type == "librenms_id" and paired_oob_id is not None and paired_oob_id != paired_host_id:
+ # This LibreNMS row is the host half of an existing host/OOB
+ # pair. Render it with the same info-tinted styling as the OOB
+ # row so the user sees them as one paired device rather than
+ # two unrelated statuses (one green "ready", one blue "OOB").
+ btn_class = "btn-outline-info"
+ btn_icon = "mdi-server-network"
+ btn_label = " Host"
+ # paired_oob_type comes from a user-editable custom field
+ # (librenms_id..oob.type) and is only string-type-checked,
+ # not sanitised, on the read path. Escape before interpolating
+ # into the title attribute to prevent stored XSS.
+ btn_title = (
+ f"Linked as host (paired OOB: LibreNMS #{int(paired_oob_id)}, {escape(paired_oob_type or '')})"
+ )
else:
btn_class = "btn-outline-success"
btn_icon = "mdi-check-circle"
diff --git a/netbox_librenms_plugin/tables/interfaces.py b/netbox_librenms_plugin/tables/interfaces.py
index d99d2a7bfe..5a3da85a07 100644
--- a/netbox_librenms_plugin/tables/interfaces.py
+++ b/netbox_librenms_plugin/tables/interfaces.py
@@ -300,7 +300,15 @@ def render_speed(self, value, record):
def render_name(self, value, record):
"""Render interface name with appropriate styling based on comparison with NetBox"""
- return self._render_field(value, record, self.interface_name_field, "name")
+ rendered = self._render_field(value, record, self.interface_name_field, "name")
+ badges = ""
+ if record.get("_source") == "oob":
+ badges += 'OOB'
+ if record.get("_dedup_conflict"):
+ badges += 'Shared LOM'
+ if badges:
+ return format_html("{}{}", rendered, mark_safe(badges))
+ return rendered
def _get_interface_status_display(self, enabled, record):
"""
diff --git a/netbox_librenms_plugin/tables/mappings.py b/netbox_librenms_plugin/tables/mappings.py
index 73949fd2c8..1a270d144f 100644
--- a/netbox_librenms_plugin/tables/mappings.py
+++ b/netbox_librenms_plugin/tables/mappings.py
@@ -1,7 +1,17 @@
import django_tables2 as tables
+from django.utils.html import format_html, mark_safe
from netbox.tables import NetBoxTable, columns
-from netbox_librenms_plugin.models import InterfaceTypeMapping
+from netbox_librenms_plugin.models import (
+ CarrierAutoInstallRule,
+ DeviceTypeMapping,
+ InterfaceTypeMapping,
+ InventoryIgnoreRule,
+ ModuleBayMapping,
+ ModuleTypeMapping,
+ NormalizationRule,
+ PlatformMapping,
+)
class InterfaceTypeMappingTable(NetBoxTable):
@@ -20,6 +30,7 @@ class Meta:
model = InterfaceTypeMapping
fields = (
+ "pk",
"id",
"librenms_type",
"librenms_speed",
@@ -28,6 +39,7 @@ class Meta:
"actions",
)
default_columns = (
+ "pk",
"id",
"librenms_type",
"librenms_speed",
@@ -36,3 +48,282 @@ class Meta:
"actions",
)
attrs = {"class": "table table-hover table-headings table-striped"}
+
+
+class DeviceTypeMappingTable(NetBoxTable):
+ """Table for displaying DeviceTypeMapping data."""
+
+ librenms_hardware = tables.Column(verbose_name="LibreNMS Hardware", linkify=True)
+ netbox_device_type = tables.Column(verbose_name="NetBox Device Type", linkify=True)
+ description = tables.Column(verbose_name="Description", linkify=False)
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
+
+ class Meta:
+ """Meta options for DeviceTypeMappingTable."""
+
+ model = DeviceTypeMapping
+ fields = (
+ "pk",
+ "id",
+ "librenms_hardware",
+ "netbox_device_type",
+ "description",
+ "actions",
+ )
+ default_columns = (
+ "pk",
+ "id",
+ "librenms_hardware",
+ "netbox_device_type",
+ "description",
+ "actions",
+ )
+ attrs = {"class": "table table-hover table-headings table-striped"}
+
+
+class ModuleTypeMappingTable(NetBoxTable):
+ """Table for displaying ModuleTypeMapping data."""
+
+ librenms_model = tables.Column(verbose_name="LibreNMS Model", linkify=True)
+ netbox_module_type = tables.Column(verbose_name="NetBox Module Type", linkify=True)
+ manufacturer = tables.Column(verbose_name="Manufacturer", linkify=True)
+ description = tables.Column(verbose_name="Description", linkify=False)
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
+
+ class Meta:
+ """Meta options for ModuleTypeMappingTable."""
+
+ model = ModuleTypeMapping
+ fields = (
+ "pk",
+ "id",
+ "librenms_model",
+ "netbox_module_type",
+ "manufacturer",
+ "description",
+ "actions",
+ )
+ default_columns = (
+ "pk",
+ "id",
+ "librenms_model",
+ "netbox_module_type",
+ "manufacturer",
+ "description",
+ "actions",
+ )
+ attrs = {"class": "table table-hover table-headings table-striped"}
+
+
+class ModuleBayMappingTable(NetBoxTable):
+ """Table for displaying ModuleBayMapping data."""
+
+ librenms_name = tables.Column(verbose_name="LibreNMS Name", linkify=True)
+ librenms_class = tables.Column(verbose_name="LibreNMS Class")
+ netbox_bay_name = tables.Column(verbose_name="NetBox Bay Name")
+ is_regex = columns.BooleanColumn(verbose_name="Regex")
+ manufacturer = tables.Column(verbose_name="Manufacturer", linkify=True)
+ description = tables.Column(verbose_name="Description", linkify=False)
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
+
+ class Meta:
+ """Meta options for ModuleBayMappingTable."""
+
+ model = ModuleBayMapping
+ fields = (
+ "pk",
+ "id",
+ "librenms_name",
+ "librenms_class",
+ "netbox_bay_name",
+ "is_regex",
+ "manufacturer",
+ "description",
+ "actions",
+ )
+ default_columns = (
+ "pk",
+ "id",
+ "librenms_name",
+ "librenms_class",
+ "netbox_bay_name",
+ "is_regex",
+ "manufacturer",
+ "description",
+ "actions",
+ )
+ attrs = {"class": "table table-hover table-headings table-striped"}
+
+
+class NormalizationRuleTable(NetBoxTable):
+ """Table for displaying NormalizationRule data."""
+
+ scope = tables.Column(verbose_name="Scope", linkify=True)
+ manufacturer = tables.Column(verbose_name="Manufacturer", linkify=True)
+ match_pattern = tables.Column(verbose_name="Match Pattern")
+ replacement = tables.Column(verbose_name="Replacement")
+ priority = tables.Column(verbose_name="Priority")
+ description = tables.Column(verbose_name="Description", linkify=False)
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
+
+ class Meta:
+ """Meta options for NormalizationRuleTable."""
+
+ model = NormalizationRule
+ fields = (
+ "pk",
+ "id",
+ "scope",
+ "manufacturer",
+ "match_pattern",
+ "replacement",
+ "priority",
+ "description",
+ "actions",
+ )
+ default_columns = (
+ "pk",
+ "id",
+ "scope",
+ "manufacturer",
+ "match_pattern",
+ "replacement",
+ "priority",
+ "actions",
+ )
+ attrs = {"class": "table table-hover table-headings table-striped"}
+
+
+class InventoryIgnoreRuleTable(NetBoxTable):
+ """Table for displaying InventoryIgnoreRule data."""
+
+ name = tables.Column(verbose_name="Name", linkify=True)
+ match_type = tables.Column(verbose_name="Match Type")
+ action = tables.Column(verbose_name="Action")
+ pattern = tables.Column(verbose_name="Pattern", empty_values=())
+ require_serial_match_parent = tables.BooleanColumn(verbose_name="Require Serial Match")
+ enabled = tables.BooleanColumn(verbose_name="Enabled")
+ description = tables.Column(verbose_name="Description", linkify=False)
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
+
+ def render_action(self, value, record):
+ """Display the human-readable action label."""
+ return record.get_action_display()
+
+ def render_pattern(self, value, record):
+ """Show dash for serial_matches_device rules where pattern is unused."""
+ if record.match_type == InventoryIgnoreRule.MATCH_SERIAL_DEVICE:
+ return mark_safe('β')
+ return format_html("{}", value) if value else "β"
+
+ def render_require_serial_match_parent(self, value, record):
+ """Show the actual stored boolean; dash for rules where the flag has no effect."""
+ if record.match_type == InventoryIgnoreRule.MATCH_SERIAL_DEVICE:
+ return mark_safe('β')
+ return (
+ mark_safe('Yes')
+ if value
+ else mark_safe('No')
+ )
+
+ class Meta:
+ """Meta options for InventoryIgnoreRuleTable."""
+
+ model = InventoryIgnoreRule
+ fields = (
+ "pk",
+ "id",
+ "name",
+ "match_type",
+ "action",
+ "pattern",
+ "require_serial_match_parent",
+ "enabled",
+ "description",
+ "actions",
+ )
+ default_columns = (
+ "pk",
+ "id",
+ "name",
+ "match_type",
+ "action",
+ "pattern",
+ "require_serial_match_parent",
+ "enabled",
+ "actions",
+ )
+ attrs = {"class": "table table-hover table-headings table-striped"}
+
+
+class PlatformMappingTable(NetBoxTable):
+ """Table for displaying PlatformMapping data."""
+
+ librenms_os = tables.Column(verbose_name="LibreNMS OS", linkify=True)
+ netbox_platform = tables.Column(verbose_name="NetBox Platform", linkify=True)
+ description = tables.Column(verbose_name="Description", linkify=False)
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
+
+ class Meta:
+ """Meta options for PlatformMappingTable."""
+
+ model = PlatformMapping
+ fields = (
+ "pk",
+ "id",
+ "librenms_os",
+ "netbox_platform",
+ "description",
+ "actions",
+ )
+ default_columns = (
+ "pk",
+ "id",
+ "librenms_os",
+ "netbox_platform",
+ "description",
+ "actions",
+ )
+ attrs = {"class": "table table-hover table-headings table-striped"}
+
+
+class CarrierAutoInstallRuleTable(NetBoxTable):
+ """Table for displaying CarrierAutoInstallRule data."""
+
+ manufacturer = tables.Column(verbose_name="Manufacturer", linkify=True)
+ device_type_pattern = tables.Column(verbose_name="Device Type Pattern")
+ librenms_child_class = tables.Column(verbose_name="LibreNMS Child Class")
+ librenms_child_name_pattern = tables.Column(verbose_name="LibreNMS Child Name Pattern")
+ netbox_bay_name_pattern = tables.Column(verbose_name="NetBox Bay Name Pattern")
+ carrier_module_type = tables.Column(verbose_name="Carrier Module Type", linkify=True)
+ description = tables.Column(verbose_name="Description", linkify=False)
+ actions = columns.ActionsColumn(actions=("edit", "delete"))
+
+ class Meta:
+ """Meta options for CarrierAutoInstallRuleTable."""
+
+ model = CarrierAutoInstallRule
+ fields = (
+ "pk",
+ "id",
+ "manufacturer",
+ "device_type_pattern",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ "carrier_module_type",
+ "description",
+ "actions",
+ )
+ default_columns = (
+ "pk",
+ "id",
+ "manufacturer",
+ "librenms_child_class",
+ "librenms_child_name_pattern",
+ "netbox_bay_name_pattern",
+ "carrier_module_type",
+ "description",
+ "actions",
+ )
+ attrs = {"class": "table table-hover table-headings table-striped"}
diff --git a/netbox_librenms_plugin/tables/modules.py b/netbox_librenms_plugin/tables/modules.py
new file mode 100644
index 0000000000..e1deb532dc
--- /dev/null
+++ b/netbox_librenms_plugin/tables/modules.py
@@ -0,0 +1,773 @@
+from urllib.parse import urlencode, urlparse
+
+import re
+
+import django_tables2 as tables
+from django.urls import reverse
+from django.utils.html import escape, format_html, mark_safe
+from netbox.tables.columns import ToggleColumn
+from utilities.paginator import EnhancedPaginator
+
+from netbox_librenms_plugin.utils import get_table_paginate_count
+
+
+class LibreNMSModuleTable(tables.Table):
+ """Table for displaying LibreNMS inventory items mapped to NetBox modules."""
+
+ selection = ToggleColumn(
+ orderable=False,
+ visible=True,
+ accessor="ent_physical_index",
+ attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}},
+ )
+ name = tables.Column(
+ verbose_name="Name",
+ empty_values=(),
+ attrs={
+ "td": {"data-col": "name"},
+ "th": {
+ "title": "Name from ENTITY-MIB (entPhysicalName). May differ from interface names in ifDescr/ifName."
+ },
+ },
+ )
+ model = tables.Column(verbose_name="Model", empty_values=(), attrs={"td": {"data-col": "model"}})
+ serial = tables.Column(verbose_name="Serial", empty_values=(), attrs={"td": {"data-col": "serial"}})
+ description = tables.Column(verbose_name="Description", empty_values=(), attrs={"td": {"data-col": "description"}})
+ item_class = tables.Column(verbose_name="Class", empty_values=(), attrs={"td": {"data-col": "item_class"}})
+ module_bay = tables.Column(verbose_name="Module Bay", empty_values=(), attrs={"td": {"data-col": "module_bay"}})
+ module_type = tables.Column(verbose_name="Module Type", empty_values=(), attrs={"td": {"data-col": "module_type"}})
+ status = tables.Column(verbose_name="Status", empty_values=(), attrs={"td": {"data-col": "status"}})
+ actions = tables.Column(
+ verbose_name="Actions", orderable=False, empty_values=(), attrs={"td": {"data-col": "actions"}}
+ )
+
+ class Meta:
+ attrs = {"class": "table table-hover object-list", "id": "librenms-module-table"}
+ row_attrs = {
+ "data-ent-index": lambda record: record.get("ent_physical_index", ""),
+ "data-status": lambda record: record.get("status", ""),
+ "data-depth": lambda record: str(record.get("depth", 0)),
+ "data-item-class": lambda record: record.get("item_class", ""),
+ }
+
+ def __init__(
+ self,
+ *args,
+ device=None,
+ server_key="",
+ has_write_permission=False,
+ can_add_module=False,
+ can_change_module=False,
+ can_delete_module=False,
+ can_add_module_bay_template=False,
+ can_add_module_type=False,
+ can_add_carrier_rule=False,
+ can_add_module_bay_mapping=False,
+ can_add_module_type_mapping=False,
+ **kwargs,
+ ):
+ """Initialize table with optional device context."""
+ self.device = device
+ self.csrf_token = ""
+ self.server_key = server_key
+ self.has_write_permission = has_write_permission
+ self.can_add_module = can_add_module
+ self.can_change_module = can_change_module
+ self.can_delete_module = can_delete_module
+ self.can_add_module_bay_template = can_add_module_bay_template
+ self.can_add_module_type = can_add_module_type
+ self.can_add_carrier_rule = can_add_carrier_rule
+ self.can_add_module_bay_mapping = can_add_module_bay_mapping
+ self.can_add_module_type_mapping = can_add_module_type_mapping
+ super().__init__(*args, **kwargs)
+ if not (has_write_permission and can_add_module) and hasattr(self, "columns"):
+ self.columns["selection"].column.visible = False
+ self.tab = "modules"
+ self.htmx_url = None
+ self.prefix = "modules_"
+
+ def configure(self, request):
+ """Configure pagination settings and CSRF token."""
+ from django.middleware.csrf import get_token
+ from django.utils.http import url_has_allowed_host_and_scheme
+
+ self.csrf_token = get_token(request)
+ # Use HX-Current-URL (the real browser URL) when available so that
+ # after saving a mapping the redirect lands on the browsable tab page
+ # (which handles GET) rather than the HTMX-only POST endpoint.
+ if request:
+ # HX-Current-URL is the real browser URL (full absolute URL).
+ # Extract only the path+query so return_url stays relative, which
+ # is what NetBox's ObjectEditView expects. Validate via
+ # url_has_allowed_host_and_scheme to prevent open-redirect attacks
+ # from client-controlled values like "//@example.com/...". Fall
+ # back to the HTMX endpoint path when the header is absent or the
+ # value fails validation.
+ htmx_current = request.headers.get("HX-Current-URL", "")
+ safe_relative = ""
+ if htmx_current and url_has_allowed_host_and_scheme(
+ htmx_current,
+ allowed_hosts={request.get_host()},
+ require_https=request.is_secure(),
+ ):
+ parsed = urlparse(htmx_current)
+ safe_relative = parsed.path
+ if parsed.query:
+ safe_relative = f"{safe_relative}?{parsed.query}"
+ self.return_url = safe_relative or request.get_full_path()
+ else:
+ self.return_url = ""
+ paginate = {"paginator_class": EnhancedPaginator, "per_page": get_table_paginate_count(request, self.prefix)}
+ tables.RequestConfig(request, paginate).configure(self)
+
+ def render_name(self, value, record):
+ """Render inventory item name with tree indentation for sub-components."""
+ depth = record.get("depth", 0)
+ oob_badge = (
+ format_html(' OOB')
+ if record.get("_source") == "oob"
+ else ""
+ )
+ if depth == 0:
+ return format_html("{}{}", value or "-", oob_badge)
+ # Build visual tree prefix based on nesting depth
+ padding_px = depth * 20
+ prefix = "ββ "
+ return format_html('{}{}{}', padding_px, prefix, value or "-", oob_badge)
+
+ def render_model(self, value, record):
+ """Render model with link to module type if matched."""
+ if not value or value == "-":
+ return "-"
+ if url := record.get("module_type_url"):
+ return format_html('{}', url, value)
+ return format_html("{}", value)
+
+ def render_serial(self, value, record):
+ """Render serial number."""
+ return format_html("{}", value or "-")
+
+ def render_description(self, value, record):
+ """Render description, truncated for display."""
+ if not value:
+ return "-"
+ if len(value) > 60:
+ return format_html('{}…', value, value[:57])
+ return format_html("{}", value)
+
+ def render_item_class(self, value, record):
+ """Render the entPhysicalClass with an icon."""
+ icons = {
+ "module": "mdi-expansion-card",
+ "ioModule": "mdi-expansion-card",
+ "cpmModule": "mdi-expansion-card",
+ "mdaModule": "mdi-expansion-card",
+ "fabricModule": "mdi-expansion-card",
+ "xioModule": "mdi-expansion-card",
+ "powerSupply": "mdi-power-plug",
+ "fan": "mdi-fan",
+ "port": "mdi-ethernet",
+ "other": "mdi-card-outline",
+ }
+ icon = icons.get(value, "mdi-card-outline")
+ return format_html(' {}', icon, value)
+
+ def render_module_bay(self, value, record):
+ """Render module bay with link if found in NetBox."""
+ if not value or value == "-":
+ return format_html('{}', "No matching bay")
+ if url := record.get("module_bay_url"):
+ return format_html('{}', url, value)
+ return format_html("{}", value)
+
+ def render_module_type(self, value, record):
+ """Render module type match status."""
+ if not value or value == "-":
+ return format_html('{}', "No matching type")
+ if url := record.get("module_type_url"):
+ return format_html('{}', url, value)
+ return format_html("{}", value)
+
+ def render_status(self, value, record):
+ """Render sync status with badge."""
+ # Promote No Bay β Missing Carrier when concrete carrier-install rules
+ # produced suggestions for this row (one-click install offered below).
+ carrier_options = record.get("carrier_install_options")
+ if value == "No Bay" and carrier_options:
+ value = "Missing Carrier"
+
+ badge_classes = {
+ "Installed": "bg-success text-white",
+ "Matched": "bg-info text-white",
+ "No Bay": "bg-warning text-dark",
+ "No Type": "bg-warning text-dark",
+ "Missing Carrier": "bg-warning text-dark",
+ "Unmatched": "bg-secondary text-white",
+ "Serial Mismatch": "bg-danger text-white",
+ "Name Conflict": "bg-warning text-dark",
+ "Type Mismatch": "bg-warning text-dark",
+ "Integrated": "bg-light text-muted border",
+ }
+ badge_class = badge_classes.get(value, "bg-secondary text-white")
+ warning = record.get("model_warning")
+
+ # More descriptive label when the parent module type simply has no bay templates.
+ display_text = "No Bay on Parent" if record.get("no_bay_reason") == "empty_parent_bays" else value
+
+ # Small "Possible Carrier?" hint badge: holder hint fired but no
+ # concrete CarrierAutoInstallRule matched. Encourages the user to add
+ # a rule (button rendered in the actions column). Built upfront so
+ # every return path below can append it.
+ if record.get("holder_hint_present") and not record.get("carrier_install_options"):
+ possible_carrier_html = mark_safe(
+ ' '
+ ' Possible Carrier?'
+ )
+ else:
+ possible_carrier_html = mark_safe("")
+
+ if value == "Integrated":
+ parent_name = record.get("integrated_in_name") or "parent module"
+ tooltip = (
+ f"Duplicate SNMP entry for the same physical card as '{parent_name}' "
+ f"(matching entPhysicalSerialNum + entPhysicalModelName). "
+ "No separate NetBox bay/type is needed β this row is informational."
+ )
+ return format_html(
+ 'Integrated in {}',
+ badge_class,
+ tooltip,
+ parent_name,
+ )
+
+ if value == "Name Conflict" and (conflict_reason := record.get("name_conflict_reason")):
+ status_html = format_html(
+ '{} ',
+ badge_class,
+ display_text,
+ conflict_reason,
+ )
+ elif warning:
+ status_html = format_html(
+ '{}'
+ ' ',
+ badge_class,
+ warning,
+ display_text,
+ warning,
+ )
+ else:
+ status_html = format_html('{}', badge_class, display_text)
+
+ # "Fix Model" badge on the parent row when its installed module type is missing bay templates.
+ if record.get("model_incomplete"):
+ url = record.get("model_incomplete_url", "")
+ name = record.get("model_incomplete_name", "module type")
+ target_pk = record.get("model_incomplete_target_pk")
+ suggestion = record.get("model_incomplete_suggestion") or {}
+ title = f"Module type '{name}' has no bay templates β click to add them so sub-components can be installed"
+ fix_html = self._render_fix_bay_template_badge(
+ title=title,
+ target_kind="module_type",
+ target_pk=target_pk,
+ target_label=name,
+ suggestion=suggestion,
+ fallback_url=url,
+ label="Fix Model",
+ )
+ return status_html + fix_html + possible_carrier_html
+
+ # "Fix Device Type" badge when the device type is missing bay templates for this component.
+ if record.get("device_type_incomplete"):
+ url = record.get("device_type_incomplete_url", "")
+ name = record.get("device_type_incomplete_name", "device type")
+ target_pk = record.get("device_type_incomplete_target_pk")
+ suggestion = record.get("device_type_incomplete_suggestion") or {}
+ title = f"Device type '{name}' is missing bay templates for this component β click to add them"
+ fix_html = self._render_fix_bay_template_badge(
+ title=title,
+ target_kind="device_type",
+ target_pk=target_pk,
+ target_label=name,
+ suggestion=suggestion,
+ fallback_url=url,
+ label="Fix Device Type",
+ )
+ return status_html + fix_html + possible_carrier_html
+
+ return status_html + possible_carrier_html
+
+ def _render_fix_bay_template_badge(
+ self, *, title, target_kind, target_pk, target_label, suggestion, fallback_url, label
+ ):
+ """
+ Render a "Fix Model" / "Fix Device Type" badge.
+
+ When this table has a bound device, a numeric ``target_pk`` and the
+ viewer has ``dcim.add_modulebaytemplate``, the badge becomes an HTMX
+ trigger that opens the Add Bay Template modal pre-filled with the
+ LibreNMS-derived suggestion.
+
+ When the viewer can't add bay templates, the badge is hidden so it
+ doesn't act as a dead-end control (the modal would only return a 403
+ for them). The ```` and ```` fallbacks below are kept
+ for callers that don't have a bound device (e.g. unit tests built via
+ ``object.__new__``) or have no ``target_pk`` / URL available.
+ """
+ device = getattr(self, "device", None)
+ can_add_template = getattr(self, "can_add_module_bay_template", False)
+ if device and target_pk and can_add_template:
+ modal_url = reverse(
+ "plugins:netbox_librenms_plugin:add_bay_template",
+ kwargs={"pk": device.pk},
+ )
+ params = urlencode(
+ {
+ "target_kind": target_kind,
+ "target_pk": target_pk,
+ "suggested_name": suggestion.get("name", ""),
+ "suggested_position": suggestion.get("position", ""),
+ "suggested_label": suggestion.get("label", ""),
+ "librenms_name": suggestion.get("librenms_name", ""),
+ "librenms_class": suggestion.get("librenms_class", ""),
+ }
+ )
+ return format_html(
+ ' ',
+ title,
+ modal_url,
+ params,
+ label,
+ )
+ if device and not can_add_template:
+ # Viewer lacks dcim.add_modulebaytemplate β don't render a clickable
+ # badge that would only surface a permission error. mark_safe("")
+ # preserves the SafeString-ness of the surrounding concatenation
+ # (`status_html + fix_html + possible_carrier_html`); a bare ""
+ # would downgrade the result to a plain str and trigger escaping.
+ return mark_safe("")
+ if fallback_url:
+ return format_html(
+ ' '
+ ' {}',
+ fallback_url,
+ title,
+ label,
+ )
+ return format_html(
+ ' {}',
+ title,
+ label,
+ )
+
+ def render_actions(self, value, record):
+ """Render install button for matched modules and install branch for parents."""
+ if not self.device:
+ return ""
+ if not self.has_write_permission:
+ return ""
+ # "Integrated" rows are duplicate SNMP entries for a single physical
+ # card (parent + integrated child sharing serial+model) β there's
+ # nothing to install, so no actions.
+ if record.get("status") == "Integrated":
+ return ""
+
+ buttons = []
+
+ # Single install button (requires add permission)
+ if self.can_add_module and record.get("can_install"):
+ url = reverse("plugins:netbox_librenms_plugin:install_module", kwargs={"pk": self.device.pk})
+ buttons.append(
+ format_html(
+ '",
+ url,
+ self.csrf_token,
+ self.server_key,
+ record.get("selected_device_id") or self.device.pk,
+ record.get("module_bay_id", ""),
+ record.get("module_type_id", ""),
+ record.get("serial") or "",
+ )
+ )
+
+ # Install branch button for parents with installable children (requires add)
+ if self.can_add_module and record.get("has_installable_children") and record.get("ent_physical_index"):
+ url = reverse("plugins:netbox_librenms_plugin:install_branch", kwargs={"pk": self.device.pk})
+ buttons.append(
+ format_html(
+ '",
+ url,
+ self.csrf_token,
+ self.server_key,
+ record.get("selected_device_id") or self.device.pk,
+ record.get("ent_physical_index", ""),
+ )
+ )
+
+ # Update serial button for serial mismatch rows (requires change)
+ if self.can_change_module and record.get("can_update_serial") and record.get("installed_module_id"):
+ url = reverse("plugins:netbox_librenms_plugin:update_module_serial", kwargs={"pk": self.device.pk})
+ buttons.append(
+ format_html(
+ '",
+ url,
+ self.csrf_token,
+ self.server_key,
+ record.get("selected_device_id") or self.device.pk,
+ record["installed_module_id"],
+ record.get("serial") or "",
+ )
+ )
+
+ # Replace button for type/serial mismatch rows (requires add+change+delete)
+ if (
+ self.can_add_module
+ and self.can_change_module
+ and self.can_delete_module
+ and record.get("can_replace")
+ and record.get("installed_module_id")
+ ):
+ preview_url = reverse(
+ "plugins:netbox_librenms_plugin:module_mismatch_preview", kwargs={"pk": self.device.pk}
+ )
+ buttons.append(
+ format_html(
+ '",
+ record["installed_module_id"],
+ record.get("ent_physical_index", ""),
+ self.server_key or "",
+ record.get("selected_device_id") or self.device.pk,
+ preview_url,
+ )
+ )
+
+ # Move button for can_install rows where a single serial conflict exists (requires change+delete)
+ if (
+ self.can_change_module
+ and self.can_delete_module
+ and record.get("can_move_from")
+ and record.get("serial_conflict_module")
+ and record.get("module_bay_id")
+ ):
+ move_url = reverse("plugins:netbox_librenms_plugin:move_module", kwargs={"pk": self.device.pk})
+ conflict_module = record["serial_conflict_module"]
+ buttons.append(
+ format_html(
+ '",
+ move_url,
+ self.csrf_token,
+ self.server_key,
+ record.get("selected_device_id") or self.device.pk,
+ conflict_module.pk,
+ record["module_bay_id"],
+ conflict_module.device.name,
+ conflict_module.module_bay.name,
+ )
+ )
+
+ # "Install Carrier" buttons for No Bay rows where one or more
+ # CarrierAutoInstallRule rows match. One button per (rule, empty bay)
+ # candidate. Suggest-only β the user clicks to install.
+ if self.can_add_module and record.get("carrier_install_options"):
+ install_url = reverse("plugins:netbox_librenms_plugin:install_module", kwargs={"pk": self.device.pk})
+ for opt in record["carrier_install_options"]:
+ buttons.append(
+ format_html(
+ '",
+ install_url,
+ self.csrf_token,
+ self.server_key,
+ record.get("selected_device_id") or self.device.pk,
+ opt["bay_id"],
+ opt["module_type_id"],
+ opt["module_type_name"],
+ opt["bay_name"],
+ opt["module_type_name"],
+ opt["bay_name"],
+ )
+ )
+
+ # "Add Carrier Rule" prefilled link when the holder hint fires but no
+ # concrete CarrierAutoInstallRule matched. Pre-fills the form with
+ # manufacturer / class / regex for the orphan child name and a regex
+ # alternation across the device's empty bay names so the user only
+ # needs to pick the carrier ModuleType.
+ if (
+ record.get("status") == "No Bay"
+ and record.get("holder_hint_present")
+ and not record.get("carrier_install_options")
+ and getattr(self, "can_add_carrier_rule", False)
+ ):
+ base_url = reverse("plugins:netbox_librenms_plugin:carrierautoinstallrule_add")
+ return_url = getattr(self, "return_url", "") or ""
+ params = {}
+ mfr = getattr(getattr(self.device, "device_type", None), "manufacturer", None)
+ if mfr is not None:
+ params["manufacturer"] = mfr.pk
+ phys_class = (record.get("item_class") or "").strip()
+ if phys_class:
+ params["librenms_child_class"] = phys_class
+ child_name = (record.get("name") or "").strip()
+ if child_name:
+ params["librenms_child_name_pattern"] = "^" + re.escape(child_name) + "$"
+ empty_names = sorted(record.get("device_empty_bay_names") or [])
+ if empty_names:
+ params["netbox_bay_name_pattern"] = "^(" + "|".join(re.escape(n) for n in empty_names) + ")$"
+ if return_url:
+ params["return_url"] = return_url
+ qs = urlencode(params)
+ buttons.append(
+ format_html(
+ ''
+ ' Add Carrier Rule'
+ "",
+ base_url,
+ qs,
+ )
+ )
+
+ # "Add mapping" button for No Bay rows where we can suggest a mapping.
+ # Opens the ModuleBayMapping create form pre-filled with the regex
+ # capturing the trailing-number pattern (e.g. ^0/(\d+)$ -> Slot \1),
+ # so one entry covers the whole device-type slot family rather than
+ # one mapping per slot.
+ if (
+ record.get("status") == "No Bay"
+ and record.get("model_suggestion")
+ and getattr(self, "can_add_module_bay_mapping", False)
+ ):
+ sug = record["model_suggestion"]
+ base_url = reverse("plugins:netbox_librenms_plugin:modulebaymapping_add")
+ return_url = getattr(self, "return_url", "") or ""
+ params = {
+ "librenms_name": sug["librenms_name"],
+ "librenms_class": sug.get("librenms_class") or "",
+ "netbox_bay_name": sug["netbox_bay_name"],
+ "is_regex": "true" if sug.get("is_regex") else "false",
+ "description": sug.get("description") or "",
+ }
+ # Pre-fill manufacturer FK so the new mapping is auto-scoped to the
+ # device's vendor; user can clear it in the form to make it global.
+ if sug.get("manufacturer"):
+ params["manufacturer"] = sug["manufacturer"]
+ if return_url:
+ params["return_url"] = return_url
+ qs = urlencode(params)
+ buttons.append(
+ format_html(
+ ''
+ ' Add Mapping'
+ "",
+ base_url,
+ qs,
+ )
+ )
+
+ # "Add mapping" button for No Type rows where we can suggest a mapping.
+ # Opens the ModuleTypeMapping create form pre-filled with the LibreNMS
+ # model name and a helpful description so the user only needs to pick
+ # or create the matching NetBox ModuleType.
+ if (
+ record.get("status") == "No Type"
+ and record.get("type_suggestion")
+ and getattr(self, "can_add_module_type_mapping", False)
+ ):
+ sug = record["type_suggestion"]
+ base_url = reverse("plugins:netbox_librenms_plugin:moduletypemapping_add")
+ return_url = getattr(self, "return_url", "") or ""
+ params = {
+ "librenms_model": sug["librenms_model"],
+ "description": sug.get("description") or "",
+ }
+ if sug.get("manufacturer"):
+ params["manufacturer"] = sug["manufacturer"]
+ if return_url:
+ params["return_url"] = return_url
+ qs = urlencode(params)
+ buttons.append(
+ format_html(
+ ''
+ ' Add Mapping'
+ "",
+ base_url,
+ qs,
+ )
+ )
+
+ # "Add Module Type" button for No Type rows β opens NetBox's native
+ # ModuleType create form pre-filled with details we know from LibreNMS
+ # (manufacturer, model, part number, description). This is the
+ # alternative to "Add Mapping": rather than aliasing the LibreNMS
+ # model string to an existing NetBox type, the user creates the
+ # missing ModuleType directly so subsequent matches work natively.
+ if (
+ record.get("status") == "No Type"
+ and record.get("module_type_create")
+ and getattr(self, "can_add_module_type", False)
+ ):
+ create = record["module_type_create"]
+ base_url = reverse("dcim:moduletype_add")
+ return_url = getattr(self, "return_url", "") or ""
+ params = {k: v for k, v in create.items() if v not in ("", None)}
+ if return_url:
+ params["return_url"] = return_url
+ qs = urlencode(params)
+ buttons.append(
+ format_html(
+ ''
+ ' Add Module Type'
+ "",
+ base_url,
+ qs,
+ )
+ )
+
+ return mark_safe("".join(buttons)) if buttons else ""
+
+ def format_module_data(self, record):
+ """Format a module row for verify endpoint partial updates."""
+ return {
+ "name": str(self.render_name(record.get("name"), record)),
+ "model": str(self.render_model(record.get("model"), record)),
+ "serial": str(self.render_serial(record.get("serial"), record)),
+ "description": str(self.render_description(record.get("description"), record)),
+ "item_class": str(self.render_item_class(record.get("item_class"), record)),
+ "module_bay": str(self.render_module_bay(record.get("module_bay"), record)),
+ "module_type": str(self.render_module_type(record.get("module_type"), record)),
+ "status": str(self.render_status(record.get("status"), record)),
+ "actions": str(self.render_actions(None, record)),
+ }
+
+
+class VCModuleTable(LibreNMSModuleTable):
+ """Module sync table variant with virtual chassis member selection."""
+
+ device_selection = tables.Column(
+ verbose_name="Virtual Chassis Member",
+ accessor="selected_device_id",
+ orderable=False,
+ empty_values=(),
+ attrs={"td": {"data-col": "device_selection"}},
+ visible=False,
+ )
+
+ def __init__(self, *args, device=None, **kwargs):
+ super().__init__(*args, device=device, **kwargs)
+ # Cache VC members once so render_device_selection doesn't re-query for
+ # every row in large module tables.
+ self._vc_members = []
+ if hasattr(self.device, "virtual_chassis") and self.device.virtual_chassis:
+ self._vc_members = list(self.device.virtual_chassis.members.all())
+ self.columns.show("device_selection")
+
+ def render_device_selection(self, value, record):
+ selected_device_id = record.get("selected_device_id") or self.device.id
+ ent_index = record.get("ent_physical_index", "")
+
+ options = [
+ (
+ f'"
+ )
+ for member in self._vc_members
+ ]
+
+ return format_html(
+ '',
+ ent_index,
+ mark_safe("".join(options)),
+ )
+
+ def format_module_data(self, record):
+ formatted = super().format_module_data(record)
+ if hasattr(self.device, "virtual_chassis") and self.device.virtual_chassis:
+ formatted["device_selection"] = str(self.render_device_selection(record.get("selected_device_id"), record))
+ return formatted
+
+ class Meta(LibreNMSModuleTable.Meta):
+ sequence = [
+ "selection",
+ "device_selection",
+ "name",
+ "model",
+ "serial",
+ "description",
+ "item_class",
+ "module_bay",
+ "module_type",
+ "status",
+ "actions",
+ ]
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
index c8a51bdcad..c060e62f56 100644
--- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html
@@ -311,6 +311,7 @@
+ {% include 'netbox_librenms_plugin/_module_sync_content.html' %}
+
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html
new file mode 100644
index 0000000000..eccff430e8
--- /dev/null
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html
@@ -0,0 +1,44 @@
+{% load helpers %}
+{% include 'inc/messages.html' %}
+
+
+{% if module_sync.table %}
+
+
+
+ Showing inventory items from LibreNMS matched against NetBox module bays and module types.
+
+
+ {% if module_sync.cache_expiry %}
+
+ Cache expires in:
+
+ {% endif %}
+
+
+{% if has_write_permission %}
+{# Separate form for Install Selected β uses JS to collect checked rows before submit #}
+
+{% endif %}
+
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %}
+ {% include 'inc/table.html' with table=module_sync.table %}
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %}
+
+{% else %}
+
+
+
+
No inventory data loaded. Click Refresh Modules to fetch data from LibreNMS.
Suggest a holder/carrier ModuleType to install when LibreNMS reports orphan
+ child modules (e.g. CPM cards, mezzanines, MDAs) that have no matching NetBox
+ bay because their parent carrier was never installed in NetBox.
+
Each rule is matched against the device's manufacturer / device-type and the
+ orphan child's entPhysicalClass + entPhysicalName.
+ When the chassis has at least one empty bay matching NetBox Bay Name Pattern,
+ an Install Carrier button appears on the module sync page.
+
Suggest-only β no automatic installation. The user clicks to apply.
Map LibreNMS hardware strings to NetBox device types.
+ When importing devices from LibreNMS, these mappings are checked first before
+ falling back to exact part number / model matching.
+
Example: Map "Juniper MX480 Internet Backbone Router" to device type "MX480"
+
+ {{ block.super }}
+{% endblock %}
+
+{% block bulk_buttons %}
+ {{ block.super }}
+
+{% endblock %}
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..8423298f79
--- /dev/null
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_dt_mapping_form.html
@@ -0,0 +1,96 @@
+{% 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 %}
+
+
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..8eeef5abad
--- /dev/null
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/_platform_mapping_form.html
@@ -0,0 +1,94 @@
+{% 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 %}
+
+
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/add_bay_template_modal.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/add_bay_template_modal.html
new file mode 100644
index 0000000000..98894b7a2b
--- /dev/null
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/add_bay_template_modal.html
@@ -0,0 +1,231 @@
+{% load helpers %}
+{# Modal-content fragment for adding a missing ModuleBayTemplate to a Device Type or Module Type. #}
+{# Rendered by AddBayTemplateView.get and swapped into #htmx-modal-content. #}
+
+
+ Add Bay Template
+
+
+
+
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_collision.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_collision.html
new file mode 100644
index 0000000000..6456cefad3
--- /dev/null
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_collision.html
@@ -0,0 +1,61 @@
+{# Bulk-import collision warning: shown when two or more selected LibreNMS rows resolve to the same NetBox device. #}
+
+
+ Bulk import blocked: NetBox device collisions
+
+
+
+
+
+
+
+ Two or more selected LibreNMS devices would update the
+ same NetBox device. Importing them as a batch could
+ leave NetBox in an inconsistent state, so the whole import has been
+ blocked. Resolve each colliding NetBox device individually first
+ (use the per-row import action on the table), or deselect the
+ duplicates and try again.
+
+
+ How to fix: close this dialog, untick all but one
+ of the LibreNMS rows in each colliding group, and click
+ Bulk Import again. After the first row is imported, the
+ conflict for the second one usually resolves into an
+ Add as OOB or Promote to host action that you
+ can run from the per-row import button.
+
+
+
+
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
new file mode 100644
index 0000000000..f2b557e0de
--- /dev/null
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/create_platform_modal.html
@@ -0,0 +1,93 @@
+{# Shared partial for the Create Platform modal content. Used in two contexts: #}
+{# Device sync page (use_htmx not set): regular POST via method="post", redirects on success. #}
+{# Import page (use_htmx=True): hx-post, returns OOB swaps on success. #}
+{# Expected context variables: #}
+{# librenms_os - LibreNMS OS string, e.g. "ios" #}
+{# platform_name - Suggested platform name (typically same as librenms_os) #}
+{# manufacturers - Queryset/list of Manufacturer objects #}
+{# form_action - URL the form POSTs to #}
+{# device_pk - (optional) NetBox Device/VM pk to also assign the platform to #}
+{# selected_manufacturer_pk - (optional) Pre-selected manufacturer pk #}
+{# use_htmx - (optional) When True adds hx-post/hx-swap attributes #}
+{# htmx_include - (optional) CSS selector string for hx-include #}
+{# server_key - (optional) LibreNMS server key, forwarded as hidden field #}
+
+
+ Create New Platform
+
+
+
+
+
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html
index 76aac95546..21a36ea735 100644
--- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html
@@ -16,7 +16,4 @@
{% endif %}
-{# Consume and clear any pending Django messages to prevent reappearing toasts #}
-
-{% for _ in messages %}{% endfor %}
-
+{# Toast messages are appended once per HTMX response by the calling view via _attach_messages_oob() so multi-row OOB swaps don't wipe each other's #django-messages container. Do not include 'inc/messages.html' here. #}
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 dc69bcf0ee..acef4e281e 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
@@ -6,7 +6,7 @@
+ {% 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" %}
+
{% else %}
- No matching type
+ {% if libre_device.hardware and libre_device.hardware != "-" %}
+
+ No matching type
+ {% include "netbox_librenms_plugin/htmx/_dt_mapping_form.html" %}
+
+ {% else %}
+ No matching type
+ {% endif %}
{% endif %}
{{ libre_device.hardware|default:"β" }}
@@ -293,16 +309,24 @@
+ {% if libre_device.os and libre_device.os != "-" %}
+
+ {% endif %}
{% endif %}
{% elif sync_info and sync_info.platform_synced %}
{% endif %}
- {% elif validation.platform.platform %}
- {{ validation.platform.platform.name }}
-
- {% elif sync_info and sync_info.platform_info.platform_exists %}
+ {% elif validation.existing_device %}
+ {# Existing device with no platform assigned β show Not set + sync button #}
Not set
- {% if validation.existing_device %}
+ {% if validation.platform.platform or sync_info and sync_info.platform_info.platform_exists %}
+ {% elif libre_device.os and libre_device.os != "-" %}
+
{% endif %}
+ {% elif validation.platform.platform %}
+ {# New import β platform will be assigned on import #}
+ {{ validation.platform.platform.name }}
+
{% else %}
+ {% if libre_device.os and libre_device.os != "-" %}
+
+ {% else %}
Optional
+ {% endif %}
+ {% 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 %}
{% endif %}
{{ libre_device.os|default:"β" }}
@@ -379,7 +428,84 @@
{# Status & Actions #}
{% if validation.existing_device %}
- {% if validation.existing_match_type == 'librenms_id' %}
+ {% if validation.serial_action == 'merge_netbox_devices' %}
+ {# Stage 2: two NetBox devices appear to represent the same physical box. #}
+
+ Two NetBox devices
+
+ β A hostname match and a chassis-serial match resolved to two different NetBox
+ devices. Pick which one to keep; the other will be marked as merged.
+
+
+
+
+
+ Pick the device to keep (winner).
+ The other one becomes the donor and is absorbed.
+
+
+
Moved to winner: LibreNMS link (host id, OOB id, OOB type) and OOB IP (only if the winner has no OOB IP yet).
+
Stays on donor: interfaces, cables, primary IP. Re-home them later from the donor's "Migrated to ..." tab.
+
+
+
+ The donor device is not deleted. After you have re-homed its
+ child objects, you can delete it from NetBox manually if no longer needed.
+
+
+
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html
new file mode 100644
index 0000000000..98dc0d013a
--- /dev/null
+++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html
@@ -0,0 +1,132 @@
+{% load helpers %}
+{# Modal body fragment for the module replace / move dialog. #}
+{# Rendered server-side by ModuleMismatchPreviewView and injected into #htmx-modal-body via JS. #}
+
+
Comparing the module currently in NetBox with the LibreNMS inventory data.
+
+ Different module type β the installed module type does not match LibreNMS.
+ Replacing will delete the current module and install {{ librenms_model }}.
+
+{% elif serial_mismatch %}
+
+
+ Same module type, different serial β the module may have been physically replaced.
+
+{% endif %}
+
+{% if serial_conflict %}
+
+
+ Serial conflict:{{ librenms_serial }} is currently installed at
+ {{ serial_conflict.device.name }} /
+ Bay: {{ serial_conflict.module_bay.name }}.
+
+ Replace will also remove it from that location.
+ Move will update its location to this bay instead of creating a new entry.
+
+
+{% elif serial_conflict_ambiguous %}
+
+
+ Ambiguous serial conflict: Serial {{ librenms_serial }} is assigned to multiple
+ modules. Please resolve the conflict manually before proceeding.
+
+{% endif %}
+
+
+
+
+ {% if serial_mismatch and not type_mismatch and not serial_conflict and not serial_conflict_ambiguous and installed_module %}
+ {# Quick serial-only update β no delete/recreate needed #}
+
+ {% csrf_token %}
+
+
+
+
+
+
+ {% endif %}
+
+ {% if serial_conflict %}
+ {# Move the existing module here rather than creating a new entry #}
+
+ {% csrf_token %}
+
+
+
+
+
+
+
+ {% endif %}
+
+ {# Replace: delete current + install fresh from LibreNMS data #}
+ {% if installed_module and not serial_conflict_ambiguous %}
+
Configurable rules to skip ENTITY-MIB entries during module sync.
+ Some vendors (e.g. Cisco IOS-XR) report EEPROM/IDPROM chips as child
+ entities with the same model name and serial as their parent hardware.
+ These phantom entries would appear as duplicate modules in the sync UI.
+
Each rule matches an entity using one of the following strategies:
+
+
ends_with / starts_with / contains / regex β matches the entity name string.
+
serial_matches_device β matches when the entity's serial number is identical to
+ the parent device's serial. Useful for suppressing EEPROM/IDPROM phantom entries that share the
+ device serial.
+
+
When Require Serial Match
+ is enabled (for name-based rules), the entry is only skipped if its serial number also matches the
+ parent entity β providing a safety net against accidentally hiding
+ legitimate modules.
+
Example β Cisco IOS-XR IDPROM entries:
+ Match type: ends_with, Pattern: IDPROM,
+ Require serial match: Yes
+ Skips entries like Optics0/0/0/0-IDPROM,
+ 0/FT0-FT IDPROM, Rack 0-Chassis IDPROM.