From 1520c8dc3e3b1d0b8918338b9f21b19700a0942d Mon Sep 17 00:00:00 2001 From: Marcin Zieba Date: Tue, 31 Mar 2026 20:41:41 +0200 Subject: [PATCH 001/218] feat(inventory): modules/inventory sync tab with mapping rules Add inventory/modules sync functionality: - Six mapping model types: DeviceTypeMapping, ModuleTypeMapping, ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, PlatformMapping - Migration 0010 creating all mapping tables - Modules sync tab on Device/VM detail pages with ENTITY-MIB inventory data - Install, replace, and move module actions - Mapping CRUD views with YAML bulk export for all mapping models - LibreNMS API: get_device_transceivers() for transceiver data - Platform matching: PlatformMapping lookup before name-exact match - contrib/ YAML files with example mappings and rules - Comprehensive test coverage (test_sync_modules, test_modules_view, test_module_replace, test_platform_mapping, test_tables_modules) --- contrib/README.md | 30 + contrib/device_type_mappings.yaml | 73 + contrib/interface_type_mappings.yaml | 75 + contrib/inventory_ignore_rules.yaml | 94 + contrib/module_bay_mappings.yaml | 216 ++ contrib/module_type_mappings.yaml | 332 +++ contrib/normalization_rules.yaml | 61 + docs/development/testing.md | 1 + netbox_librenms_plugin/api/serializers.py | 92 +- netbox_librenms_plugin/api/urls.py | 6 + netbox_librenms_plugin/api/views.py | 92 +- netbox_librenms_plugin/filters.py | 98 +- netbox_librenms_plugin/forms.py | 447 +++- .../import_utils/bulk_import.py | 16 +- netbox_librenms_plugin/import_utils/cache.py | 6 +- .../import_utils/virtual_chassis.py | 1 - .../import_utils/vm_operations.py | 5 +- netbox_librenms_plugin/librenms_api.py | 103 +- .../migrations/0010_inventory_models.py | 325 +++ netbox_librenms_plugin/models.py | 572 +++- netbox_librenms_plugin/navigation.py | 118 +- .../js/librenms_sync.js | 6 + netbox_librenms_plugin/tables/__init__.py | 16 +- netbox_librenms_plugin/tables/mappings.py | 228 +- netbox_librenms_plugin/tables/modules.py | 269 ++ .../netbox_librenms_plugin/_module_sync.html | 28 + .../_module_sync_content.html | 44 + .../devicetypemapping.html | 28 + .../devicetypemapping_list.html | 19 + .../htmx/module_mismatch_modal.html | 112 + .../interfacetypemapping_list.html | 7 + .../inventoryignorerule.html | 36 + .../inventoryignorerule_list.html | 35 + .../librenms_sync_base.html | 53 +- .../modulebaymapping.html | 32 + .../modulebaymapping_list.html | 19 + .../moduletypemapping.html | 28 + .../moduletypemapping_list.html | 19 + .../normalizationrule.html | 34 + .../normalizationrule_list.html | 23 + .../platformmapping.html | 28 + .../platformmapping_list.html | 20 + .../tests/mock_librenms_server.py | 4 - .../tests/test_background_jobs.py | 216 +- .../tests/test_coverage_actions.py | 73 +- .../tests/test_coverage_api.py | 37 +- .../tests/test_coverage_api2.py | 10 + .../tests/test_coverage_base_views2.py | 12 +- .../tests/test_coverage_bulk_import.py | 2326 +++++++++++++++++ .../tests/test_coverage_device_fields.py | 21 +- .../tests/test_coverage_device_operations.py | 24 +- .../tests/test_coverage_devices.py | 922 +++++++ .../tests/test_coverage_filters.py | 2 + .../tests/test_coverage_forms.py | 188 ++ .../tests/test_coverage_list.py | 114 + .../tests/test_coverage_mixins.py | 41 +- .../tests/test_coverage_sync_view.py | 181 +- .../tests/test_coverage_sync_views.py | 25 +- .../tests/test_coverage_sync_views2.py | 37 +- .../tests/test_coverage_utils.py | 21 +- .../tests/test_integration_virtual_chassis.py | 59 + .../tests/test_librenms_api.py | 6 +- .../tests/test_librenms_id.py | 40 +- .../tests/test_module_replace.py | 464 ++++ .../tests/test_modules_view.py | 1282 +++++++++ .../tests/test_permissions.py | 2 +- .../tests/test_platform_mapping.py | 527 ++++ .../tests/test_sync_devices.py | 19 +- .../tests/test_sync_modules.py | 2073 +++++++++++++++ .../tests/test_sync_view_mismatch.py | 235 +- .../tests/test_tables_modules.py | 647 +++++ netbox_librenms_plugin/tests/test_utils.py | 78 +- .../tests/test_view_wiring.py | 28 +- .../tests/test_vm_operations.py | 150 +- netbox_librenms_plugin/urls.py | 401 ++- netbox_librenms_plugin/utils.py | 255 +- netbox_librenms_plugin/views/__init__.py | 65 + .../views/base/librenms_sync_view.py | 32 +- .../views/base/modules_view.py | 1062 ++++++++ .../views/imports/actions.py | 51 + netbox_librenms_plugin/views/mapping_views.py | 462 +++- .../views/object_sync/__init__.py | 1 + .../views/object_sync/devices.py | 39 +- .../views/sync/device_fields.py | 53 +- netbox_librenms_plugin/views/sync/devices.py | 14 +- netbox_librenms_plugin/views/sync/modules.py | 883 +++++++ tests/e2e/__init__.py | 0 tests/e2e/conftest.py | 6 + tests/e2e/test_module_install.py | 350 +++ 89 files changed, 16769 insertions(+), 616 deletions(-) create mode 100644 contrib/README.md create mode 100644 contrib/device_type_mappings.yaml create mode 100644 contrib/interface_type_mappings.yaml create mode 100644 contrib/inventory_ignore_rules.yaml create mode 100644 contrib/module_bay_mappings.yaml create mode 100644 contrib/module_type_mappings.yaml create mode 100644 contrib/normalization_rules.yaml create mode 100644 netbox_librenms_plugin/migrations/0010_inventory_models.py create mode 100644 netbox_librenms_plugin/tables/modules.py create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/inventoryignorerule_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/modulebaymapping_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/moduletypemapping_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/normalizationrule_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/platformmapping_list.html create mode 100644 netbox_librenms_plugin/tests/test_coverage_bulk_import.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_devices.py create mode 100644 netbox_librenms_plugin/tests/test_module_replace.py create mode 100644 netbox_librenms_plugin/tests/test_modules_view.py create mode 100644 netbox_librenms_plugin/tests/test_platform_mapping.py create mode 100644 netbox_librenms_plugin/tests/test_sync_modules.py create mode 100644 netbox_librenms_plugin/tests/test_tables_modules.py create mode 100644 netbox_librenms_plugin/views/base/modules_view.py create mode 100644 netbox_librenms_plugin/views/sync/modules.py create mode 100644 tests/e2e/__init__.py create mode 100644 tests/e2e/conftest.py create mode 100644 tests/e2e/test_module_install.py diff --git a/contrib/README.md b/contrib/README.md new file mode 100644 index 000000000..8cffa798c --- /dev/null +++ b/contrib/README.md @@ -0,0 +1,30 @@ +# 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) | + +## 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/device_type_mappings.yaml b/contrib/device_type_mappings.yaml new file mode 100644 index 000000000..2dec24152 --- /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 000000000..3f48cd94b --- /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 000000000..e56b7c997 --- /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 000000000..64c063176 --- /dev/null +++ b/contrib/module_bay_mappings.yaml @@ -0,0 +1,216 @@ +# 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 +# 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 + description: "Juniper MX SFP+ @ fpc/pic/port → Transceiver pic/port" + +# ─── 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 000000000..e70d726f1 --- /dev/null +++ b/contrib/module_type_mappings.yaml @@ -0,0 +1,332 @@ +# 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 +# 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: "QSFP28-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 ───────────────────────────────────── +# These are mapped based on port context (QSFP28 100G slot) when vendor is unknown. + +- librenms_model: "1F3QAA" + netbox_module_type: "QSFP-100G-LR4" + description: "Unknown QSFP28 100G (mapped by port context)" diff --git a/contrib/normalization_rules.yaml b/contrib/normalization_rules.yaml new file mode 100644 index 000000000..c3d2081be --- /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 alnum + 2 quality-tier letters), +# discards the 2-letter revision code + 2-digit build number. +- scope: module_type + manufacturer: Nokia + match_pattern: "^(3HE\\w{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\\w+)\\s+.*$" + replacement: "\\1" + priority: 50 + description: "Extract Nokia part number from transceiver model field (strip trailing vendor/oui info)" diff --git a/docs/development/testing.md b/docs/development/testing.md index 1e1290a25..12e4327cd 100644 --- a/docs/development/testing.md +++ b/docs/development/testing.md @@ -65,6 +65,7 @@ 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 | Supporting files: diff --git a/netbox_librenms_plugin/api/serializers.py b/netbox_librenms_plugin/api/serializers.py index 6bcd0aef2..d455e23b1 100644 --- a/netbox_librenms_plugin/api/serializers.py +++ b/netbox_librenms_plugin/api/serializers.py @@ -1,6 +1,14 @@ from netbox.api.serializers import NetBoxModelSerializer -from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) class InterfaceTypeMappingSerializer(NetBoxModelSerializer): @@ -11,3 +19,85 @@ 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", "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", "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", + ] diff --git a/netbox_librenms_plugin/api/urls.py b/netbox_librenms_plugin/api/urls.py index 230aa078d..d2136f2e2 100644 --- a/netbox_librenms_plugin/api/urls.py +++ b/netbox_librenms_plugin/api/urls.py @@ -7,6 +7,12 @@ 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) 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 a5d440b8f..60f817859 100644 --- a/netbox_librenms_plugin/api/views.py +++ b/netbox_librenms_plugin/api/views.py @@ -12,11 +12,35 @@ 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 ( + 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 ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) + +from .serializers import ( + DeviceTypeMappingSerializer, + InterfaceTypeMappingSerializer, + InventoryIgnoreRuleSerializer, + ModuleBayMappingSerializer, + ModuleTypeMappingSerializer, + NormalizationRuleSerializer, + PlatformMappingSerializer, +) logger = logging.getLogger(__name__) @@ -45,6 +69,66 @@ 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") + serializer_class = ModuleTypeMappingSerializer + + +class ModuleBayMappingViewSet(NetBoxModelViewSet): + """API viewset for ModuleBayMapping CRUD operations.""" + + permission_classes = [LibreNMSPluginPermission] + filterset_class = ModuleBayMappingFilterSet + + queryset = ModuleBayMapping.objects.all() + 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 + + @api_view(["POST"]) @permission_classes([LibreNMSPluginPermission]) def sync_job_status(request, job_pk): diff --git a/netbox_librenms_plugin/filters.py b/netbox_librenms_plugin/filters.py index 9ec162a64..dc59566ef 100644 --- a/netbox_librenms_plugin/filters.py +++ b/netbox_librenms_plugin/filters.py @@ -1,13 +1,109 @@ import django_filters +from dcim.models import Manufacturer -from .models import InterfaceTypeMapping +from .models import ( + 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") + + class Meta: + """Meta options for ModuleTypeMappingFilterSet.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "description"] + + +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") + + class Meta: + """Meta options for ModuleBayMappingFilterSet.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex"] + + +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"] diff --git a/netbox_librenms_plugin/forms.py b/netbox_librenms_plugin/forms.py index da1417b71..b599f9d45 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,24 @@ 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 ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + LibreNMSSettings, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) logger = logging.getLogger(__name__) @@ -51,14 +66,31 @@ 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)")] + api = None + cache_key = "librenms_poller_group_choices" try: api = LibreNMSAPI() + cache_key = f"librenms_poller_group_choices_{api.server_key}" + except Exception: + pass + + cached_choices = cache.get(cache_key) + if cached_choices is not None: + return cached_choices + + try: + if api is None: + api = LibreNMSAPI() + cache_key = f"librenms_poller_group_choices_{api.server_key}" success, poller_groups = api.get_poller_groups() if success: @@ -73,6 +105,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 +187,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 +293,307 @@ 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 = forms.ModelChoiceField( + queryset=DeviceType.objects.all(), + label="NetBox Device Type", + widget=forms.Select(attrs={"class": "form-select"}), + ) + + 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.""" + + class Meta: + """Meta options for ModuleTypeMappingForm.""" + + model = ModuleTypeMapping + fields = ["librenms_model", "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") + 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.""" + + class Meta: + """Meta options for ModuleBayMappingForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "description"] + + +class ModuleBayMappingImportForm(NetBoxModelImportForm): + """Form for bulk importing module bay mappings.""" + + class Meta: + """Meta options for ModuleBayMappingImportForm.""" + + model = ModuleBayMapping + fields = ["librenms_name", "librenms_class", "netbox_bay_name", "is_regex", "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", + ) + + model = ModuleBayMapping + + +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 +601,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 +647,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 +686,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 +699,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 66e741b1d..4c3fbf0dd 100644 --- a/netbox_librenms_plugin/import_utils/bulk_import.py +++ b/netbox_librenms_plugin/import_utils/bulk_import.py @@ -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" @@ -448,6 +456,10 @@ def _lookup_in_model(m): elif not actual_is_vm: validation["device_role"] = {"found": False, "role": None} 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 b6860a3fe..b066f5c66 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_e3b0c44298fc1c14_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/virtual_chassis.py b/netbox_librenms_plugin/import_utils/virtual_chassis.py index 4927ddea7..332ab8e51 100644 --- a/netbox_librenms_plugin/import_utils/virtual_chassis.py +++ b/netbox_librenms_plugin/import_utils/virtual_chassis.py @@ -573,7 +573,6 @@ def create_virtual_chassis_with_members( ) members_created += 1 - # Validate member count # Validate member count — exclude master-slot entries with blank serials expected_members = len( [ diff --git a/netbox_librenms_plugin/import_utils/vm_operations.py b/netbox_librenms_plugin/import_utils/vm_operations.py index a630b5563..db68df0fd 100644 --- a/netbox_librenms_plugin/import_utils/vm_operations.py +++ b/netbox_librenms_plugin/import_utils/vm_operations.py @@ -29,8 +29,11 @@ def create_vm_from_librenms( Args: libre_device: Device data from LibreNMS validation: Validation result from validate_device_for_import with import_as_vm=True + server_key: LibreNMS server key used to store the librenms_id custom field. + Defaults to "default" for backward compatibility. Always pass explicitly + when the calling context has a multi-server configuration so that the + librenms_id custom field is stored under the correct server key. use_sysname: If True, prefer sysName; if False, use hostname - server_key: LibreNMS server key used to store the librenms_id custom field Returns: Created VirtualMachine instance diff --git a/netbox_librenms_plugin/librenms_api.py b/netbox_librenms_plugin/librenms_api.py index 457eea37f..1a377e7fd 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 @@ -711,6 +710,65 @@ 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}" + + transceivers = data["transceivers"] + if not isinstance(transceivers, list): + msg = data.get("message") + return False, msg or f"Unexpected transceivers response format for device {device_id}" + + if any(item is None or not isinstance(item, dict) for item in transceivers): + return False, f"Malformed transceiver entry in response for device {device_id}" + + return True, transceivers + except requests.exceptions.RequestException as e: + return False, str(e) + def get_poller_groups(self): """ Fetch all poller groups from LibreNMS. @@ -955,14 +1013,15 @@ def get_device_vlans(self, device_id: int) -> tuple[bool, list | str]: if not isinstance(all_vlans, list): msg = result.get("message") return False, msg or "Unexpected response format: missing 'vlans' list" + if not all(isinstance(v, dict) for v in all_vlans): + return False, "Unexpected response format: invalid item shape in 'vlans'" # Filter VLANs by device_id since resources endpoint returns all VLANs - device_vlans = [ - v for v in all_vlans if isinstance(v, dict) and str(v.get("device_id")) == str(device_id) - ] + device_vlans = [v for v in all_vlans if str(v.get("device_id")) == str(device_id)] return True, device_vlans if isinstance(result, dict): return False, result.get("message") or "Unexpected response format" return False, "Unexpected response format" + except requests.exceptions.HTTPError as e: if e.response.status_code == 404: return False, "VLANs resource not found" @@ -1005,20 +1064,18 @@ 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" + 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_models.py b/netbox_librenms_plugin/migrations/0010_inventory_models.py new file mode 100644 index 000000000..7e0b42412 --- /dev/null +++ b/netbox_librenms_plugin/migrations/0010_inventory_models.py @@ -0,0 +1,325 @@ +""" +Add inventory/modules sync models: DeviceTypeMapping, ModuleTypeMapping, +ModuleBayMapping, NormalizationRule, InventoryIgnoreRule, and PlatformMapping, +along with two default InventoryIgnoreRule entries. + +Squashed from 0010–0013. +""" + +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_rules(apps, schema_editor): + 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 — " + '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 — detection " + "is purely serial-based." + ), + ) + + +def _delete_default_rules(apps, schema_editor): + db_alias = schema_editor.connection.alias + InventoryIgnoreRule = apps.get_model("netbox_librenms_plugin", "InventoryIgnoreRule") + InventoryIgnoreRule.objects.using(db_alias).filter( + name="Cisco IOS-XR IDPROM entries", + match_type="ends_with", + pattern="IDPROM", + action="skip", + require_serial_match_parent=True, + enabled=True, + ).delete() + InventoryIgnoreRule.objects.using(db_alias).filter( + name="Embedded RP / fixed-chassis system board", + match_type="serial_matches_device", + pattern="", + action="transparent", + require_serial_match_parent=False, + enabled=True, + ).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ("dcim", "0225_gfk_indexes"), + ("extras", "0134_owner"), + ("netbox_librenms_plugin", "0009_convert_librenms_id_to_json"), + ] + + operations = [ + # DeviceTypeMapping + 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)), + ( + "netbox_device_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_device_type_mappings", + to="dcim.devicetype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_hardware"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + # InterfaceTypeMapping: ordering + unique_together → UniqueConstraint + 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( + fields=("librenms_type", "librenms_speed"), name="unique_interface_type_mapping" + ), + ), + # ModuleTypeMapping + 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, unique=True)), + ("description", models.TextField(blank=True)), + ( + "netbox_module_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_module_type_mappings", + to="dcim.moduletype", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_model"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + # ModuleBayMapping (with UniqueConstraint directly) + 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)), + ("description", models.TextField(blank=True)), + ( + "is_regex", + models.BooleanField( + default=False, + help_text="Treat LibreNMS Name as a regex pattern with backreferences in NetBox Bay Name", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_name"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.AddConstraint( + model_name="modulebaymapping", + constraint=models.UniqueConstraint( + fields=("librenms_name", "librenms_class"), name="unique_module_bay_mapping" + ), + ), + # NormalizationRule (with SET_NULL and db_index on scope) + 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)), + ( + "manufacturer", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="normalization_rules", + to="dcim.manufacturer", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["scope", "priority", "pk"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + # InventoryIgnoreRule (with db_index on enabled) + 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( + choices=[ + ("ends_with", "Ends with (entPhysicalName)"), + ("starts_with", "Starts with (entPhysicalName)"), + ("contains", "Contains (entPhysicalName)"), + ("regex", "Regex (entPhysicalName)"), + ("serial_matches_device", "Serial matches device (entPhysicalSerialNum = Device.serial)"), + ], + default="ends_with", + max_length=25, + ), + ), + ("pattern", models.CharField(blank=True, max_length=200)), + ( + "action", + models.CharField( + choices=[ + ("skip", "Skip (remove from table)"), + ("transparent", "Transparent (hide row, promote children to device level)"), + ], + 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)), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["name", "pk"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + migrations.RunPython(code=_insert_default_rules, reverse_code=_delete_default_rules), + # PlatformMapping + 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)), + ( + "netbox_platform", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="librenms_platform_mappings", + to="dcim.platform", + ), + ), + ("tags", taggit.managers.TaggableManager(through="extras.TaggedItem", to="extras.Tag")), + ], + options={ + "ordering": ["librenms_os"], + }, + bases=( + netbox_librenms_plugin.models.FullCleanOnSaveMixin, + netbox.models.deletion.DeleteMixin, + models.Model, + ), + ), + ] diff --git a/netbox_librenms_plugin/models.py b/netbox_librenms_plugin/models.py index cd79f4755..9d0039a0e 100644 --- a/netbox_librenms_plugin/models.py +++ b/netbox_librenms_plugin/models.py @@ -1,8 +1,26 @@ +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__) + + +class FullCleanOnSaveMixin: + """Mixin that calls full_clean() on every save() so custom clean() logic runs even on programmatic saves.""" + + def save(self, *args, **kwargs): + if not kwargs.get("update_fields"): + self.full_clean() + super().save(*args, **kwargs) + class LibreNMSSettings(models.Model): """ @@ -34,6 +52,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.""" @@ -70,7 +92,555 @@ 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"], + name="unique_interface_type_mapping", + ), + ] + 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 so whitespace-padded values don't create duplicate entries.""" + super().clean() + self.librenms_hardware = (self.librenms_hardware or "").strip() + 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, + unique=True, + 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", + ) + 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"] + + def __str__(self): + return f"{self.librenms_model} -> {self.netbox_module_type}" + + def to_yaml(self): + data = { + "librenms_model": self.librenms_model, + "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", + ) + 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 "" + 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: + pattern.sub(self.netbox_bay_name, self.librenms_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.""" + + constraints = [ + models.UniqueConstraint( + fields=["librenms_name", "librenms_class"], + name="unique_module_bay_mapping", + ), + ] + ordering = ["librenms_name"] + + def __str__(self): + cls = f" [{self.librenms_class}]" if self.librenms_class else "" + return f"{self.librenms_name}{cls} -> {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, + "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: + compiled.sub(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 check_match(self, item_name: str, item_serial: str, device_serial: str) -> bool: + """ + Return True if this rule matches the given inventory item. + + For ``serial_matches_device``: compares *item_serial* to *device_serial*. + For all other match types: delegates to :meth:`matches_name`. + """ + if self.match_type == self.MATCH_SERIAL_DEVICE: + return bool(item_serial and device_serial and item_serial == device_serial) + return self.matches_name(item_name) + + 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 so whitespace-padded values don't create duplicate entries.""" + super().clean() + self.librenms_os = (self.librenms_os or "").strip() + 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) diff --git a/netbox_librenms_plugin/navigation.py b/netbox_librenms_plugin/navigation.py index a08e62740..be89dc1c2 100644 --- a/netbox_librenms_plugin/navigation.py +++ b/netbox_librenms_plugin/navigation.py @@ -1,6 +1,6 @@ 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", @@ -23,11 +23,127 @@ 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], + ), + ), + ), + PluginMenuItem( + 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], + ), + ), + ), + PluginMenuItem( + 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: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: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: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: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], ), ), ), 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 f0ab37e22..7d57daea0 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 @@ -193,10 +193,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"); } // ============================================ @@ -256,6 +261,7 @@ function initializeCheckboxes() { initializeTableCheckboxes('librenms-ipaddress-table'); initializeTableCheckboxes('librenms-vlan-table'); initializeTableCheckboxes('librenms-port-vlan-table'); + initializeTableCheckboxes('librenms-module-table'); } // ============================================ diff --git a/netbox_librenms_plugin/tables/__init__.py b/netbox_librenms_plugin/tables/__init__.py index 32bade63f..8fb6f4691 100644 --- a/netbox_librenms_plugin/tables/__init__.py +++ b/netbox_librenms_plugin/tables/__init__.py @@ -3,18 +3,32 @@ from .interfaces import LibreNMSInterfaceTable, LibreNMSVMInterfaceTable, VCInterfaceTable from .ipaddresses import IPAddressTable from .locations import SiteLocationSyncTable -from .mappings import InterfaceTypeMappingTable +from .mappings import ( + DeviceTypeMappingTable, + InterfaceTypeMappingTable, + InventoryIgnoreRuleTable, + ModuleBayMappingTable, + ModuleTypeMappingTable, + NormalizationRuleTable, + PlatformMappingTable, +) from .vlans import LibreNMSVLANTable from .VM_status import VMStatusTable __all__ = [ "DeviceStatusTable", + "DeviceTypeMappingTable", "InterfaceTypeMappingTable", + "InventoryIgnoreRuleTable", "IPAddressTable", "LibreNMSCableTable", "LibreNMSInterfaceTable", "LibreNMSVLANTable", "LibreNMSVMInterfaceTable", + "ModuleBayMappingTable", + "ModuleTypeMappingTable", + "NormalizationRuleTable", + "PlatformMappingTable", "SiteLocationSyncTable", "VCInterfaceTable", "VMStatusTable", diff --git a/netbox_librenms_plugin/tables/mappings.py b/netbox_librenms_plugin/tables/mappings.py index 73949fd2c..cb9e4f680 100644 --- a/netbox_librenms_plugin/tables/mappings.py +++ b/netbox_librenms_plugin/tables/mappings.py @@ -1,7 +1,16 @@ import django_tables2 as tables +from django.utils.html import format_html from netbox.tables import NetBoxTable, columns -from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.models import ( + DeviceTypeMapping, + InterfaceTypeMapping, + InventoryIgnoreRule, + ModuleBayMapping, + ModuleTypeMapping, + NormalizationRule, + PlatformMapping, +) class InterfaceTypeMappingTable(NetBoxTable): @@ -36,3 +45,220 @@ class Meta: "actions", ) attrs = {"class": "table table-hover table-headings table-striped"} + + +class DeviceTypeMappingTable(NetBoxTable): + """Table for displaying DeviceTypeMapping data.""" + + librenms_hardware = tables.Column(verbose_name="LibreNMS Hardware", linkify=True) + netbox_device_type = tables.Column(verbose_name="NetBox Device Type", linkify=True) + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for DeviceTypeMappingTable.""" + + model = DeviceTypeMapping + fields = ( + "id", + "librenms_hardware", + "netbox_device_type", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_hardware", + "netbox_device_type", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class ModuleTypeMappingTable(NetBoxTable): + """Table for displaying ModuleTypeMapping data.""" + + librenms_model = tables.Column(verbose_name="LibreNMS Model", linkify=True) + netbox_module_type = tables.Column(verbose_name="NetBox Module Type", linkify=True) + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for ModuleTypeMappingTable.""" + + model = ModuleTypeMapping + fields = ( + "id", + "librenms_model", + "netbox_module_type", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_model", + "netbox_module_type", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class ModuleBayMappingTable(NetBoxTable): + """Table for displaying ModuleBayMapping data.""" + + librenms_name = tables.Column(verbose_name="LibreNMS Name", linkify=True) + librenms_class = tables.Column(verbose_name="LibreNMS Class") + netbox_bay_name = tables.Column(verbose_name="NetBox Bay Name") + is_regex = columns.BooleanColumn(verbose_name="Regex") + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for ModuleBayMappingTable.""" + + model = ModuleBayMapping + fields = ( + "id", + "librenms_name", + "librenms_class", + "netbox_bay_name", + "is_regex", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_name", + "librenms_class", + "netbox_bay_name", + "is_regex", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class NormalizationRuleTable(NetBoxTable): + """Table for displaying NormalizationRule data.""" + + scope = tables.Column(verbose_name="Scope", linkify=True) + manufacturer = tables.Column(verbose_name="Manufacturer", linkify=True) + match_pattern = tables.Column(verbose_name="Match Pattern") + replacement = tables.Column(verbose_name="Replacement") + priority = tables.Column(verbose_name="Priority") + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for NormalizationRuleTable.""" + + model = NormalizationRule + fields = ( + "id", + "scope", + "manufacturer", + "match_pattern", + "replacement", + "priority", + "description", + "actions", + ) + default_columns = ( + "id", + "scope", + "manufacturer", + "match_pattern", + "replacement", + "priority", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class InventoryIgnoreRuleTable(NetBoxTable): + """Table for displaying InventoryIgnoreRule data.""" + + name = tables.Column(verbose_name="Name", linkify=True) + match_type = tables.Column(verbose_name="Match Type") + action = tables.Column(verbose_name="Action") + pattern = tables.Column(verbose_name="Pattern", empty_values=()) + require_serial_match_parent = tables.BooleanColumn(verbose_name="Require Serial Match") + enabled = tables.BooleanColumn(verbose_name="Enabled") + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + def render_action(self, value, record): + """Display the human-readable action label.""" + return record.get_action_display() + + def render_pattern(self, value, record): + """Show dash for serial_matches_device rules where pattern is unused.""" + if record.match_type == InventoryIgnoreRule.MATCH_SERIAL_DEVICE: + return format_html('') + return format_html("{}", value) if value else "—" + + def render_require_serial_match_parent(self, value, record): + """Show the actual stored boolean for require_serial_match_parent.""" + return ( + format_html('Yes') + if value + else format_html('No') + ) + + class Meta: + """Meta options for InventoryIgnoreRuleTable.""" + + model = InventoryIgnoreRule + fields = ( + "id", + "name", + "match_type", + "action", + "pattern", + "require_serial_match_parent", + "enabled", + "description", + "actions", + ) + default_columns = ( + "id", + "name", + "match_type", + "action", + "pattern", + "require_serial_match_parent", + "enabled", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} + + +class PlatformMappingTable(NetBoxTable): + """Table for displaying PlatformMapping data.""" + + librenms_os = tables.Column(verbose_name="LibreNMS OS", linkify=True) + netbox_platform = tables.Column(verbose_name="NetBox Platform", linkify=True) + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for PlatformMappingTable.""" + + model = PlatformMapping + fields = ( + "id", + "librenms_os", + "netbox_platform", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_os", + "netbox_platform", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} diff --git a/netbox_librenms_plugin/tables/modules.py b/netbox_librenms_plugin/tables/modules.py new file mode 100644 index 000000000..62be49b67 --- /dev/null +++ b/netbox_librenms_plugin/tables/modules.py @@ -0,0 +1,269 @@ +import django_tables2 as tables +from django.urls import reverse +from django.utils.html import format_html, mark_safe +from netbox.tables.columns import ToggleColumn +from utilities.paginator import EnhancedPaginator + +from netbox_librenms_plugin.utils import get_table_paginate_count + + +class LibreNMSModuleTable(tables.Table): + """Table for displaying LibreNMS inventory items mapped to NetBox modules.""" + + selection = ToggleColumn( + orderable=False, + visible=True, + accessor="ent_physical_index", + attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}}, + ) + name = tables.Column( + verbose_name="Name", + empty_values=(), + attrs={ + "td": {"data-col": "name"}, + "th": { + "title": "Name from ENTITY-MIB (entPhysicalName). May differ from interface names in ifDescr/ifName." + }, + }, + ) + model = tables.Column(verbose_name="Model", empty_values=(), attrs={"td": {"data-col": "model"}}) + serial = tables.Column(verbose_name="Serial", empty_values=(), attrs={"td": {"data-col": "serial"}}) + description = tables.Column(verbose_name="Description", empty_values=(), attrs={"td": {"data-col": "description"}}) + item_class = tables.Column(verbose_name="Class", empty_values=(), attrs={"td": {"data-col": "item_class"}}) + module_bay = tables.Column(verbose_name="Module Bay", empty_values=(), attrs={"td": {"data-col": "module_bay"}}) + module_type = tables.Column(verbose_name="Module Type", empty_values=(), attrs={"td": {"data-col": "module_type"}}) + status = tables.Column(verbose_name="Status", empty_values=(), attrs={"td": {"data-col": "status"}}) + actions = tables.Column( + verbose_name="Actions", orderable=False, empty_values=(), attrs={"td": {"data-col": "actions"}} + ) + + class Meta: + attrs = {"class": "table table-hover object-list", "id": "librenms-module-table"} + row_attrs = { + "class": lambda record: record.get("row_class", ""), + "data-ent-index": lambda record: record.get("ent_physical_index", ""), + "data-status": lambda record: record.get("status", ""), + "data-depth": lambda record: str(record.get("depth", 0)), + "data-item-class": lambda record: record.get("item_class", ""), + } + + def __init__( + self, + *args, + device=None, + server_key="", + can_add_module=False, + can_change_module=False, + can_delete_module=False, + **kwargs, + ): + """Initialize table with optional device context.""" + self.device = device + self.csrf_token = "" + self.server_key = server_key + self.can_add_module = can_add_module + self.can_change_module = can_change_module + self.can_delete_module = can_delete_module + super().__init__(*args, **kwargs) + if not can_add_module and hasattr(self, "columns"): + self.columns["selection"].column.visible = False + self.tab = "modules" + self.htmx_url = None + self.prefix = "modules_" + + def configure(self, request): + """Configure pagination settings and CSRF token.""" + from django.middleware.csrf import get_token + + self.csrf_token = get_token(request) + paginate = {"paginator_class": EnhancedPaginator, "per_page": get_table_paginate_count(request, self.prefix)} + tables.RequestConfig(request, paginate).configure(self) + + def render_name(self, value, record): + """Render inventory item name with tree indentation for sub-components.""" + depth = record.get("depth", 0) + if depth == 0: + return value or "-" + # Build visual tree prefix based on nesting depth + padding_px = depth * 20 + prefix = "└─ " + return format_html('{}{}', padding_px, prefix, value or "-") + + def render_model(self, value, record): + """Render model with link to module type if matched.""" + if not value or value == "-": + return "-" + if url := record.get("module_type_url"): + return format_html('{}', url, value) + return value + + def render_serial(self, value, record): + """Render serial number.""" + return value or "-" + + def render_description(self, value, record): + """Render description, truncated for display.""" + if not value: + return "-" + if len(value) > 60: + return format_html('{}…', value, value[:57]) + return value + + def render_item_class(self, value, record): + """Render the entPhysicalClass with an icon.""" + icons = { + "module": "mdi-expansion-card", + "ioModule": "mdi-expansion-card", + "cpmModule": "mdi-expansion-card", + "mdaModule": "mdi-expansion-card", + "fabricModule": "mdi-expansion-card", + "xioModule": "mdi-expansion-card", + "powerSupply": "mdi-power-plug", + "fan": "mdi-fan", + "port": "mdi-ethernet", + "other": "mdi-card-outline", + } + icon = icons.get(value, "mdi-card-outline") + return format_html(' {}', icon, value) + + def render_module_bay(self, value, record): + """Render module bay with link if found in NetBox.""" + if not value or value == "-": + return format_html('{}', "No matching bay") + if url := record.get("module_bay_url"): + return format_html('{}', url, value) + return value + + def render_module_type(self, value, record): + """Render module type match status.""" + if not value or value == "-": + return format_html('{}', "No matching type") + if url := record.get("module_type_url"): + return format_html('{}', url, value) + return value + + def render_status(self, value, record): + """Render sync status with badge.""" + badge_classes = { + "Installed": "bg-success", + "Matched": "bg-info", + "No Bay": "bg-warning", + "No Type": "bg-warning", + "Unmatched": "bg-secondary", + "Serial Mismatch": "bg-danger", + "Name Conflict": "bg-warning", + "Type Mismatch": "bg-warning", + } + badge_class = badge_classes.get(value, "bg-secondary") + if warning := record.get("name_conflict_warning"): + return format_html( + '{}' + ' ', + badge_class, + warning, + value, + warning, + ) + return format_html('{}', badge_class, value) + + def render_actions(self, value, record): + """Render install button for matched modules and install branch for parents.""" + if not self.device: + return "" + if not self.can_add_module and not self.can_change_module: + return "" + + buttons = [] + + # Single install button (requires add permission) + if self.can_add_module and record.get("can_install"): + url = reverse("plugins:netbox_librenms_plugin:install_module", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '' + '' + '' + '
", + url, + self.csrf_token, + self.server_key, + record.get("module_bay_id", ""), + record.get("module_type_id", ""), + record.get("serial") or "", + ) + ) + + # Install branch button for parents with installable children (requires add) + if self.can_add_module and record.get("has_installable_children") and record.get("ent_physical_index"): + url = reverse("plugins:netbox_librenms_plugin:install_branch", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '' + '
", + url, + self.csrf_token, + self.server_key, + record.get("ent_physical_index", ""), + ) + ) + + # Update serial button for serial mismatch rows (requires change) + if self.can_change_module and record.get("can_update_serial") and record.get("installed_module_id"): + url = reverse("plugins:netbox_librenms_plugin:update_module_serial", kwargs={"pk": self.device.pk}) + buttons.append( + format_html( + '
' + '' + '' + '' + '' + '
", + url, + self.csrf_token, + self.server_key, + record["installed_module_id"], + record.get("serial") or "", + ) + ) + + # Replace button for type/serial mismatch rows (requires add+change+delete) + if ( + self.can_add_module + and self.can_change_module + and self.can_delete_module + and record.get("can_replace") + and record.get("installed_module_id") + ): + preview_url = reverse( + "plugins:netbox_librenms_plugin:module_mismatch_preview", kwargs={"pk": self.device.pk} + ) + buttons.append( + format_html( + '", + record["installed_module_id"], + record.get("ent_physical_index", ""), + self.server_key or "", + preview_url, + ) + ) + + return mark_safe("".join(buttons)) if buttons else "" diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html new file mode 100644 index 000000000..3eb57d10e --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync.html @@ -0,0 +1,28 @@ +{% load helpers %} + + +
+

