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 2f822e8f8a..a2be7efa2e 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -70,6 +70,29 @@ - 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/testing.instructions.md b/.github/instructions/testing.instructions.md index 222ea8d0a3..beb3ffc7bc 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -29,6 +29,8 @@ description: Testing patterns and conventions for the NetBox LibreNMS plugin - `librenms_api.py` β†’ `test_librenms_api.py`, `test_librenms_api_helpers.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/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000000..4936a44c4f --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,103 @@ +# For most projects, this workflow file will not need changing; you simply need +# to commit it to your repository. +# +# You may wish to alter this file to override the set of languages analyzed, +# or to provide custom queries or build logic. +# +# ******** NOTE ******** +# We have attempted to detect the languages in your repository. Please check +# the `language` matrix defined below to confirm you have the correct set of +# supported CodeQL languages. +# +name: "CodeQL Advanced" + +on: + push: + branches: [ "master", "develop" ] + pull_request: + branches: [ "master", "develop" ] + schedule: + - cron: '35 13 * * 0' + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + # Runner size impacts CodeQL analysis time. To learn more, please see: + # - https://gh.io/recommended-hardware-resources-for-running-codeql + # - https://gh.io/supported-runners-and-hardware-resources + # - https://gh.io/using-larger-runners (GitHub.com only) + # Consider using larger runners or machines with greater resources for possible analysis time improvements. + runs-on: ${{ (matrix.language == 'swift' && 'macos-latest') || 'ubuntu-latest' }} + permissions: + # required for all workflows + security-events: write + + # required to fetch internal or private CodeQL packs + packages: read + + # only required for workflows in private repositories + actions: read + contents: read + + strategy: + fail-fast: false + matrix: + include: + - language: actions + build-mode: none + - language: javascript-typescript + build-mode: none + - language: python + build-mode: none + # CodeQL supports the following values keywords for 'language': 'actions', 'c-cpp', 'csharp', 'go', 'java-kotlin', 'javascript-typescript', 'python', 'ruby', 'rust', 'swift' + # Use `c-cpp` to analyze code written in C, C++ or both + # Use 'java-kotlin' to analyze code written in Java, Kotlin or both + # Use 'javascript-typescript' to analyze code written in JavaScript, TypeScript or both + # To learn more about changing the languages that are analyzed or customizing the build mode for your analysis, + # see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/customizing-your-advanced-setup-for-code-scanning. + # If you are analyzing a compiled language, you can modify the 'build-mode' for that language to customize how + # your codebase is analyzed, see https://docs.github.com/en/code-security/code-scanning/creating-an-advanced-setup-for-code-scanning/codeql-code-scanning-for-compiled-languages + steps: + - name: Checkout repository + uses: actions/checkout@v6 + + # Add any setup steps before running the `github/codeql-action/init` action. + # This includes steps like installing compilers or runtimes (`actions/setup-node` + # or others). This is typically only required for manual builds. + # - name: Setup runtime (example) + # uses: actions/setup-example@v1 + + # Initializes the CodeQL tools for scanning. + - name: Initialize CodeQL + uses: github/codeql-action/init@v4 + with: + languages: ${{ matrix.language }} + build-mode: ${{ matrix.build-mode }} + # If you wish to specify custom queries, you can do so here or in a config file. + # By default, queries listed here will override any specified in a config file. + # Prefix the list here with "+" to use these queries and those in the config file. + + # For more details on CodeQL's query packs, refer to: https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs + # queries: security-extended,security-and-quality + + # If the analyze step fails for one of the languages you are analyzing with + # "We were unable to automatically build your code", modify the matrix above + # to set the build mode to "manual" for that language. Then modify this step + # to build your code. + # ℹ️ Command-line programs to run using the OS shell. + # πŸ“š See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun + - name: Run manual build steps + if: matrix.build-mode == 'manual' + shell: bash + run: | + echo 'If you are using a "manual" build mode for one or more of the' \ + 'languages you are analyzing, replace this with the commands to build' \ + 'your code, for example:' + echo ' make bootstrap' + echo ' make release' + exit 1 + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v4 + with: + category: "/language:${{matrix.language}}" 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/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/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/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/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..1dbc10c87c 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") @@ -153,8 +184,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 +290,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 +715,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 +761,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 +800,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 +813,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/bulk_import.py b/netbox_librenms_plugin/import_utils/bulk_import.py index 66e741b1d6..93962432e7 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -228,7 +228,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 +357,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 +378,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 +454,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/device_operations.py b/netbox_librenms_plugin/import_utils/device_operations.py index 152414998b..6fb760529e 100644 --- a/netbox_librenms_plugin/import_utils/device_operations.py +++ b/netbox_librenms_plugin/import_utils/device_operations.py @@ -617,7 +617,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: 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/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 457eea37f7..8104fce90f 100644 --- a/netbox_librenms_plugin/librenms_api.py +++ b/netbox_librenms_plugin/librenms_api.py @@ -343,12 +343,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 +428,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 +709,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 +1016,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 +1067,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/models.py b/netbox_librenms_plugin/models.py index cd79f47550..16c8b68952 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,10 @@ class LibreNMSSettings(models.Model): help_text="Remove domain suffix from device names during import", ) + def save(self, *args, **kwargs): + self.pk = 1 + super().save(*args, **kwargs) + class Meta: """Meta options for LibreNMSSettings.""" @@ -48,7 +97,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 +112,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 +140,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..a2facc706f 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 // ============================================ @@ -1035,7 +1111,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 +1172,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) { 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 = '' + ' {{ block.super }} {% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html new file mode 100644 index 0000000000..62db37c672 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html @@ -0,0 +1,36 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
NameMatch TypePatternActionRequire Serial MatchEnabledDescription
{{ object.name }}{{ object.get_match_type_display }}{% if object.match_type == "serial_matches_device" %}β€”{% else %}{{ object.pattern }}{% endif %}{{ object.get_action_display }}{% if object.match_type == "serial_matches_device" %}β€”{% elif object.require_serial_match_parent %}Yes{% else %}No{% endif %}{% if object.enabled %}Yes{% else %}No{% endif %}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html new file mode 100644 index 0000000000..13c0040d29 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html @@ -0,0 +1,35 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Inventory Ignore Rules

+

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.

+
+ {{ block.super }} +{% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html index 3378b513d2..cb85117c40 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html @@ -73,6 +73,7 @@ {% if not mapping.is_configured %} {% if lookup_device_model_name == "device" or lookup_device_model_name == "virtualmachine" %} + {% if lookup_device_pk == object.pk %}
Remove
+ {% else %} + + Managed by VC sync device + + {% endif %} {% endif %} {% endif %} @@ -621,6 +627,14 @@
Device Information Sync
{% endif %} {% endwith %} + {% if module_sync and object|meta:"model_name" == "device" %} + + {% endif %}
Device Information Sync {% include 'netbox_librenms_plugin/_ipaddress_sync.html' %}
+ {% if module_sync and object|meta:"model_name" == "device" %} +
+ {% include 'netbox_librenms_plugin/_module_sync.html' %} +
+ {% endif %} + {% with model_name=object|meta:"model_name" %} {% if model_name == "device" %}
Device Information Sync + + + {% if mismatched_device %}