Module Sync

+
+
+ {% csrf_token %} + + {% if has_librenms_id %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ + +
+ {% include 'netbox_librenms_plugin/_module_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html new file mode 100644 index 000000000..eccff430e --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_module_sync_content.html @@ -0,0 +1,44 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + + +{% if module_sync.table %} +
+
+ + Showing inventory items from LibreNMS matched against NetBox module bays and module types. + +
+ {% if module_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ +{% if has_write_permission %} +{# Separate form for Install Selected — uses JS to collect checked rows before submit #} +
+ {% csrf_token %} + +
+ +
+
+{% endif %} +
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} + {% include 'inc/table.html' with table=module_sync.table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=module_sync.table %} +
+{% else %} +
+
+ +

No inventory data loaded. Click Refresh Modules to fetch data from LibreNMS.

+
+
+{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html new file mode 100644 index 000000000..389444117 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping.html @@ -0,0 +1,28 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + +
LibreNMS HardwareNetBox Device TypeDescription
{{ object.librenms_hardware }}{{ object.netbox_device_type }}{{ object.description|default:"—" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html new file mode 100644 index 000000000..602542266 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/devicetypemapping_list.html @@ -0,0 +1,19 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Device Type Mapping

+

Map LibreNMS hardware strings to NetBox device types. + When importing devices from LibreNMS, these mappings are checked first before + falling back to exact part number / model matching.

+

Example: Map "Juniper MX480 Internet Backbone Router" to device type "MX480"

+
+ {{ block.super }} +{% endblock %} + +{% block bulk_buttons %} + {{ block.super }} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html new file mode 100644 index 000000000..c3142ace3 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/module_mismatch_modal.html @@ -0,0 +1,112 @@ +{% load helpers %} +{# Modal body fragment for the module replace / move dialog. #} +{# Rendered server-side by ModuleMismatchPreviewView and injected into #htmx-modal-body via JS. #} + +

Comparing the module currently in NetBox with the LibreNMS inventory data.

+ + + + + + + + + + + + + + + + + + + + + + + + + +
Currently in NetBoxFrom LibreNMS
Bay{{ bay_name }}
Module Type + {% if installed_module %} + {{ installed_module.module_type.model }} + {% else %}-{% endif %} + {{ librenms_model }}
Serial{{ installed_serial|default:"-" }}{{ librenms_serial|default:"-" }}
+ +{% if type_mismatch %} +
+ + Different module type — the installed module type does not match LibreNMS. + Replacing will delete the current module and install {{ librenms_model }}. +
+{% elif serial_mismatch %} +
+ + Same module type, different serial — the module may have been physically replaced. +
+{% endif %} + +{% if serial_conflict %} +
+ + Serial conflict: {{ librenms_serial }} is currently installed at + {{ serial_conflict.device.name }} / + Bay: {{ serial_conflict.module_bay.name }}. +
+ Replace will also remove it from that location. + Move will update its location to this bay instead of creating a new entry. + +
+{% endif %} + +
+ + + {% if serial_mismatch and not type_mismatch %} + {# Quick serial-only update — no delete/recreate needed #} +
+ {% csrf_token %} + + + + +
+ {% endif %} + + {% if serial_conflict %} + {# Move the existing module here rather than creating a new entry #} +
+ {% csrf_token %} + + + + + +
+ {% endif %} + + {# Replace: delete current + install fresh from LibreNMS data #} +
+ {% csrf_token %} + + + + {% if serial_conflict %} + + {% endif %} + +
+
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html index 3044c4dda..64ea3f183 100644 --- a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html @@ -10,3 +10,10 @@

Interface Type Mapping

{{ 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 000000000..62db37c67 --- /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 000000000..13c0040d2 --- /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 3378b513d..9cd24b6e4 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 %} @@ -329,27 +335,23 @@
Device Information Sync
- Name - - + Name
{{ object.name }}
- {% if resolved_name and resolved_name != object.name %} + {% if sysName and sysName != "-" and sysName != object.name %}
{% csrf_token %}
- {% elif resolved_name %} - + {% elif sysName and sysName != "-" %} + {% endif %} @@ -407,6 +409,9 @@
Device Information Sync
Serial Number + {% if object.virtual_chassis and vc_inventory_serials %} + {{ vc_inventory_serials|length }} + {% endif %}
@@ -422,7 +427,7 @@
Device Information Sync
{% else %} {% if librenms_device_serial != "-" %} @@ -621,6 +626,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 %}