From 673e67106e808d633a459c95e3b0c4cc0648d525 Mon Sep 17 00:00:00 2001 From: Vlastislav Svatek Date: Fri, 5 Jun 2026 10:39:05 +0200 Subject: [PATCH] first commit --- .devcontainer/.env.example | 55 + .devcontainer/README.md | 396 ++ .../config/codespaces-configuration.py | 30 + .../config/extra-configuration.py.example | 49 + .devcontainer/config/extra-plugins.py.example | 0 .devcontainer/config/plugin-config.py.example | 46 + .devcontainer/devcontainer.json | 82 + .devcontainer/docker-compose.yml | 74 + .devcontainer/extra-requirements.txt.example | 27 + .devcontainer/scripts/diagnose.sh | 65 + .devcontainer/scripts/load-aliases.sh | 224 + .devcontainer/scripts/process-helpers.sh | 24 + .devcontainer/scripts/setup.sh | 329 + .devcontainer/scripts/start-netbox.sh | 107 + .devcontainer/scripts/welcome.sh | 56 + .editorconfig | 21 + .flake8 | 2 + .github/FUNDING.yml | 15 + .github/ISSUE_TEMPLATE/bug_report.yaml | 61 + .github/ISSUE_TEMPLATE/config.yml | 12 + .github/ISSUE_TEMPLATE/feature_request.yaml | 59 + .github/ISSUE_TEMPLATE/housekeeping.yaml | 24 + .github/copilot-instructions.md | 75 + .github/dependabot.yml | 14 + .../background-jobs.instructions.md | 84 + .github/instructions/frontend.instructions.md | 66 + .github/instructions/sync.instructions.md | 72 + .github/instructions/testing.instructions.md | 50 + .github/pull_request_template.md | 49 + .github/workflows/codeql.yml | 103 + .github/workflows/lint-format.yaml | 50 + .github/workflows/mkdocs.yaml | 18 + .github/workflows/publish-pypi.yaml | 58 + .github/workflows/test.yaml | 88 + .gitignore | 292 + .pre-commit-config.yaml | 19 + .pypirc | 0 LICENSE | 201 + MANIFEST.in | 12 + Makefile | 22 + README.md | 259 + docs/README.md | 259 + docs/SUMMARY.md | 28 + docs/changelog.md | 428 ++ docs/contributing.md | 112 + docs/development/README.md | 10 + docs/development/mixins.md | 44 + docs/development/structure.md | 35 + docs/development/templates.md | 28 + docs/development/testing.md | 247 + docs/development/views.md | 74 + docs/feature_list.md | 72 + docs/img/Netbox-librenms-plugin-Sites.gif | Bin 0 -> 688403 bytes .../Netbox-librenms-plugin-interfaceadd.gif | Bin 0 -> 1772253 bytes docs/img/Netbox-librenms-plugin-mappings.png | Bin 0 -> 78959 bytes .../Netbox-librenms-plugin-virtualchassis.gif | Bin 0 -> 6471230 bytes docs/img/interface_mappings/addmapping.png | Bin 0 -> 1438 bytes docs/img/interface_mappings/deletemapping.png | Bin 0 -> 4294 bytes docs/img/interface_mappings/editmapping.png | Bin 0 -> 1073 bytes .../interfacemappings_menu.png | Bin 0 -> 20817 bytes docs/img/netbox-librenms-plugin-dbdiagram.png | Bin 0 -> 6172 bytes docs/librenms_import/background_jobs.md | 76 + docs/librenms_import/import_settings.md | 71 + docs/librenms_import/overview.md | 71 + docs/librenms_import/search.md | 98 + docs/librenms_import/validation.md | 51 + docs/usage_tips/README.md | 94 + docs/usage_tips/custom_field.md | 97 + docs/usage_tips/interface_mappings.md | 137 + docs/usage_tips/multi_server_configuration.md | 92 + docs/usage_tips/permissions.md | 153 + docs/usage_tips/suggested_workflow.md | 88 + docs/usage_tips/virtual_chassis.md | 31 + media/configuration.testing.py | 52 + mkdocs.yml | 88 + netbox_librenms_plugin/__init__.py | 142 + netbox_librenms_plugin/admin.py | 0 netbox_librenms_plugin/api/__init__.py | 0 netbox_librenms_plugin/api/serializers.py | 13 + netbox_librenms_plugin/api/urls.py | 13 + netbox_librenms_plugin/api/views.py | 106 + netbox_librenms_plugin/constants.py | 6 + netbox_librenms_plugin/filters.py | 13 + netbox_librenms_plugin/filtersets.py | 140 + netbox_librenms_plugin/forms.py | 787 +++ .../import_utils/__init__.py | 53 + .../import_utils/bulk_import.py | 706 ++ netbox_librenms_plugin/import_utils/cache.py | 232 + .../import_utils/device_operations.py | 1003 +++ .../import_utils/filters.py | 288 + .../import_utils/permissions.py | 44 + .../import_utils/virtual_chassis.py | 640 ++ .../import_utils/vm_operations.py | 257 + .../import_validation_helpers.py | 168 + netbox_librenms_plugin/jobs.py | 275 + netbox_librenms_plugin/librenms_api.py | 1098 +++ .../migrations/0001_initial.py | 23 + ...2_interfacetypemapping_created_and_more.py | 35 + ...facetypemapping_librenms_speed_and_more.py | 26 + .../migrations/0004_librenmssettings.py | 46 + ...emove_librenmssettings_created_and_more.py | 28 + .../0006_interfacetypemapping_description.py | 20 + ...librenmssettings_vc_member_name_pattern.py | 21 + .../0008_librenmssettings_import_defaults.py | 28 + .../0009_convert_librenms_id_to_json.py | 78 + netbox_librenms_plugin/migrations/__init__.py | 0 netbox_librenms_plugin/models.py | 76 + netbox_librenms_plugin/navigation.py | 67 + .../js/librenms_import.js | 1347 ++++ .../js/librenms_sync.js | 1694 +++++ netbox_librenms_plugin/tables/VM_status.py | 56 + netbox_librenms_plugin/tables/__init__.py | 21 + netbox_librenms_plugin/tables/cables.py | 161 + .../tables/device_status.py | 722 ++ netbox_librenms_plugin/tables/interfaces.py | 616 ++ netbox_librenms_plugin/tables/ipaddresses.py | 122 + netbox_librenms_plugin/tables/locations.py | 84 + netbox_librenms_plugin/tables/mappings.py | 38 + netbox_librenms_plugin/tables/vlans.py | 183 + .../netbox_librenms_plugin/_cable_sync.html | 28 + .../_cable_sync_content.html | 118 + .../_interface_sync.html | 37 + .../_interface_sync_content.html | 358 + .../_ipaddress_sync.html | 37 + .../_ipaddress_sync_content.html | 84 + .../netbox_librenms_plugin/_vlan_sync.html | 30 + .../_vlan_sync_content.html | 63 + .../htmx/bulk_import_confirm.html | 212 + .../htmx/device_import_row.html | 22 + .../htmx/device_validation_details.html | 625 ++ .../htmx/device_vc_details.html | 121 + .../netbox_librenms_plugin/inc/paginator.html | 59 + .../interfacetypemapping.html | 30 + .../interfacetypemapping_list.html | 12 + .../librenms_import.html | 438 ++ .../librenms_sync_base.html | 1018 +++ .../netbox_librenms_plugin/settings.html | 339 + .../site_location_sync.html | 51 + .../netbox_librenms_plugin/status_check.html | 15 + netbox_librenms_plugin/tests/__init__.py | 1 + netbox_librenms_plugin/tests/conftest.py | 336 + .../tests/mock_librenms_server.py | 262 + .../tests/test_background_jobs.py | 967 +++ .../tests/test_cable_verify.py | 311 + .../tests/test_coverage_actions.py | 3955 +++++++++++ .../tests/test_coverage_api.py | 1217 ++++ .../tests/test_coverage_api2.py | 707 ++ .../tests/test_coverage_base_views.py | 2196 ++++++ .../tests/test_coverage_base_views2.py | 2037 ++++++ .../tests/test_coverage_cache.py | 320 + .../tests/test_coverage_device_fields.py | 2039 ++++++ .../tests/test_coverage_device_operations.py | 1667 +++++ .../tests/test_coverage_filters.py | 768 +++ .../tests/test_coverage_forms.py | 61 + .../tests/test_coverage_list.py | 1406 ++++ .../tests/test_coverage_mixins.py | 1022 +++ .../tests/test_coverage_sync_interfaces.py | 1193 ++++ .../tests/test_coverage_sync_view.py | 692 ++ .../tests/test_coverage_sync_views.py | 2577 +++++++ .../tests/test_coverage_sync_views2.py | 2240 +++++++ .../tests/test_coverage_sync_views3.py | 980 +++ .../tests/test_coverage_tables.py | 2649 ++++++++ .../tests/test_coverage_utils.py | 551 ++ .../tests/test_coverage_virtual_chassis.py | 294 + .../tests/test_coverage_vlans_table.py | 367 + .../tests/test_import_utils.py | 5894 +++++++++++++++++ .../tests/test_import_validation_helpers.py | 368 + netbox_librenms_plugin/tests/test_init.py | 216 + .../tests/test_integration_sync.py | 399 ++ .../tests/test_integration_virtual_chassis.py | 854 +++ .../tests/test_interface_vlan_sync.py | 563 ++ .../tests/test_ip_verify.py | 137 + .../tests/test_librenms_api.py | 1347 ++++ .../tests/test_librenms_api_helpers.py | 26 + .../tests/test_librenms_id.py | 367 + netbox_librenms_plugin/tests/test_mixins.py | 207 + .../tests/test_netbox_librenms_plugin.py | 3 + .../tests/test_permissions.py | 1083 +++ .../tests/test_reviewer_fixes.py | 426 ++ .../tests/test_sync_devices.py | 314 + .../tests/test_sync_interfaces.py | 302 + .../tests/test_sync_view_mismatch.py | 589 ++ netbox_librenms_plugin/tests/test_utils.py | 818 +++ .../tests/test_verify_views.py | 205 + .../tests/test_view_wiring.py | 443 ++ .../tests/test_virtual_chassis.py | 103 + .../tests/test_vlan_sync.py | 483 ++ .../tests/test_vm_operations.py | 669 ++ netbox_librenms_plugin/urls.py | 351 + netbox_librenms_plugin/utils.py | 768 +++ netbox_librenms_plugin/views/__init__.py | 67 + netbox_librenms_plugin/views/base/__init__.py | 13 + .../views/base/cables_view.py | 576 ++ .../views/base/interfaces_view.py | 374 ++ .../views/base/ip_addresses_view.py | 498 ++ .../views/base/librenms_sync_view.py | 547 ++ .../views/base/vlan_table_view.py | 216 + .../views/imports/__init__.py | 27 + .../views/imports/actions.py | 1418 ++++ netbox_librenms_plugin/views/imports/list.py | 480 ++ netbox_librenms_plugin/views/mapping_views.py | 86 + netbox_librenms_plugin/views/mixins.py | 693 ++ .../views/object_sync/__init__.py | 18 + .../views/object_sync/devices.py | 395 ++ .../views/object_sync/vms.py | 77 + .../views/settings_views.py | 194 + netbox_librenms_plugin/views/status_check.py | 117 + netbox_librenms_plugin/views/sync/__init__.py | 0 netbox_librenms_plugin/views/sync/cables.py | 237 + .../views/sync/device_fields.py | 713 ++ netbox_librenms_plugin/views/sync/devices.py | 141 + .../views/sync/interfaces.py | 398 ++ .../views/sync/ip_addresses.py | 160 + .../views/sync/locations.py | 170 + netbox_librenms_plugin/views/sync/vlans.py | 175 + pyproject.toml | 71 + requirements_dev.txt | 2 + 217 files changed, 76612 insertions(+) create mode 100644 .devcontainer/.env.example create mode 100644 .devcontainer/README.md create mode 100644 .devcontainer/config/codespaces-configuration.py create mode 100644 .devcontainer/config/extra-configuration.py.example create mode 100644 .devcontainer/config/extra-plugins.py.example create mode 100644 .devcontainer/config/plugin-config.py.example create mode 100644 .devcontainer/devcontainer.json create mode 100644 .devcontainer/docker-compose.yml create mode 100644 .devcontainer/extra-requirements.txt.example create mode 100755 .devcontainer/scripts/diagnose.sh create mode 100755 .devcontainer/scripts/load-aliases.sh create mode 100755 .devcontainer/scripts/process-helpers.sh create mode 100755 .devcontainer/scripts/setup.sh create mode 100755 .devcontainer/scripts/start-netbox.sh create mode 100755 .devcontainer/scripts/welcome.sh create mode 100644 .editorconfig create mode 100644 .flake8 create mode 100644 .github/FUNDING.yml create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yaml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yaml create mode 100644 .github/ISSUE_TEMPLATE/housekeeping.yaml create mode 100644 .github/copilot-instructions.md create mode 100644 .github/dependabot.yml create mode 100644 .github/instructions/background-jobs.instructions.md create mode 100644 .github/instructions/frontend.instructions.md create mode 100644 .github/instructions/sync.instructions.md create mode 100644 .github/instructions/testing.instructions.md create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/codeql.yml create mode 100644 .github/workflows/lint-format.yaml create mode 100644 .github/workflows/mkdocs.yaml create mode 100644 .github/workflows/publish-pypi.yaml create mode 100644 .github/workflows/test.yaml create mode 100644 .gitignore create mode 100644 .pre-commit-config.yaml create mode 100644 .pypirc create mode 100644 LICENSE create mode 100644 MANIFEST.in create mode 100644 Makefile create mode 100644 README.md create mode 100644 docs/README.md create mode 100644 docs/SUMMARY.md create mode 100644 docs/changelog.md create mode 100644 docs/contributing.md create mode 100644 docs/development/README.md create mode 100644 docs/development/mixins.md create mode 100644 docs/development/structure.md create mode 100644 docs/development/templates.md create mode 100644 docs/development/testing.md create mode 100644 docs/development/views.md create mode 100644 docs/feature_list.md create mode 100644 docs/img/Netbox-librenms-plugin-Sites.gif create mode 100644 docs/img/Netbox-librenms-plugin-interfaceadd.gif create mode 100644 docs/img/Netbox-librenms-plugin-mappings.png create mode 100644 docs/img/Netbox-librenms-plugin-virtualchassis.gif create mode 100644 docs/img/interface_mappings/addmapping.png create mode 100644 docs/img/interface_mappings/deletemapping.png create mode 100644 docs/img/interface_mappings/editmapping.png create mode 100644 docs/img/interface_mappings/interfacemappings_menu.png create mode 100644 docs/img/netbox-librenms-plugin-dbdiagram.png create mode 100644 docs/librenms_import/background_jobs.md create mode 100644 docs/librenms_import/import_settings.md create mode 100644 docs/librenms_import/overview.md create mode 100644 docs/librenms_import/search.md create mode 100644 docs/librenms_import/validation.md create mode 100644 docs/usage_tips/README.md create mode 100644 docs/usage_tips/custom_field.md create mode 100644 docs/usage_tips/interface_mappings.md create mode 100644 docs/usage_tips/multi_server_configuration.md create mode 100644 docs/usage_tips/permissions.md create mode 100644 docs/usage_tips/suggested_workflow.md create mode 100644 docs/usage_tips/virtual_chassis.md create mode 100644 media/configuration.testing.py create mode 100644 mkdocs.yml create mode 100644 netbox_librenms_plugin/__init__.py create mode 100644 netbox_librenms_plugin/admin.py create mode 100644 netbox_librenms_plugin/api/__init__.py create mode 100644 netbox_librenms_plugin/api/serializers.py create mode 100644 netbox_librenms_plugin/api/urls.py create mode 100644 netbox_librenms_plugin/api/views.py create mode 100644 netbox_librenms_plugin/constants.py create mode 100644 netbox_librenms_plugin/filters.py create mode 100644 netbox_librenms_plugin/filtersets.py create mode 100644 netbox_librenms_plugin/forms.py create mode 100644 netbox_librenms_plugin/import_utils/__init__.py create mode 100644 netbox_librenms_plugin/import_utils/bulk_import.py create mode 100644 netbox_librenms_plugin/import_utils/cache.py create mode 100644 netbox_librenms_plugin/import_utils/device_operations.py create mode 100644 netbox_librenms_plugin/import_utils/filters.py create mode 100644 netbox_librenms_plugin/import_utils/permissions.py create mode 100644 netbox_librenms_plugin/import_utils/virtual_chassis.py create mode 100644 netbox_librenms_plugin/import_utils/vm_operations.py create mode 100644 netbox_librenms_plugin/import_validation_helpers.py create mode 100644 netbox_librenms_plugin/jobs.py create mode 100644 netbox_librenms_plugin/librenms_api.py create mode 100644 netbox_librenms_plugin/migrations/0001_initial.py create mode 100644 netbox_librenms_plugin/migrations/0002_interfacetypemapping_created_and_more.py create mode 100644 netbox_librenms_plugin/migrations/0003_interfacetypemapping_librenms_speed_and_more.py create mode 100644 netbox_librenms_plugin/migrations/0004_librenmssettings.py create mode 100644 netbox_librenms_plugin/migrations/0005_remove_librenmssettings_created_and_more.py create mode 100644 netbox_librenms_plugin/migrations/0006_interfacetypemapping_description.py create mode 100644 netbox_librenms_plugin/migrations/0007_librenmssettings_vc_member_name_pattern.py create mode 100644 netbox_librenms_plugin/migrations/0008_librenmssettings_import_defaults.py create mode 100644 netbox_librenms_plugin/migrations/0009_convert_librenms_id_to_json.py create mode 100644 netbox_librenms_plugin/migrations/__init__.py create mode 100644 netbox_librenms_plugin/models.py create mode 100644 netbox_librenms_plugin/navigation.py create mode 100644 netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_import.js create mode 100644 netbox_librenms_plugin/static/netbox_librenms_plugin/js/librenms_sync.js create mode 100644 netbox_librenms_plugin/tables/VM_status.py create mode 100644 netbox_librenms_plugin/tables/__init__.py create mode 100644 netbox_librenms_plugin/tables/cables.py create mode 100644 netbox_librenms_plugin/tables/device_status.py create mode 100644 netbox_librenms_plugin/tables/interfaces.py create mode 100644 netbox_librenms_plugin/tables/ipaddresses.py create mode 100644 netbox_librenms_plugin/tables/locations.py create mode 100644 netbox_librenms_plugin/tables/mappings.py create mode 100644 netbox_librenms_plugin/tables/vlans.py create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync_content.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_vc_details.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/paginator.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/site_location_sync.html create mode 100644 netbox_librenms_plugin/templates/netbox_librenms_plugin/status_check.html create mode 100644 netbox_librenms_plugin/tests/__init__.py create mode 100644 netbox_librenms_plugin/tests/conftest.py create mode 100644 netbox_librenms_plugin/tests/mock_librenms_server.py create mode 100644 netbox_librenms_plugin/tests/test_background_jobs.py create mode 100644 netbox_librenms_plugin/tests/test_cable_verify.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_actions.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_api.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_api2.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_base_views.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_base_views2.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_cache.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_device_fields.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_device_operations.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_filters.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_forms.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_list.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_mixins.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_sync_interfaces.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_sync_view.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_sync_views.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_sync_views2.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_sync_views3.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_tables.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_utils.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_virtual_chassis.py create mode 100644 netbox_librenms_plugin/tests/test_coverage_vlans_table.py create mode 100644 netbox_librenms_plugin/tests/test_import_utils.py create mode 100644 netbox_librenms_plugin/tests/test_import_validation_helpers.py create mode 100644 netbox_librenms_plugin/tests/test_init.py create mode 100644 netbox_librenms_plugin/tests/test_integration_sync.py create mode 100644 netbox_librenms_plugin/tests/test_integration_virtual_chassis.py create mode 100644 netbox_librenms_plugin/tests/test_interface_vlan_sync.py create mode 100644 netbox_librenms_plugin/tests/test_ip_verify.py create mode 100644 netbox_librenms_plugin/tests/test_librenms_api.py create mode 100644 netbox_librenms_plugin/tests/test_librenms_api_helpers.py create mode 100644 netbox_librenms_plugin/tests/test_librenms_id.py create mode 100644 netbox_librenms_plugin/tests/test_mixins.py create mode 100644 netbox_librenms_plugin/tests/test_netbox_librenms_plugin.py create mode 100644 netbox_librenms_plugin/tests/test_permissions.py create mode 100644 netbox_librenms_plugin/tests/test_reviewer_fixes.py create mode 100644 netbox_librenms_plugin/tests/test_sync_devices.py create mode 100644 netbox_librenms_plugin/tests/test_sync_interfaces.py create mode 100644 netbox_librenms_plugin/tests/test_sync_view_mismatch.py create mode 100644 netbox_librenms_plugin/tests/test_utils.py create mode 100644 netbox_librenms_plugin/tests/test_verify_views.py create mode 100644 netbox_librenms_plugin/tests/test_view_wiring.py create mode 100644 netbox_librenms_plugin/tests/test_virtual_chassis.py create mode 100644 netbox_librenms_plugin/tests/test_vlan_sync.py create mode 100644 netbox_librenms_plugin/tests/test_vm_operations.py create mode 100644 netbox_librenms_plugin/urls.py create mode 100644 netbox_librenms_plugin/utils.py create mode 100644 netbox_librenms_plugin/views/__init__.py create mode 100644 netbox_librenms_plugin/views/base/__init__.py create mode 100644 netbox_librenms_plugin/views/base/cables_view.py create mode 100644 netbox_librenms_plugin/views/base/interfaces_view.py create mode 100644 netbox_librenms_plugin/views/base/ip_addresses_view.py create mode 100644 netbox_librenms_plugin/views/base/librenms_sync_view.py create mode 100644 netbox_librenms_plugin/views/base/vlan_table_view.py create mode 100644 netbox_librenms_plugin/views/imports/__init__.py create mode 100644 netbox_librenms_plugin/views/imports/actions.py create mode 100644 netbox_librenms_plugin/views/imports/list.py create mode 100644 netbox_librenms_plugin/views/mapping_views.py create mode 100644 netbox_librenms_plugin/views/mixins.py create mode 100644 netbox_librenms_plugin/views/object_sync/__init__.py create mode 100644 netbox_librenms_plugin/views/object_sync/devices.py create mode 100644 netbox_librenms_plugin/views/object_sync/vms.py create mode 100644 netbox_librenms_plugin/views/settings_views.py create mode 100644 netbox_librenms_plugin/views/status_check.py create mode 100644 netbox_librenms_plugin/views/sync/__init__.py create mode 100644 netbox_librenms_plugin/views/sync/cables.py create mode 100644 netbox_librenms_plugin/views/sync/device_fields.py create mode 100644 netbox_librenms_plugin/views/sync/devices.py create mode 100644 netbox_librenms_plugin/views/sync/interfaces.py create mode 100644 netbox_librenms_plugin/views/sync/ip_addresses.py create mode 100644 netbox_librenms_plugin/views/sync/locations.py create mode 100644 netbox_librenms_plugin/views/sync/vlans.py create mode 100644 pyproject.toml create mode 100644 requirements_dev.txt diff --git a/.devcontainer/.env.example b/.devcontainer/.env.example new file mode 100644 index 0000000..11f8040 --- /dev/null +++ b/.devcontainer/.env.example @@ -0,0 +1,55 @@ +# NetBox Development Environment Variables +# Copy this file to .env and customize as needed + +# NetBox Version to install (latest, v4.1-3.3.0, v4.0-3.3.0, snapshot) +NETBOX_VERSION=latest + +# Database Configuration +DB_HOST=postgres +DB_NAME=netbox +DB_USER=netbox +DB_PASSWORD=netbox + +# Redis Configuration +REDIS_HOST=redis +REDIS_PASSWORD= + +# Development Settings +DEBUG=True +DEVELOPER=True +SECRET_KEY=dev-secret-key-not-for-production-use-12345678901234 + +# Superuser Configuration (auto-created on first run) +SUPERUSER_NAME=admin +SUPERUSER_EMAIL=admin@example.com +SUPERUSER_PASSWORD=admin + +# Auto-creation control +SKIP_SUPERUSER=false + +# Plugins are configured only in: +# .devcontainer/plugin-config.py.example β†’ .devcontainer/plugin-config.py +# Advanced NetBox configuration (optional): +# .devcontainer/extra-configuration.py.example β†’ .devcontainer/extra-configuration.py + +# Proxy Configuration (optional, for corporate networks with MITM proxies) +# Uncomment and set these if you're behind a proxy +# HTTP_PROXY=http://proxy.example.com:8080 +# HTTPS_PROXY=http://proxy.example.com:8080 +# NO_PROXY=localhost,127.0.0.1,postgres,redis +# +# CA bundle configuration: +# Normally you SHOULD NOT set REQUESTS_CA_BUNDLE, SSL_CERT_FILE, or CURL_CA_BUNDLE here. +# Instead, place a ca-bundle.crt file in the workspace root and setup.sh will install it +# into the system trust store and set these variables automatically to: +# /etc/ssl/certs/ca-certificates.crt +# Only set the following manually for custom CA setups that cannot use the automatic +# configuration provided by setup.sh. +# REQUESTS_CA_BUNDLE=/custom/path/to/ca-bundle.crt +# SSL_CERT_FILE=/custom/path/to/ca-bundle.crt +# CURL_CA_BUNDLE=/custom/path/to/ca-bundle.crt + +# Git SSL verification override (default: false) +# Only set to true if behind a MITM proxy and you cannot provide a CA bundle. +# Prefer placing a ca-bundle.crt in the workspace root instead. +# ALLOW_GIT_SSL_DISABLE=false diff --git a/.devcontainer/README.md b/.devcontainer/README.md new file mode 100644 index 0000000..3560a93 --- /dev/null +++ b/.devcontainer/README.md @@ -0,0 +1,396 @@ +# NetBox LibreNMS Plugin - Development Container + +The Dev container was created to help aid with development without the need for a full NetBox installation locally. It provides a complete development environment using the official NetBox Docker images with PostgreSQL and Redis. + +This directory contains the development container configuration for the NetBox LibreNMS Plugin. + +## Table of contents + +- [Prerequisites](#-prerequisites) +- [Quick Start](#-quick-start) +- [Out-of-the-box defaults](#out-of-the-box-defaults) +- [Configuration](#-configuration) + - [NetBox Version and Environment](#netbox-version-and-environment-use-devcontainerenv) + - [Changing NetBox Versions](#-changing-netbox-versions) + - [Other environment variables](#other-environment-variables) +- [Other configurations](#other-configurations) + - [NetBox Configuration](#netbox-configuration) + - [Additional packages](#additional-packages-including-other-netbox-plugins) +- [Git Setup](#-git-setup) +- [Commands](#-commands-aliases) +- [Troubleshooting](#-troubleshooting) +- [Cleanup](#-cleanup-remove-the-dev-containers) + +## πŸ“‹ Prerequisites + +### For Local Development (VS Code) + +To use this dev container locally, you need: + +- **[Docker](https://docs.docker.com/get-docker/)** (Docker Engine + Docker Compose) +- **[Visual Studio Code](https://code.visualstudio.com/)** +- **[Dev Containers extension](https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers)** for VS Code + +### For GitHub Codespaces + +If using GitHub Codespaces, all prerequisites are automatically available - just click "Code" β†’ "Create codespace" in the GitHub repository. + +**⚠️ Network Limitation with Codespaces:** GitHub Codespaces runs in the cloud and can only access publicly available LibreNMS servers. + +If you need to test with a LibreNMS instance on a private network (local lab, corporate network, etc.), you'll need to use the local dev container instead. + + +## πŸš€ Quick Start + +1. Fork and Clone: fork the plugin repo in Github and clone locally +2. Open in VS Code and choose "Reopen in Container" (or Ctrl+Shift+P β†’ Dev Containers: Reopen in Container) +2. Wait for setup (~5min on first run or when new NetBox image is used). The container will install the plugin and prep NetBox +3. Set up GitHub access: `gh auth login` (for pushing/pulling code changes) +4. Create your plugin config β€” see [Plugin configuration](#plugin-configuration): + - `cp .devcontainer/config/plugin-config.py.example .devcontainer/config/plugin-config.py` + - Edit it with your server details (tokens/URLs) +5. Start NetBox with `netbox-run` (or `netbox-run-bg` in background) (see [Commands](#-commands-aliases)) +6. Access NetBox at http://localhost:8000 + - Username: `admin` + - Password: `admin` + +### πŸ”„ Code changes and Committing +8. Edit code in the repo root. Check out [contributing docs](../docs/contributing.md) +9. Use `netbox-logs` to follow log output on screen +6. Commit changes and contribute as normal by submitting a PR on GitHub. + +### Quick Tips +- **Auto-reload**: Works for most code changes when `DEBUG=True` +- **Config changes**: Always restart NetBox after changing plugin settings +- **GitHub CLI**: Automatically configured for easy PR submission +- **Logs**: Use `netbox-logs` to debug issues in real-time + + +### πŸ“‘ LibreNMS Server Configuration + +You need a LibreNMS instance to use this plugin. Configure your LibreNMS server(s) in `plugin-config.py`: + +1. Copy the example config: + + ```bash + cp .devcontainer/config/plugin-config.py.example .devcontainer/config/plugin-config.py + ``` + +2. Edit it with your LibreNMS server URL(s) and API token(s) +3. Restart NetBox: `netbox-restart` + +## Out-of-the-box defaults + +Below are the dev container defaults. The field name to change these defaults is listed below each line. + +- NetBox image: `netboxcommunity/netbox:${NETBOX_VERSION:-latest}` (default `latest`) + - .env: `NETBOX_VERSION` +- DB: PostgreSQL 15 (db: `netbox`, user: `netbox`, password: `netbox`) + - .env: `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` +- Redis: 7-alpine + - .env: `REDIS_HOST`, `REDIS_PASSWORD` +- NetBox DEBUG: `True` (dev only) + - .env: `DEBUG` +- Secret key: dev placeholder (not for production) + - .env: `SECRET_KEY` (optional). If unset, a dev-safe default is used inside the container. +- Superuser: `admin` / `admin` + - .env: `SUPERUSER_NAME`, `SUPERUSER_EMAIL`, `SUPERUSER_PASSWORD`, `SKIP_SUPERUSER` +- Plugin loader: enabled; reads `.devcontainer/config/plugin-config.py` if present +- If `plugin-config.py` is missing: plugin is enabled with empty config (features won’t work until configured) + +## πŸ”§ Configuration + +### NetBox Version and Environment (use .devcontainer/.env) + +The default NetBox docker image version is set to `latest`. + +To update the NetBox image version create `.devcontainer/.env` using `NETBOX_VERSION` Example: + +If you don’t have an env file yet, create it in the `.devcontainer/` folder from the example and customize: + +```bash +cp .devcontainer/.env.example .devcontainer/.env +``` + +Change the NetBox image version in the `.env` file: +```bash +# .devcontainer/.env +NETBOX_VERSION=v4.2-3.3.4 +``` + +After changing `.devcontainer/.env`, rebuild the dev container to apply it (Command Palette β†’ Dev Containers: Rebuild Container). + +See NetBox Docker tag docs for available tags: +https://hub.docker.com/r/netboxcommunity/netbox/#container-image-tags + +### πŸ”„ Changing NetBox Versions + +You might experience issues with database schemas and migrations when changing NetBox version. Since this is a development container, the simplest way to handle NetBox version changes is to reset the database completely. + +**To change NetBox versions:** + +1. **Update the version** in `.devcontainer/.env`: + ```bash + NETBOX_VERSION=v4.1-3.1.1 # or whatever version you need + ``` + +2. **Reset the development environment:** + ```bash + # Stop containers and remove volumes (removes all dev data) + docker compose down -v + + # Rebuild container with new NetBox version + # VS Code: Ctrl+Shift+P β†’ "Dev Containers: Rebuild Container" + ``` + +3. **Start fresh:** The container will automatically set up the new NetBox version with a clean database. + +**Note:** This removes all development data (test devices, configurations, etc.), but that's typically fine for development and testing scenarios. + +### Other environment variables: + +- Core: `NETBOX_VERSION`, `DEBUG`, `SECRET_KEY` +- Database: `DB_HOST`, `DB_NAME`, `DB_USER`, `DB_PASSWORD` +- Redis: `REDIS_HOST`, `REDIS_PASSWORD` +- Superuser: `SUPERUSER_NAME`, `SUPERUSER_EMAIL`, `SUPERUSER_PASSWORD`, `SKIP_SUPERUSER` +- Proxy: `HTTP_PROXY`, `HTTPS_PROXY`, `NO_PROXY`, `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, `CURL_CA_BUNDLE` + +### 🌐 Proxy Configuration (MITM Proxies) + +If you're behind a corporate proxy or MITM proxy (like Zscaler, BlueCoat, etc.), you need to configure the proxy at two levels: the Docker client (for building) and the container runtime (for package installation inside the container). + +**Step 1: Configure Docker client proxy** (`~/.docker/config.json`) + +This is **required** so that `apt-get`, `curl`, etc. work during the container image build (e.g., when installing devcontainer features like `git` and `github-cli`). + +Create or edit `~/.docker/config.json`: + +```json +{ + "proxies": { + "default": { + "httpProxy": "http://proxy.example.com:8080", + "httpsProxy": "http://proxy.example.com:8080", + "noProxy": "localhost,127.0.0.1,postgres,redis" + } + } +} +``` + +Docker automatically injects these as environment variables into every `RUN` instruction during `docker build`. No VS Code restart is needed β€” this takes effect immediately. + +> **Docker Desktop users:** You can configure the same settings via Docker Desktop Settings β†’ Resources β†’ Proxies, which writes this file for you. + +**Step 2: Create `.devcontainer/.env`** (for container runtime) + +```bash +cp .devcontainer/.env.example .devcontainer/.env +``` + +Add your proxy settings to `.devcontainer/.env`: + +```bash +# Proxy Configuration +HTTP_PROXY=http://proxy.example.com:8080 +HTTPS_PROXY=http://proxy.example.com:8080 +NO_PROXY=localhost,127.0.0.1,postgres,redis +``` + +> **Note:** You do **not** need to set `REQUESTS_CA_BUNDLE`, `SSL_CERT_FILE`, or `CURL_CA_BUNDLE` manually. When a `ca-bundle.crt` file is present in the workspace root, `setup.sh` automatically installs it into the system trust store and sets these variables to `/etc/ssl/certs/ca-certificates.crt`. + +**Step 3: Add your CA certificate** (optional, only if your proxy intercepts TLS): + - Export your proxy's CA certificate (usually available from your IT department or browser) + - Save it as `ca-bundle.crt` in the root of your workspace + - `setup.sh` will automatically install it and configure CA bundle environment variables + +**Step 4: Rebuild the container**: + - VS Code: Ctrl+Shift+P β†’ "Dev Containers: Rebuild Container" + +**What gets configured:** +- `~/.docker/config.json` β†’ proxy for Docker build steps (devcontainer features, apt in Dockerfile) +- `.devcontainer/.env` β†’ proxy for running containers (apt, pip, curl at runtime) +- `setup.sh` auto-configures apt proxy and git SSL settings inside the container + +**Important Notes:** +- The `.env` file is ignored by git, so your proxy credentials stay private +- `~/.docker/config.json` is a per-user file outside the repo +- Add internal service names to `NO_PROXY` to avoid routing internal Docker traffic through the proxy +- **Proxy authentication:** Embedding credentials directly in the proxy URL (e.g., `http://username:password@proxy.example.com:8080`) is insecure β€” credentials can be visible in process listings, environment dumps, `docker inspect` output, and logs. Prefer safer alternatives such as Docker's `config.json` with `credsStore` or a secret manager for storing proxy credentials securely. + +**Common Issues:** + +*"Could not connect to archive.ubuntu.com" during build* +- β†’ `~/.docker/config.json` is missing or has wrong proxy URL + +*"SSL certificate errors" during build* +- β†’ Your proxy uses a MITM certificate. Export it and add it to the system trust store, or set `SSL_CERT_FILE` in `.env` + +*Container builds but apt/pip fails inside* +- β†’ .env file is missing or has wrong proxy settings. Check .env matches Docker Desktop settings + + +After any `.env` change, rebuild the dev container to apply environment updates. + - VS Code: β€œDev Containers: Rebuild Container” (from the Command Palette) + + +## Other configurations + +### NetBox Configuration: +- Create `.devcontainer/config/extra-configuration.py` for additional NetBox settings (TIME_ZONE, banners, logging, etc) + - After changes: run `netbox-restart` (see [Commands](#-commands-aliases)) + +### Additional packages (including other netbox plugins) +- Create `.devcontainer/extra-requirements.txt` for extra Python packages. Example: `.devcontainer/extra-requirements.txt.example`. + - After changes: run `plugins-install` to install packages, then `netbox-restart` (see [Commands](#-commands-aliases)) + +## πŸ”§ Git Setup + +The dev container includes Git and GitHub CLI pre-installed. You'll need to configure authentication for commits and pushes. + +### Important: SSH vs HTTPS Remote URLs + +**Common Issue**: If you cloned this repository using SSH (`git@github.com:...`), you may encounter authentication errors when pushing changes. This is because: +- Dev containers don't have SSH keys by default +- GitHub CLI authentication uses HTTPS protocol + +**Solution**: The setup script automatically converts SSH remote URLs to HTTPS. If you encounter issues, manually fix with: +```bash +# Check current remote URL +git remote -v + +# If it shows git@github.com:..., convert to HTTPS +git remote set-url origin https://github.com/bonzo81/netbox-librenms-plugin.git +``` + +### Recommended: GitHub CLI (Easiest) +```bash +# Authenticate with GitHub (handles Git credentials automatically) +gh auth login + +# Verify authentication +gh auth status +``` + +The GitHub CLI automatically configures Git to use your GitHub credentials for this repository. + +### GitHub Codespaces +In Codespaces, GitHub authentication is often pre-configured, but you can verify with: +```bash +# Check current status +gh auth status + +# If needed, authenticate +gh auth login +``` + +### Manual Git Setup (Alternative) +If you prefer manual setup or need non-GitHub authentication: + +#### Local Dev Container +```bash +# Set your Git identity +git config --global user.name "Your Name" +git config --global user.email "your.email@example.com" + +# Optional: Set default branch name +git config --global init.defaultBranch main +``` + +#### SSH Key Setup (for private repositories) +```bash +# Generate SSH key (if you don't have one) +ssh-keygen -t ed25519 -C "your.email@example.com" + +# Add to SSH agent +eval "$(ssh-agent -s)" +ssh-add ~/.ssh/id_ed25519 + +# Display public key to add to GitHub +cat ~/.ssh/id_ed25519.pub +``` + +> **πŸ’‘ Authentication Persistence:** +> - **GitHub CLI**: Authentication persists across container rebuilds (stored in persistent volume) +> - **Manual Git Config**: Git identity settings are **NOT persistent** across rebuilds +> - **GitHub Codespaces**: Authentication is automatically handled by the Codespaces platform +> +> **Recommendation**: Use `gh auth login` for the best experience - it's persistent and handles everything automatically. + +## πŸ“‹ Commands (aliases) + +- `netbox-run-bg` - start NetBox and RQ worker in background +- `netbox-run` - start NetBox and RQ worker in foreground (with Django logs showing) +- `netbox-stop` - stop both NetBox and RQ worker +- `netbox-restart` - restart NetBox and RQ worker +- `netbox-reload` - reinstall plugin and restart +- `netbox-status` - show server and RQ worker status +- `netbox-logs` - tail NetBox server logs +- `rq-status` - check RQ worker status +- `rq-logs` - tail RQ worker logs +- `netbox-shell` - Django shell +- `netbox-manage` - Django manage.py +- `netbox-test` - run tests +- `plugin-install` - reinstall plugin +- `ruff-check|format|fix` - Ruff helpers + +## πŸ› Troubleshooting + +### Git Authentication Issues + +**Problem**: `Permission denied (publickey)` or authentication errors when pushing +``` +git@github.com: Permission denied (publickey). +fatal: Could not read from remote repository. +``` + +**Solutions**: +1. **Check remote URL** - should use HTTPS, not SSH: + ```bash + git remote -v + # Should show: https://github.com/bonzo81/netbox-librenms-plugin.git + # NOT: git@github.com:bonzo81/netbox-librenms-plugin.git + ``` + +2. **Fix SSH remote URL**: + ```bash + git remote set-url origin https://github.com/bonzo81/netbox-librenms-plugin.git + ``` + +3. **Authenticate with GitHub CLI**: + ```bash + gh auth login + gh auth setup-git # Optional: explicitly setup Git integration + ``` + +### Other Issues + +- Rebuild container if setup fails (Ctrl+Shift+P β†’ Rebuild Container) +- Check logs `docker-compose logs postgres redis devcontainer` +- Ensure plugin is importable inside container: `python -c "import netbox_librenms_plugin"` +- Run `diagnose` to see whether `plugin-config.py` was detected and the NetBox config path + +## 🧹 Cleanup: remove the dev containers + +Data warning: removing volumes deletes all dev data (PostgreSQL DB, Redis AOF, NetBox media/static). + +1) Close the VS Code Dev Container session first (Command Palette β†’ Dev Containers: Close Remote) +2) From the repo root: + +```bash +# Stop and remove containers +docker compose -f .devcontainer/docker-compose.yml down + +# Also remove named volumes (DB/media/static) β€” irreversible +docker compose -f .devcontainer/docker-compose.yml down -v + +# Optional: reclaim image space built/pulled for this project +docker compose -f .devcontainer/docker-compose.yml down --rmi local -v +``` + +Alternatively, run from inside the .devcontainer folder without -f: + +```bash +cd .devcontainer +docker compose down # containers only +docker compose down -v # containers + volumes +``` diff --git a/.devcontainer/config/codespaces-configuration.py b/.devcontainer/config/codespaces-configuration.py new file mode 100644 index 0000000..84c1b66 --- /dev/null +++ b/.devcontainer/config/codespaces-configuration.py @@ -0,0 +1,30 @@ +# GitHub Codespaces NetBox Configuration +# CSRF/hosts setup for Codespaces URLs + +import os + +codespace_name = os.environ.get("CODESPACE_NAME") +port_domain = os.environ.get("GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN", "app.github.dev") + +if codespace_name: + codespaces_url = f"https://{codespace_name}-8000.{port_domain}" + CSRF_TRUSTED_ORIGINS = [ + codespaces_url, + "http://localhost:8000", + "http://127.0.0.1:8000", + ] + ALLOWED_HOSTS = [ + f"{codespace_name}-8000.{port_domain}", + "localhost", + "127.0.0.1", + "*", + ] + print(f"πŸ”— Codespaces detected: {codespace_name}") + print(f"πŸ”’ CSRF Trusted Origins: {CSRF_TRUSTED_ORIGINS}") + print(f"🌐 Allowed Hosts: {ALLOWED_HOSTS}") +else: + CSRF_TRUSTED_ORIGINS = [ + "http://localhost:8000", + "http://127.0.0.1:8000", + ] + ALLOWED_HOSTS = ["*"] diff --git a/.devcontainer/config/extra-configuration.py.example b/.devcontainer/config/extra-configuration.py.example new file mode 100644 index 0000000..4b9729e --- /dev/null +++ b/.devcontainer/config/extra-configuration.py.example @@ -0,0 +1,49 @@ +# Example: Extra NetBox Configuration +# Copy to 'extra-configuration.py' and customize + +import os + +# Example: Custom logging configuration +# LOGGING = { +# 'version': 1, +# 'disable_existing_loggers': False, +# 'handlers': { +# 'file': { +# 'level': 'INFO', +# 'class': 'logging.FileHandler', +# 'filename': '/opt/netbox/logs/netbox.log', +# }, +# }, +# 'loggers': { +# 'netbox_librenms_plugin': { +# 'handlers': ['file'], +# 'level': 'DEBUG', +# 'propagate': True, +# }, +# }, +# } + +# Example: Time zone +# TIME_ZONE = os.environ.get('TIME_ZONE', 'UTC') + +# Example: Auth backends +# AUTHENTICATION_BACKENDS = [ +# 'django.contrib.auth.backends.RemoteUserBackend', +# 'django.contrib.auth.backends.ModelBackend', +# ] + +# Example: Paths +# MEDIA_ROOT = '/opt/netbox/netbox/media' +# STATIC_ROOT = '/opt/netbox/netbox/static' + +# Dev banners +BANNER_TOP = "Development Environment" +BANNER_BOTTOM = "NetBox LibreNMS Plugin Dev Container" +BANNER_LOGIN = "NetBox LibreNMS Plugin Development access only" + +# Plugin-specific configuration +# PLUGINS_CONFIG = { +# 'netbox_librenms_plugin': { +# 'debug_logging': True, +# }, +# } diff --git a/.devcontainer/config/extra-plugins.py.example b/.devcontainer/config/extra-plugins.py.example new file mode 100644 index 0000000..e69de29 diff --git a/.devcontainer/config/plugin-config.py.example b/.devcontainer/config/plugin-config.py.example new file mode 100644 index 0000000..5e48833 --- /dev/null +++ b/.devcontainer/config/plugin-config.py.example @@ -0,0 +1,46 @@ +""" +Default plugin configuration for the NetBox LibreNMS Plugin in the dev container. + +- This file is an example of Plugin configuration +- Copy this file to .devcontainer/plugin-config.py +- Edit values as needed. + +- Add config for all other plugins here if any. +""" + +# Ensure our plugin is enabled in dev (the loader sets this as a default too) +PLUGINS = [ + "netbox_librenms_plugin", +] + +# Sample configuration with example servers +PLUGINS_CONFIG = { + "netbox_librenms_plugin": { + "servers": { + "production": { + "display_name": "Production LibreNMS", + "librenms_url": "https://librenms-prod.example.com", + "api_token": "your-prod-token", + "cache_timeout": 300, + "verify_ssl": True, + "interface_name_field": "ifDescr", + }, + "testing": { + "display_name": "Test LibreNMS", + "librenms_url": "https://librenms-test.example.com", + "api_token": "your_test_token", + "cache_timeout": 300, + "verify_ssl": False, + "interface_name_field": "ifName", + }, + "development": { + "display_name": "Dev LibreNMS", + "librenms_url": "https://librenms-dev.example.com", + "api_token": "your_dev_token", + "cache_timeout": 180, + "verify_ssl": False, + "interface_name_field": "ifDescr", + }, + } + } +} diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json new file mode 100644 index 0000000..4f04987 --- /dev/null +++ b/.devcontainer/devcontainer.json @@ -0,0 +1,82 @@ +{ + "name": "NetBox LibreNMS Plugin Dev", + "dockerComposeFile": "docker-compose.yml", + "service": "devcontainer", + "workspaceFolder": "/workspaces/${localWorkspaceFolderBasename}", + "forwardPorts": [ + 8000 + ], + "portsAttributes": { + "8000": { + "label": "NetBox Web Interface", + "protocol": "http", + "requireLocalPort": false, + "elevateIfNeeded": false + } + }, + "otherPortsAttributes": { + "onAutoForward": "ignore" + }, + "hostRequirements": { + "memory": "4gb", + "storage": "32gb" + }, + "containerEnv": { + "NETBOX_VERSION": "${localEnv:NETBOX_VERSION:latest}", + "DEBUG": "${localEnv:DEBUG:True}", + "DEVELOPER": "${localEnv:DEVELOPER:True}", + "DB_HOST": "${localEnv:DB_HOST:postgres}", + "DB_NAME": "${localEnv:DB_NAME:netbox}", + "DB_USER": "${localEnv:DB_USER:netbox}", + "DB_PASSWORD": "${localEnv:DB_PASSWORD:netbox}", + "REDIS_HOST": "${localEnv:REDIS_HOST:redis}", + "REDIS_PASSWORD": "${localEnv:REDIS_PASSWORD:}", + "SUPERUSER_NAME": "${localEnv:SUPERUSER_NAME:admin}", + "SUPERUSER_EMAIL": "${localEnv:SUPERUSER_EMAIL:admin@example.com}", + "SUPERUSER_PASSWORD": "${localEnv:SUPERUSER_PASSWORD:admin}", + "SKIP_SUPERUSER": "${localEnv:SKIP_SUPERUSER:false}", + "HTTP_PROXY": "${localEnv:HTTP_PROXY}", + "HTTPS_PROXY": "${localEnv:HTTPS_PROXY}", + "http_proxy": "${localEnv:HTTP_PROXY}", + "https_proxy": "${localEnv:HTTPS_PROXY}", + "NO_PROXY": "${localEnv:NO_PROXY}", + "no_proxy": "${localEnv:NO_PROXY}", + "REQUESTS_CA_BUNDLE": "${localEnv:REQUESTS_CA_BUNDLE}", + "SSL_CERT_FILE": "${localEnv:SSL_CERT_FILE}", + "CURL_CA_BUNDLE": "${localEnv:CURL_CA_BUNDLE}", + "ALLOW_GIT_SSL_DISABLE": "${localEnv:ALLOW_GIT_SSL_DISABLE:false}" + }, + "features": {}, + "customizations": { + "vscode": { + "extensions": [ + "ms-python.python", + "charliermarsh.ruff", + "ms-vscode.vscode-json", + "ms-vscode-remote.remote-containers" + ], + "settings": { + "python.defaultInterpreterPath": "/opt/netbox/venv/bin/python", + "python.linting.enabled": false, + "python.formatting.provider": "none", + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.codeActionsOnSave": { + "source.organizeImports": "explicit", + "source.fixAll.ruff": "explicit" + } + }, + "ruff.organizeImports": true, + "ruff.fixAll": true, + "editor.formatOnSave": true, + "files.trimTrailingWhitespace": true, + "editor.rulers": [ + 88 + ] + } + } + }, + "postCreateCommand": "bash .devcontainer/scripts/setup.sh", + "postAttachCommand": "bash .devcontainer/scripts/welcome.sh", + "remoteUser": "root" +} diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml new file mode 100644 index 0000000..4af2d07 --- /dev/null +++ b/.devcontainer/docker-compose.yml @@ -0,0 +1,74 @@ +services: + devcontainer: + image: netboxcommunity/netbox:${NETBOX_VERSION:-latest} + volumes: + - ../..:/workspaces:cached + - netbox_media:/opt/netbox/netbox/media + - netbox_static:/opt/netbox/netbox/static + - gh_config:/root/.config/gh + command: sleep infinity + environment: + NETBOX_VERSION: ${NETBOX_VERSION:-latest} + DEBUG: ${DEBUG:-True} + DEVELOPER: ${DEVELOPER:-True} + DB_HOST: ${DB_HOST:-postgres} + DB_NAME: ${DB_NAME:-netbox} + DB_USER: ${DB_USER:-netbox} + DB_PASSWORD: ${DB_PASSWORD:-netbox} + REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_PASSWORD: ${REDIS_PASSWORD:-} + SECRET_KEY: ${SECRET_KEY:-dummydummydummydummydummydummydummydummydummydummydummydummy} + SUPERUSER_NAME: ${SUPERUSER_NAME:-admin} + SUPERUSER_EMAIL: ${SUPERUSER_EMAIL:-admin@example.com} + SUPERUSER_PASSWORD: ${SUPERUSER_PASSWORD:-admin} + SKIP_SUPERUSER: ${SKIP_SUPERUSER:-false} + # Proxy settings (optional) + HTTP_PROXY: ${HTTP_PROXY:-} + HTTPS_PROXY: ${HTTPS_PROXY:-} + http_proxy: ${HTTP_PROXY:-} + https_proxy: ${HTTPS_PROXY:-} + NO_PROXY: ${NO_PROXY:-} + no_proxy: ${NO_PROXY:-} + REQUESTS_CA_BUNDLE: ${REQUESTS_CA_BUNDLE:-} + SSL_CERT_FILE: ${SSL_CERT_FILE:-} + CURL_CA_BUNDLE: ${CURL_CA_BUNDLE:-} + ALLOW_GIT_SSL_DISABLE: ${ALLOW_GIT_SSL_DISABLE:-false} + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + ports: + - "8000:8000" + + postgres: + image: postgres:15 + environment: + POSTGRES_DB: ${DB_NAME:-netbox} + POSTGRES_USER: ${DB_USER:-netbox} + POSTGRES_PASSWORD: ${DB_PASSWORD:-netbox} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-netbox}"] + interval: 30s + timeout: 10s + retries: 5 + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 30s + timeout: 10s + retries: 5 + +volumes: + postgres_data: + redis_data: + netbox_media: + netbox_static: + gh_config: diff --git a/.devcontainer/extra-requirements.txt.example b/.devcontainer/extra-requirements.txt.example new file mode 100644 index 0000000..0ec23b1 --- /dev/null +++ b/.devcontainer/extra-requirements.txt.example @@ -0,0 +1,27 @@ +# Example: Extra Python Requirements +# Copy this file to 'extra-requirements.txt' and customize as needed + +# Example NetBox plugins (uncomment to install) +# netbox-secrets>=1.9.0 +# netbox-topology-views>=3.8.0 +# netbox-dns>=1.1.0 +# netbox-documents>=0.6.0 + +# Example development/debugging tools +# ipython>=8.0.0 +# django-debug-toolbar>=4.0.0 +# django-extensions>=3.2.0 + +# Example testing tools +# factory-boy>=3.3.0 +# faker>=22.0.0 +# responses>=0.24.0 + +# Example monitoring +# django-prometheus>=2.3.0 +# sentry-sdk>=1.40.0 + +# Example additional network libs +# netaddr>=0.10.0 +# dnspython>=2.4.0 +# pysnmp>=5.0.0 diff --git a/.devcontainer/scripts/diagnose.sh b/.devcontainer/scripts/diagnose.sh new file mode 100755 index 0000000..133e6ca --- /dev/null +++ b/.devcontainer/scripts/diagnose.sh @@ -0,0 +1,65 @@ +#!/bin/bash + +echo "πŸ” DevContainer Startup Diagnostics" +echo "==================================" + +PLUGIN_WS_DIR="${PLUGIN_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" +echo "πŸ“ Current working directory: $(pwd)" +echo "πŸ‘€ Current user: $(whoami)" +echo "πŸ†” User ID: $(id)" + +echo "" +echo "🐳 Container Environment:" +echo " - NETBOX_VERSION: ${NETBOX_VERSION:-not set}" +echo " - DEBUG: ${DEBUG:-not set}" +echo " - SECRET_KEY: ${SECRET_KEY:0:20}... (truncated)" +echo " - DB_HOST: ${DB_HOST:-not set}" +echo " - DB_NAME: ${DB_NAME:-not set}" +echo " - DB_USER: ${DB_USER:-not set}" +echo " - REDIS_HOST: ${REDIS_HOST:-not set}" +echo " - SUPERUSER_NAME: ${SUPERUSER_NAME:-not set}" + +echo "" +echo "πŸ”— Service Connectivity:" +echo " - PostgreSQL: $(timeout 3 bash -c 'cat < /dev/null > /dev/tcp/postgres/5432' 2>/dev/null && echo 'Connected' || echo 'Not reachable')" +echo " - Redis: $(timeout 3 bash -c 'cat < /dev/null > /dev/tcp/redis/6379' 2>/dev/null && echo 'Connected' || echo 'Not reachable')" + +echo "" +echo "πŸ—‚οΈ File System:" +echo " - NetBox venv: $(test -f /opt/netbox/venv/bin/activate && echo 'Exists' || echo 'Missing')" +echo " - Plugin directory: $(test -d "$PLUGIN_WS_DIR" && echo 'Exists' || echo 'Missing')" +echo " - Setup script: $(test -f "$PLUGIN_WS_DIR/.devcontainer/scripts/setup.sh" && echo 'Exists' || echo 'Missing')" +echo " - Start script: $(test -f "$PLUGIN_WS_DIR/.devcontainer/scripts/start-netbox.sh" && echo 'Exists' || echo 'Missing')" +echo " - Start script executable: $(test -x "$PLUGIN_WS_DIR/.devcontainer/scripts/start-netbox.sh" && echo 'Yes' || echo 'No')" +echo " - Plugin config: $(test -f "$PLUGIN_WS_DIR/.devcontainer/config/plugin-config.py" && echo 'Found' || echo 'Missing (using defaults)')" +echo " - NetBox config path: /opt/netbox/netbox/netbox/configuration.py" + +echo "" +echo "πŸš€ Process Status:" +if [ -f /tmp/netbox.pid ]; then + PID=$(cat /tmp/netbox.pid) + if [ -z "$PID" ]; then + echo " - NetBox server: PID file exists but is empty" + elif kill -0 "$PID" 2>/dev/null; then + echo " - NetBox server: Running (PID: $PID)" + else + echo " - NetBox server: PID file exists but process not running" + echo " (PID $PID is dead - NetBox may have crashed)" + fi +else + echo " - NetBox server: Not started" +fi + +# Check port listening +echo "" +echo "🌍 Port Check:" +if command -v netstat >/dev/null 2>&1; then + echo " - Port 8000: $(netstat -tuln 2>/dev/null | grep :8000 >/dev/null && echo 'Listening' || echo 'Not listening')" +elif command -v ss >/dev/null 2>&1; then + echo " - Port 8000: $(ss -tuln 2>/dev/null | grep :8000 >/dev/null && echo 'Listening' || echo 'Not listening')" +else + echo " - Port 8000: $(cat /proc/net/tcp 2>/dev/null | awk '$2 ~ /:1F40$/ {print "Listening"; exit}' | grep -q "Listening" && echo 'Listening' || echo 'Not listening')" +fi + +echo "" +echo "βœ… Diagnostic complete!" diff --git a/.devcontainer/scripts/load-aliases.sh b/.devcontainer/scripts/load-aliases.sh new file mode 100755 index 0000000..65149d6 --- /dev/null +++ b/.devcontainer/scripts/load-aliases.sh @@ -0,0 +1,224 @@ +#!/bin/bash +# Quick alias loader for current session +# Usage: source .devcontainer/scripts/load-aliases.sh + +export PATH="/opt/netbox/venv/bin:$PATH" +export DEBUG="${DEBUG:-True}" +PLUGIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +# Clean up empty CA bundle vars (Compose/devcontainer inject "" when host var is +# unset, which breaks requests/curl). When setup.sh has installed custom CAs +# into the system trust store, point to it instead. +for _ca_var in REQUESTS_CA_BUNDLE SSL_CERT_FILE CURL_CA_BUNDLE; do + _val="${!_ca_var}" + if [ -z "$_val" ]; then + if [ -f /etc/ssl/certs/ca-certificates.crt ]; then + declare -x "$_ca_var=/etc/ssl/certs/ca-certificates.crt" + else + unset "$_ca_var" + fi + fi +done +unset _ca_var _val + +# Load shared process management helpers +if ! source "$PLUGIN_DIR/.devcontainer/scripts/process-helpers.sh"; then + printf '%s\n' "Failed to load process-helpers.sh" >&2 + return 1 +fi + +netbox-run-bg() { "$PLUGIN_DIR/.devcontainer/scripts/start-netbox.sh" --background; } +netbox-run() { "$PLUGIN_DIR/.devcontainer/scripts/start-netbox.sh"; } + +# Robust stop command that kills both tracked and orphaned processes +netbox-stop() { + echo "πŸ›‘ Stopping NetBox and RQ workers..." + if [ -f /tmp/netbox.pid ]; then + local PID + PID=$(cat /tmp/netbox.pid 2>/dev/null) + if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + if is_expected_pid "$PID" "python.*runserver.*8000"; then + graceful_kill_pid "$PID" + echo " Stopped NetBox (PID: $PID)" + else + echo " Skipping stale /tmp/netbox.pid (PID $PID is not NetBox runserver)" + fi + fi + rm -f /tmp/netbox.pid + fi + if [ -f /tmp/rqworker.pid ]; then + local PID + PID=$(cat /tmp/rqworker.pid 2>/dev/null) + if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + if is_expected_pid "$PID" "python.*rqworker"; then + graceful_kill_pid "$PID" + echo " Stopped RQ worker (PID: $PID)" + else + echo " Skipping stale /tmp/rqworker.pid (PID $PID is not rqworker)" + fi + fi + rm -f /tmp/rqworker.pid + fi + if pgrep -f "python.*rqworker" >/dev/null 2>&1; then + local ORPHAN_COUNT + ORPHAN_COUNT=$(pgrep -cf "python.*rqworker" 2>/dev/null || echo 0) + graceful_kill_pattern "python.*rqworker" + echo " Killed $ORPHAN_COUNT orphaned RQ worker(s)" + fi + if pgrep -f "python.*runserver.*8000" >/dev/null 2>&1; then + graceful_kill_pattern "python.*runserver.*8000" + echo " Killed orphaned NetBox server(s)" + fi + echo "βœ… All processes stopped" +} + +netbox-restart() { + netbox-stop && sleep 1 && netbox-run-bg +} + +netbox-reload() { + cd "$PLUGIN_DIR" || return 1 + if command -v uv >/dev/null 2>&1; then + uv pip install -e . || return 1 + else + pip install -e . || return 1 + fi + netbox-restart +} + +alias netbox-logs="tail -f /tmp/netbox.log" +alias rq-logs="tail -f /tmp/rqworker.log" + +netbox-status() { + local PID + if [ -f /tmp/netbox.pid ]; then + PID=$(cat /tmp/netbox.pid 2>/dev/null) + if [ -n "$PID" ] && is_expected_pid "$PID" "python.*runserver.*8000"; then + echo "NetBox is running (PID: $PID)" + else + echo "NetBox is not running" + fi + else + echo "NetBox is not running" + fi + if [ -f /tmp/rqworker.pid ]; then + PID=$(cat /tmp/rqworker.pid 2>/dev/null) + if [ -n "$PID" ] && is_expected_pid "$PID" "python.*rqworker"; then + echo "RQ worker is running (PID: $PID)" + else + echo "RQ worker is not running" + fi + else + echo "RQ worker is not running" + fi +} + +rq-status() { + local PID + if [ -f /tmp/rqworker.pid ]; then + PID=$(cat /tmp/rqworker.pid 2>/dev/null) + if [ -n "$PID" ] && is_expected_pid "$PID" "python.*rqworker"; then + echo "RQ worker is running (PID: $PID)" + else + echo "RQ worker is not running" + fi + else + echo "RQ worker is not running" + fi +} + +netbox-shell() { + cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py shell +} + +netbox-test() { + cd "$PLUGIN_DIR" && source /opt/netbox/venv/bin/activate && python -m pytest "$@" +} + +netbox-manage() { + cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py "$@" +} + +plugin-install() { + cd "$PLUGIN_DIR" || return 1 + if command -v uv >/dev/null 2>&1; then + uv pip install -e . + else + pip install -e . + fi +} + +plugins-install() { + if [ -f "$PLUGIN_DIR/.devcontainer/extra-requirements.txt" ]; then + source /opt/netbox/venv/bin/activate && pip install -r "$PLUGIN_DIR/.devcontainer/extra-requirements.txt" + else + echo "No .devcontainer/extra-requirements.txt found" + fi +} + +ruff-check() { cd "$PLUGIN_DIR" && command ruff check .; } +ruff-format() { cd "$PLUGIN_DIR" && command ruff format .; } +ruff-fix() { cd "$PLUGIN_DIR" && command ruff check --fix .; } + +diagnose() { "$PLUGIN_DIR/.devcontainer/scripts/diagnose.sh"; } + +# RQ job inspection commands +rq-stats() { + cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py rqstats +} + +rq-jobs() { + cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py shell -c \ + "from django_rq import get_queue; q = get_queue('default'); print(f'Jobs in queue: {len(q)}'); [print(f' {job.id[:8]}: {job.func_name} - {job.get_status()}') for job in q.jobs[:10]]" +} + +rq-failed() { + cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py shell -c \ + "from django_rq import get_failed_queue; q = get_failed_queue(); print(f'Failed jobs: {len(q)}'); [print(f' {job.id[:8]}: {job.func_name}') for job in q.jobs[:10]]" +} + +rq-recent() { + cd /opt/netbox/netbox && source /opt/netbox/venv/bin/activate && python manage.py shell -c \ + "from core.models import Job; jobs = Job.objects.all().order_by('-created')[:10]; [print(f'{j.id}: {j.name[:50]} - {getattr(j.status, \"value\", j.status)} ({j.user})') for j in jobs]" +} + +# Help +dev-help() { + echo "🎯 NetBox LibreNMS Plugin Development Commands:" + echo "" + echo "πŸ“Š NetBox Server Management:" + echo " netbox-run-bg : Start NetBox in background" + echo " netbox-run : Start NetBox in foreground (for debugging)" + echo " netbox-stop : Stop NetBox and RQ worker" + echo " netbox-restart : Restart NetBox and RQ worker" + echo " netbox-reload : Reinstall plugin and restart NetBox" + echo " netbox-status : Check if NetBox and RQ worker are running" + echo " netbox-logs : View NetBox server logs" + echo "" + echo "βš™οΈ Background Jobs (RQ Worker):" + echo " rq-status : Check if RQ worker is running" + echo " rq-logs : View RQ worker logs" + echo " rq-stats : Show RQ queue statistics" + echo " rq-jobs : List jobs in default queue" + echo " rq-failed : List failed jobs" + echo " rq-recent : Show recent NetBox jobs" + echo "" + echo "πŸ› οΈ Development Tools:" + echo " netbox-shell : Open NetBox Django shell" + echo " netbox-test : Run plugin tests" + echo " netbox-manage : Run Django management commands" + echo " plugin-install : Reinstall plugin in development mode" + echo "" + echo "🧹 Code Quality:" + echo " ruff-check : Check code with Ruff" + echo " ruff-format : Format code with Ruff" + echo " ruff-fix : Auto-fix code issues with Ruff" + echo "" + echo "πŸ”Ž Diagnostics:" + echo " diagnose : Run startup diagnostics" + echo " dev-help : Show this help message" + echo "" + echo "πŸ“– NetBox available at: http://localhost:8000 (admin/admin)" +} + +echo "βœ… Dev helpers loaded! Try: rq-status, rq-stats, rq-recent, dev-help" diff --git a/.devcontainer/scripts/process-helpers.sh b/.devcontainer/scripts/process-helpers.sh new file mode 100755 index 0000000..065b3d7 --- /dev/null +++ b/.devcontainer/scripts/process-helpers.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# Shared process management helpers. +# Sourced by load-aliases.sh and start-netbox.sh. + +# Graceful termination: SIGTERM, wait, then SIGKILL if still alive. +graceful_kill_pid() { + local pid="$1" + kill -15 "$pid" 2>/dev/null || true + sleep 2 + kill -0 "$pid" 2>/dev/null && kill -9 "$pid" 2>/dev/null || true +} + +graceful_kill_pattern() { + local pattern="$1" + pkill -15 -f "$pattern" 2>/dev/null || true + sleep 2 + pgrep -f "$pattern" >/dev/null 2>&1 && pkill -9 -f "$pattern" 2>/dev/null || true +} + +# Verify a PID matches the expected process before killing it +is_expected_pid() { + local pid="$1" pattern="$2" + ps -p "$pid" -o args= 2>/dev/null | grep -Eq "$pattern" +} diff --git a/.devcontainer/scripts/setup.sh b/.devcontainer/scripts/setup.sh new file mode 100755 index 0000000..588fe65 --- /dev/null +++ b/.devcontainer/scripts/setup.sh @@ -0,0 +1,329 @@ +#!/bin/bash +set -e + +echo "πŸš€ Setting up NetBox LibreNMS Plugin development environment..." +echo "πŸ“ Current working directory: $(pwd)" +echo "πŸ‘€ Current user: $(whoami)" +NETBOX_VERSION=${NETBOX_VERSION:-"latest"} +echo "πŸ“¦ Using NetBox Docker image: netboxcommunity/netbox:${NETBOX_VERSION}" + +# --------------------------------------------------------------------------- +# Detect plugin workspace directory (must contain pyproject.toml). +# Prints the resolved path to stdout on success, or an empty string on +# failure. Always exits 0 β€” callers must check for an empty result. +# --------------------------------------------------------------------------- +detect_plugin_workspace() { + if [ -f "$PWD/pyproject.toml" ]; then + echo "$PWD" + elif [ -d "/workspaces/netbox-librenms-plugin" ] && [ -f "/workspaces/netbox-librenms-plugin/pyproject.toml" ]; then + echo "/workspaces/netbox-librenms-plugin" + else + local candidate + candidate=$(find /workspaces -maxdepth 2 -type f -name pyproject.toml 2>/dev/null | head -n1 | xargs -r dirname || true) + if [ -n "$candidate" ] && [ -f "$candidate/pyproject.toml" ]; then + echo "$candidate" + else + echo "" + fi + fi +} + +# Clean up empty CA bundle vars (Compose injects "" when host var is unset) +for _ca_var in REQUESTS_CA_BUNDLE SSL_CERT_FILE CURL_CA_BUNDLE; do + _val="${!_ca_var}" + [ -z "$_val" ] && unset "$_ca_var" +done +unset _ca_var _val + +# Configure proxy for apt and pip if proxy environment variables are set +if [ -n "$HTTP_PROXY" ] || [ -n "$HTTPS_PROXY" ]; then + echo "🌐 Configuring proxy settings..." + + # Configure apt proxy + if [ -n "$HTTP_PROXY" ]; then + echo "Acquire::http::Proxy \"$HTTP_PROXY\";" > /etc/apt/apt.conf.d/80proxy + SAFE_HTTP_PROXY=$(echo "$HTTP_PROXY" | sed 's|://[^@]*@|://***:***@|') + echo " βœ“ apt HTTP proxy: $SAFE_HTTP_PROXY" + fi + if [ -n "$HTTPS_PROXY" ]; then + echo "Acquire::https::Proxy \"$HTTPS_PROXY\";" >> /etc/apt/apt.conf.d/80proxy + SAFE_HTTPS_PROXY=$(echo "$HTTPS_PROXY" | sed 's|://[^@]*@|://***:***@|') + echo " βœ“ apt HTTPS proxy: $SAFE_HTTPS_PROXY" + fi + + # Configure pip proxy via environment (already set, but ensure it's exported) + export HTTP_PROXY HTTPS_PROXY http_proxy https_proxy NO_PROXY no_proxy + + # Install custom CA certificate into the system trust store (for MITM proxies) + PLUGIN_WS_DIR_EARLY="$(detect_plugin_workspace)" + [ -z "$PLUGIN_WS_DIR_EARLY" ] && PLUGIN_WS_DIR_EARLY="/workspaces/netbox-librenms-plugin" + CA_BUNDLE_SRC="$PLUGIN_WS_DIR_EARLY/ca-bundle.crt" + if [ -f "$CA_BUNDLE_SRC" ]; then + echo "πŸ” Installing custom CA certificate into system trust store..." + cert_count=$(grep -c '-----BEGIN CERTIFICATE-----' "$CA_BUNDLE_SRC" 2>/dev/null || true) + if [ "${cert_count:-0}" -eq 0 ]; then + echo " ⚠️ ca-bundle.crt does not contain any PEM certificate blocks; skipping CA install." + else + mkdir -p /usr/local/share/ca-certificates/proxy + # Remove stale split fragments so they don't accumulate across rebuilds + find /usr/local/share/ca-certificates/proxy -maxdepth 1 -name 'cert-*' -delete 2>/dev/null || true + # Split the bundle into individual certs β€” update-ca-certificates needs one + # cert per file and skips non-CA leaf certs, so extract each PEM block as + # a separate .crt file. + csplit -z -f /usr/local/share/ca-certificates/proxy/cert- \ + "$CA_BUNDLE_SRC" '/-----BEGIN CERTIFICATE-----/' '{*}' \ + >/dev/null 2>&1 + CSPLIT_STATUS=$? + if [ "$CSPLIT_STATUS" -ne 0 ]; then + echo " ⚠️ Failed to split ca-bundle.crt (csplit exit code: $CSPLIT_STATUS). Skipping CA install." + elif compgen -G "/usr/local/share/ca-certificates/proxy/cert-*" > /dev/null; then + # Rename split fragments to .crt + for f in /usr/local/share/ca-certificates/proxy/cert-*; do + mv "$f" "${f}.crt" 2>/dev/null || true + done + update-ca-certificates 2>/dev/null + echo " βœ“ CA certificate installed into system trust store ($cert_count cert(s))" + else + echo " ⚠️ No certificate fragments were generated from ca-bundle.crt; skipping CA install." + fi + fi + # Point environment variables to the system bundle + export REQUESTS_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + export SSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crt + export CURL_CA_BUNDLE=/etc/ssl/certs/ca-certificates.crt + export GIT_SSL_CAINFO=/etc/ssl/certs/ca-certificates.crt + # Configure pip globally so isolated virtualenvs (e.g. pre-commit) also + # use the system CA bundle instead of their bundled certifi. + pip config set global.cert /etc/ssl/certs/ca-certificates.crt 2>/dev/null || true + else + echo " ℹ️ No ca-bundle.crt found at $CA_BUNDLE_SRC, skipping CA install" + # Only disable git SSL verification if explicitly opted-in via ALLOW_GIT_SSL_DISABLE. + # Silently disabling SSL is a security risk; prefer providing a CA bundle instead. + if [ "${ALLOW_GIT_SSL_DISABLE:-false}" = "true" ]; then + git config --global http.sslVerify false + echo " ⚠️ git SSL verification disabled globally (ALLOW_GIT_SSL_DISABLE=true)" + else + echo " ⚠️ No CA bundle found and git SSL verification was NOT disabled." + echo " If you need to disable it, set ALLOW_GIT_SSL_DISABLE=true in .devcontainer/.env" + echo " Preferred: provide a ca-bundle.crt in the workspace root instead." + fi + fi +fi + +# Verify NetBox virtual environment exists +if [ ! -f "/opt/netbox/venv/bin/activate" ]; then + echo "❌ NetBox virtual environment not found at /opt/netbox/venv/" + echo "This might indicate an issue with the NetBox Docker image." + exit 1 +fi + +echo "🐍 Activating NetBox virtual environment..." +source /opt/netbox/venv/bin/activate + +# Choose installer (uv if available, else pip) +if command -v uv >/dev/null 2>&1; then + PIP_CMD="uv pip" +else + PIP_CMD="pip" +fi + +# Install dev tools +echo "πŸ”§ Installing development dependencies..." +apt-get update -qq +apt-get install -y -qq net-tools git +$PIP_CMD install pytest pytest-django ruff pre-commit + +# Install GitHub CLI (gh) +# NOTE: The chained && commands below mean a partial failure (e.g. wget succeeds +# but apt-get install gh fails) may leave artifacts (keyring, sources list, temp +# file). This is acceptable here because it only runs during container build β€” +# a rebuild will retry from scratch. If this block is ever moved to a runtime +# script, consider adding a trap or explicit cleanup on error. +if ! command -v gh >/dev/null 2>&1; then + echo "πŸ”§ Installing GitHub CLI..." + (type -p wget >/dev/null || apt-get install -y -qq wget) \ + && install -d -m 755 /etc/apt/keyrings \ + && out=$(mktemp) \ + && wget -qO "$out" https://cli.github.com/packages/githubcli-archive-keyring.gpg \ + && cat "$out" | tee /etc/apt/keyrings/githubcli-archive-keyring.gpg > /dev/null \ + && chmod go+r /etc/apt/keyrings/githubcli-archive-keyring.gpg \ + && echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null \ + && apt-get update -qq \ + && apt-get install -y -qq gh \ + && rm -f "$out" \ + && echo " βœ“ GitHub CLI installed: $(gh --version | head -1)" \ + || echo "⚠️ GitHub CLI installation failed (non-fatal)" +fi + +# Detect plugin workspace directory using the shared helper +PLUGIN_WS_DIR="$(detect_plugin_workspace)" +if [ -z "$PLUGIN_WS_DIR" ]; then + echo "❌ Could not locate plugin workspace directory (pyproject.toml not found)." + echo " Checked: $PWD and /workspaces/*" + exit 1 +fi +echo "πŸ“‚ Plugin workspace: $PLUGIN_WS_DIR" + +# Install this plugin in development mode +echo "πŸ“¦ Installing plugin in development mode from: $PLUGIN_WS_DIR" +if [ ! -f "$PLUGIN_WS_DIR/pyproject.toml" ] && [ ! -f "$PLUGIN_WS_DIR/setup.py" ]; then + echo "❌ Neither pyproject.toml nor setup.py found in $PLUGIN_WS_DIR" + ls -la "$PLUGIN_WS_DIR" || true + exit 2 +fi +cd "$PLUGIN_WS_DIR" +$PIP_CMD install -e . + +CONF_FILE="/opt/netbox/netbox/netbox/configuration.py" + +# Optional extras +if [ -f "$PLUGIN_WS_DIR/.devcontainer/extra-requirements.txt" ]; then + echo "πŸ“¦ Installing extra packages from extra-requirements.txt..." + $PIP_CMD install -r "$PLUGIN_WS_DIR/.devcontainer/extra-requirements.txt" +fi + + +# Inject plugin loader into standard NetBox configuration if present +if [ -f "$CONF_FILE" ]; then + if ! grep -q "# Devcontainer Plugins Loader" "$CONF_FILE" 2>/dev/null; then + { + echo ""; + echo "# Devcontainer Plugins Loader"; + echo "# Import PLUGINS/PLUGINS_CONFIG and optional extras dynamically from the workspace"; + echo "import importlib.util, os"; + echo "PLUGINS = ['netbox_librenms_plugin']"; + echo "PLUGINS_CONFIG = {'netbox_librenms_plugin': {}}"; + echo "_pc_path = '$PLUGIN_WS_DIR/.devcontainer/config/plugin-config.py'"; + echo "if os.path.isfile(_pc_path):"; + echo " _spec = importlib.util.spec_from_file_location('workspace_plugin_config', _pc_path)"; + echo " _mod = importlib.util.module_from_spec(_spec)"; + echo " try:"; + echo " _spec.loader.exec_module(_mod) # type: ignore[attr-defined]"; + echo " PLUGINS = getattr(_mod, 'PLUGINS', PLUGINS)"; + echo " PLUGINS_CONFIG = getattr(_mod, 'PLUGINS_CONFIG', PLUGINS_CONFIG)"; + echo " except Exception as e:"; + echo " print(f'⚠️ Failed to load plugin-config.py: {e}')"; + echo "else:"; + echo " print('ℹ️ plugin-config.py not found; using defaults')"; + + echo "# Import optional extra NetBox configuration (uppercase settings)"; + echo "_xc_path = '$PLUGIN_WS_DIR/.devcontainer/config/extra-configuration.py'"; + echo "if os.path.isfile(_xc_path):"; + echo " _xc_spec = importlib.util.spec_from_file_location('workspace_extra_configuration', _xc_path)"; + echo " _xc_mod = importlib.util.module_from_spec(_xc_spec)"; + echo " try:"; + echo " _xc_spec.loader.exec_module(_xc_mod) # type: ignore[attr-defined]"; + echo " for _name in dir(_xc_mod):"; + echo " if _name.isupper():"; + echo " globals()[_name] = getattr(_xc_mod, _name)"; + echo " except Exception as e:"; + echo " print(f'⚠️ Failed to apply extra-configuration.py: {e}')"; + + echo "# Import Codespaces configuration when applicable (uppercase settings)"; + echo "_cs_path = '$PLUGIN_WS_DIR/.devcontainer/config/codespaces-configuration.py'"; + echo "if os.environ.get('CODESPACES') == 'true' and os.path.isfile(_cs_path):"; + echo " _cs_spec = importlib.util.spec_from_file_location('workspace_codespaces_configuration', _cs_path)"; + echo " _cs_mod = importlib.util.module_from_spec(_cs_spec)"; + echo " try:"; + echo " _cs_spec.loader.exec_module(_cs_mod) # type: ignore[attr-defined]"; + echo " for _name in dir(_cs_mod):"; + echo " if _name.isupper():"; + echo " globals()[_name] = getattr(_cs_mod, _name)"; + echo " except Exception as e:"; + echo " print(f'⚠️ Failed to apply codespaces-configuration.py: {e}')"; + + echo "# Ensure SECRET_KEY exists: prefer environment, fallback to a dev placeholder"; + echo "if 'SECRET_KEY' not in globals() or not SECRET_KEY:"; + echo " SECRET_KEY = os.environ.get('SECRET_KEY', 'dummydummydummydummydummydummydummydummydummydummydummydummy')"; + } >> "$CONF_FILE" + fi + + if grep -q "netbox_librenms_plugin" "$CONF_FILE" 2>/dev/null; then + echo "βœ… Plugin configuration exists in NetBox settings" + fi + + +else + echo "⚠️ Warning: $CONF_FILE not found" + echo "Plugin configuration may need to be added manually" +fi + +# Run migrations and collectstatic +cd /opt/netbox/netbox + +# Wait briefly for DB (compose healthchecks should ensure availability) +export DEBUG="${DEBUG:-True}" + +echo "πŸ—ƒοΈ Applying database migrations..." +python manage.py migrate 2>&1 | grep -E "(Operations to perform|Running migrations|Apply all migrations|No migrations to apply|\s+Applying|\s+OK)" || true + +echo "πŸ” Creating superuser (if not exists)..." +echo " Credentials are read from environment variables (see .devcontainer/.env)" +python manage.py shell -c " +import os +from django.contrib.auth import get_user_model +User = get_user_model() +username = (os.environ.get('SUPERUSER_NAME') or '').strip() or 'admin' +email = (os.environ.get('SUPERUSER_EMAIL') or '').strip() or 'admin@example.com' +password = (os.environ.get('SUPERUSER_PASSWORD') or '').strip() or 'admin' +if not User.objects.filter(username=username).exists(): + User.objects.create_superuser(username, email, password) + print(f'Created superuser: {username}') +else: + print(f'Superuser {username} already exists') +" 2>/dev/null || true + +echo "πŸ“Š Collecting static files..." +python manage.py collectstatic --noinput >/dev/null 2>&1 || true + +# Set up pre-commit hooks +echo "πŸͺ Installing pre-commit hooks..." +cd "$PLUGIN_WS_DIR" +git config --global --add safe.directory "$PLUGIN_WS_DIR" +pre-commit install --install-hooks 2>/dev/null || echo "⚠️ Pre-commit hook installation failed (may already be installed)" + +# Ensure scripts are executable +chmod +x "$PLUGIN_WS_DIR/.devcontainer/scripts/start-netbox.sh" || true +chmod +x "$PLUGIN_WS_DIR/.devcontainer/scripts/diagnose.sh" || true +chmod +x "$PLUGIN_WS_DIR/.devcontainer/scripts/load-aliases.sh" || true + +# Load aliases and welcome message from the canonical source (load-aliases.sh). +# Appended to .bashrc so every interactive shell gets them automatically. +# Guard with a sentinel so rerunning setup.sh doesn't create duplicate entries. +BASHRC_SENTINEL="# NetBox LibreNMS Plugin β€” source aliases from the single canonical file" +if ! grep -qF "$BASHRC_SENTINEL" ~/.bashrc 2>/dev/null; then + cat >> ~/.bashrc << EOF +$BASHRC_SENTINEL +source "$PLUGIN_WS_DIR/.devcontainer/scripts/load-aliases.sh" + +# Show welcome message for new terminals +bash "$PLUGIN_WS_DIR/.devcontainer/scripts/welcome.sh" +EOF +fi + +# Fix Git remote URLs for dev container compatibility +echo "πŸ”§ Checking Git remote configuration..." +cd "$PLUGIN_WS_DIR" +CURRENT_REMOTE=$(git remote get-url origin 2>/dev/null || echo "") +if [[ "$CURRENT_REMOTE" == git@github.com:* ]]; then + # Convert SSH URL to HTTPS for dev container compatibility + HTTPS_URL=$(echo "$CURRENT_REMOTE" | sed 's|git@github.com:|https://github.com/|') + git remote set-url origin "$HTTPS_URL" + echo "βœ… Converted Git remote from SSH to HTTPS: $HTTPS_URL" + echo " This ensures compatibility with GitHub CLI authentication in dev containers" +elif [[ "$CURRENT_REMOTE" == https://github.com/* ]]; then + echo "βœ… Git remote already uses HTTPS: $CURRENT_REMOTE" +else + echo "ℹ️ Git remote URL: $CURRENT_REMOTE (no changes needed)" +fi + +# Final validation +cd /opt/netbox/netbox +if python -c "import netbox_librenms_plugin; print('βœ… Plugin import successful')" 2>/dev/null | grep -q "βœ… Plugin import successful"; then + echo "βœ… Plugin is properly installed and importable" +else + echo "⚠️ Warning: Plugin may not be properly installed" +fi + +echo "" +echo "πŸš€ NetBox LibreNMS Plugin Dev Environment Ready!" diff --git a/.devcontainer/scripts/start-netbox.sh b/.devcontainer/scripts/start-netbox.sh new file mode 100755 index 0000000..789dcb8 --- /dev/null +++ b/.devcontainer/scripts/start-netbox.sh @@ -0,0 +1,107 @@ +#!/bin/bash + +# Check if we should run in background or foreground +BACKGROUND=false +if [ "$1" = "--background" ] || [ "$1" = "-b" ]; then + BACKGROUND=true +fi + +echo "🌐 Starting NetBox development server..." + +# Set required environment variables +export DEBUG="${DEBUG:-True}" + +# Detect Codespaces and set access URL +if [ "$CODESPACES" = "true" ] && [ -n "$CODESPACE_NAME" ]; then + GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN="${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-app.github.dev}" + ACCESS_URL="https://${CODESPACE_NAME}-8000.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN}" + echo "πŸ”— GitHub Codespaces detected" +else + ACCESS_URL="http://localhost:8000" + echo "πŸ› Debug: ACCESS_URL is set to: $ACCESS_URL" +fi + +# Load shared process management helpers +if ! source "$(dirname "$0")/process-helpers.sh"; then + echo "ERROR: Failed to load process-helpers.sh" >&2 + exit 1 +fi + +# Kill any orphaned processes (not tracked by PID file) +echo "🧹 Cleaning up orphaned processes..." +if pgrep -f "python.*rqworker" >/dev/null 2>&1; then + echo " Found orphaned RQ workers, killing..." + graceful_kill_pattern "python.*rqworker" +fi + +if pgrep -f "python.*runserver.*8000" >/dev/null 2>&1; then + echo " Found orphaned NetBox servers, killing..." + graceful_kill_pattern "python.*runserver.*8000" +fi + +# Stop any tracked processes from PID files +if [ -f /tmp/netbox.pid ]; then + OLD_PID=$(cat /tmp/netbox.pid 2>/dev/null) + if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then + if is_expected_pid "$OLD_PID" "python.*runserver.*8000"; then + graceful_kill_pid "$OLD_PID" + else + echo "⚠️ Skipping stale /tmp/netbox.pid (PID $OLD_PID is not NetBox runserver)" + fi + fi + rm -f /tmp/netbox.pid +fi + +if [ -f /tmp/rqworker.pid ]; then + OLD_PID=$(cat /tmp/rqworker.pid 2>/dev/null) + if [ -n "$OLD_PID" ] && kill -0 "$OLD_PID" 2>/dev/null; then + if is_expected_pid "$OLD_PID" "python.*rqworker"; then + graceful_kill_pid "$OLD_PID" + else + echo "⚠️ Skipping stale /tmp/rqworker.pid (PID $OLD_PID is not rqworker)" + fi + fi + rm -f /tmp/rqworker.pid +fi + +# Activate NetBox virtual environment +source /opt/netbox/venv/bin/activate + +# Navigate to NetBox directory +cd /opt/netbox/netbox + +# Start RQ worker in background +echo "βš™οΈ Starting RQ worker..." +( + source /opt/netbox/venv/bin/activate + cd /opt/netbox/netbox + python manage.py rqworker --verbosity=1 +) > /tmp/rqworker.log 2>&1 & + +RQ_PID=$! +echo $RQ_PID > /tmp/rqworker.pid +echo "βœ… RQ worker started (PID: $RQ_PID)" + +if [ "$BACKGROUND" = true ]; then + echo "πŸš€ Starting NetBox in background" + ( + export DEBUG="${DEBUG:-True}" + source /opt/netbox/venv/bin/activate + cd /opt/netbox/netbox + python manage.py runserver 0.0.0.0:8000 --verbosity=0 + ) > /tmp/netbox.log 2>&1 & + + NETBOX_PID=$! + echo $NETBOX_PID > /tmp/netbox.pid + echo "βœ… NetBox started in background (PID: $NETBOX_PID)" + echo "πŸ“ Access NetBox at: $ACCESS_URL" + echo "πŸ’‘ If clicking the URL opens 0.0.0.0:8000, manually type: localhost:8000" + echo "πŸ“„ View logs with: netbox-logs" + echo "πŸ›‘ Stop NetBox with: netbox-stop" +else + echo "🌍 Starting NetBox in foreground" + echo "πŸ“ Access NetBox at: $ACCESS_URL" + echo "πŸ’‘ If clicking the URL opens 0.0.0.0:8000, manually type: localhost:8000" + echo "" + python manage.py runserver 0.0.0.0:8000 +fi diff --git a/.devcontainer/scripts/welcome.sh b/.devcontainer/scripts/welcome.sh new file mode 100755 index 0000000..9328d66 --- /dev/null +++ b/.devcontainer/scripts/welcome.sh @@ -0,0 +1,56 @@ +#!/bin/bash + +# Ensure aliases are available in the postAttach terminal session +source "$(dirname "$0")/load-aliases.sh" 2>/dev/null + +echo "" +echo "🎯 NetBox LibreNMS Plugin Development Environment" + +PLUGIN_WS_DIR="${PLUGIN_DIR:-$(cd "$(dirname "$0")/../.." && pwd)}" +if [ ! -f "$PLUGIN_WS_DIR/.devcontainer/config/plugin-config.py" ]; then + echo "" + echo "⚠️ Plugin configuration not found: .devcontainer/config/plugin-config.py" + echo " Create it first: cp .devcontainer/config/plugin-config.py.example .devcontainer/config/plugin-config.py" + echo " Then edit it and set your plugin values (e.g. LibreNMS server URL/token)" +fi + +# Check GitHub CLI authentication status +echo "" +if command -v gh >/dev/null 2>&1; then + if gh auth status >/dev/null 2>&1; then + # Get the authenticated user info + GH_USER=$(gh api user --jq '.login' 2>/dev/null || echo "unknown") + echo "βœ… GitHub authenticated as: $GH_USER" + echo " Git is configured for GitHub operations" + else + echo "πŸ”‘ GitHub CLI available but not authenticated" + echo " Run 'gh auth login' to authenticate with GitHub" + echo " This will automatically configure Git for pushing/pulling" + fi +else + echo "⚠️ GitHub CLI not available" +fi + +echo "" +if [ -n "$CODESPACES" ]; then + echo "🌐 GitHub Codespaces Environment:" + echo " NetBox will be available via automatic port forwarding" + echo " Check the 'Ports' panel for the forwarded port labeled 'NetBox Web Interface'" + if [ -n "$CODESPACE_NAME" ]; then + # Try to construct the likely URL (GitHub Codespaces pattern) + CODESPACE_URL="https://${CODESPACE_NAME}-8000.${GITHUB_CODESPACES_PORT_FORWARDING_DOMAIN:-preview.app.github.dev}" + echo " Expected URL: $CODESPACE_URL" + fi + echo " πŸ’‘ Click the link in the Ports panel or look for the 'Open in Browser' button" +else + echo "πŸ–₯️ Local Development Environment:" + echo " NetBox will be available at: http://localhost:8000 (paste into you browser)" +fi + +echo "" +echo "πŸš€ Quick start:" +echo " β€’ Type 'netbox-run' to start the development server" +echo " β€’ Type 'netbox-restart' to restart NetBox (after config changes)" +echo " β€’ Type 'dev-help' to see all available commands" +echo " β€’ Edit code in the workspace - auto-reload is enabled" +echo "" diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..d4a2c44 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +# http://editorconfig.org + +root = true + +[*] +indent_style = space +indent_size = 4 +trim_trailing_whitespace = true +insert_final_newline = true +charset = utf-8 +end_of_line = lf + +[*.bat] +indent_style = tab +end_of_line = crlf + +[LICENSE] +insert_final_newline = false + +[Makefile] +indent_style = tab diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..6deafc2 --- /dev/null +++ b/.flake8 @@ -0,0 +1,2 @@ +[flake8] +max-line-length = 120 diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..7afc636 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,15 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +polar: # Replace with a single Polar username +buy_me_a_coffee: bonzo81 # Replace with a single Buy Me a Coffee username +thanks_dev: # Replace with a single thanks.dev username +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml new file mode 100644 index 0000000..4ea42f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -0,0 +1,61 @@ +--- +name: πŸ› Bug Report +description: Report a reproducible bug in the current release of NetBox Librenms Plugin +labels: ["type: bug"] +body: + - type: markdown + attributes: + value: > + **NOTE:** This form is only for reporting _reproducible bugs_ in a current NetBox Librenms Plugin release. + - type: input + attributes: + label: NetBox Librenms Plugin version + description: What version of NetBox Librenms Plugin are you currently running? + placeholder: v0.1.0 + validations: + required: true + - type: input + attributes: + label: NetBox version + description: What version of NetBox are you currently running? + placeholder: v3.6.0 + validations: + required: true + - type: dropdown + attributes: + label: Python version + description: What version of Python are you currently running? + options: + - "3.8" + - "3.9" + - "3.10" + - "3.11" + - "3.12" + - "3.13" + validations: + required: true + - type: textarea + attributes: + label: Steps to Reproduce + description: > + Please provide a minimal working example to demonstrate the bug. Ensure that your example is as concise as possible + while adequately illustrating the issue. + + _Please refrain from including any confidential or sensitive + information in your example._ + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: What did you expect to happen? + placeholder: The script should execute without raising any errors or exceptions + validations: + required: true + - type: textarea + attributes: + label: Observed Behavior + description: What happened instead? + placeholder: A TypeError exception was raised + validations: + required: true diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..97bce8c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,12 @@ +# Reference: https://help.github.com/en/github/building-a-strong-community/configuring-issue-templates-for-your-repository#configuring-the-template-chooser +blank_issues_enabled: false +contact_links: + - name: πŸ“– Contributing Policy + url: https://github.com/bonzo81/netbox-librenms-plugin/blob/master/docs/contributing.md + about: "Please read through our contributing policy before opening an issue or pull request." + - name: ❓ Discussion + url: https://github.com/bonzo81/netbox-librenms-plugin/discussions + about: "If you're just looking for help, try starting a discussion instead." + - name: πŸ’¬ Community Slack + url: https://netdev.chat + about: "Join #netbox on the NetDev Community Slack for assistance with installation issues and other problems." diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml new file mode 100644 index 0000000..9414df8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yaml @@ -0,0 +1,59 @@ +--- +name: ✨ Feature Request +description: Propose a new NetBox Librenms Plugin feature or enhancement +labels: ["type: feature"] +body: + - type: markdown + attributes: + value: > + **NOTE:** This form is only for submitting well-formed proposals to extend or modify + NetBox Librenms Plugin in some way. If you're trying to solve a problem but can't figure out how, or if + you still need time to work on the details of a proposed new feature, please start a + [discussion](https://github.com/bonzo81/netbox-librenms-plugin/discussions) instead. + - type: input + attributes: + label: NetBox Librenms Plugin version + description: What version of NetBox Librenms Plugin are you currently running? + placeholder: v0.1.0 + validations: + required: true + - type: input + attributes: + label: NetBox version + description: What version of NetBox are you currently running? + placeholder: v3.6.0 + validations: + required: true + - type: dropdown + attributes: + label: Feature type + options: + - Data model extension + - New functionality + - Change to existing functionality + validations: + required: true + - type: textarea + attributes: + label: Proposed functionality + description: > + Describe in detail the new feature or behavior you are proposing. Include any specific changes + to work flows, data models, and/or the user interface. The more detail you provide here, the + greater chance your proposal has of being discussed. Feature requests which don't include an + actionable implementation plan will be rejected. + validations: + required: true + - type: textarea + attributes: + label: Use case + description: > + Explain how adding this functionality would benefit NetBox Librenms Plugin users. What need does it address? + validations: + required: true + - type: textarea + attributes: + label: External dependencies + description: > + List any new dependencies on external libraries or services that this new feature would + introduce. For example, does the proposal require the installation of a new Python package? + (Not all new features introduce new dependencies.) diff --git a/.github/ISSUE_TEMPLATE/housekeeping.yaml b/.github/ISSUE_TEMPLATE/housekeeping.yaml new file mode 100644 index 0000000..7778713 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/housekeeping.yaml @@ -0,0 +1,24 @@ +--- +name: 🏑 Housekeeping +description: A change pertaining to the codebase itself (developers only) +labels: ["type: housekeeping"] +body: + - type: markdown + attributes: + value: > + **NOTE:** This template is for use by maintainers only. Please do not submit + an issue using this template unless you have been specifically asked to do so. + - type: textarea + attributes: + label: Proposed Changes + description: > + Describe in detail the new feature or behavior you'd like to propose. + Include any specific changes to work flows, data models, or the user interface. + validations: + required: true + - type: textarea + attributes: + label: Justification + description: Please provide justification for the proposed change(s). + validations: + required: true diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..b64357a --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,75 @@ +# NetBox LibreNMS Plugin – AI Assistant Guide + +> **Note:** Additional context-specific instructions are in `.github/instructions/`: +> - [testing.instructions.md](instructions/testing.instructions.md) – applies to `tests/**` +> - [frontend.instructions.md](instructions/frontend.instructions.md) – applies to templates and static files +> - [background-jobs.instructions.md](instructions/background-jobs.instructions.md) – applies to `jobs.py`, import views, and import utilities +> - [sync.instructions.md](instructions/sync.instructions.md) – applies to sync views, base views, tables, and sync JS + +## Architecture & Key Modules +- Plugin hooks into NetBox (Django 5) under `netbox_librenms_plugin/`; respect NetBox plugin APIs (`navigation.py`, `urls.py`, `api/`). +- LibreNMS communication lives in `librenms_api.py`; reuse this client instead of new `requests` calls. It handles multi-server configs via `LibreNMSSettings` model and the `servers` plugin config, plus caching via Django cache + custom fields. +- Views follow a three-layer structure: + - **Base views** (`views/base/`) β€” abstract views for each sync resource (`BaseInterfaceTableView`, `BaseCableTableView`, `BaseIPAddressTableView`, `BaseVLANTableView`). + - **Object sync views** (`views/object_sync/`) β€” concrete per-model views registered as tabs on NetBox's Device/VM detail pages via `@register_model_view(Device, ...)`. These wire base views to models. + - **Sync action views** (`views/sync/`) β€” POST-only views that apply changes (add/change/delete NetBox objects). Includes `interfaces.py`, `cables.py`, `ip_addresses.py`, `vlans.py`, `devices.py`, `device_fields.py`, `locations.py`. + - **Shared mixins** (`views/mixins.py`) β€” `LibreNMSPermissionMixin`, `NetBoxObjectPermissionMixin`, `LibreNMSAPIMixin`, `CacheMixin`, `VlanAssignmentMixin`. +- All four sync resources (interfaces, cables, IP addresses, VLANs) follow the same three-layer pattern. VLAN sync additionally uses `VlanAssignmentMixin` for VLAN group scope resolution (Rack β†’ Location β†’ Site β†’ SiteGroup β†’ Region β†’ Global). +- New views should extend the closest base class and compose mixins. +- Tables (`tables/*.py`) and templates (`templates/netbox_librenms_plugin/`) drive the UI. See `frontend.instructions.md` for HTMX, template, and styling conventions. +- Forms (`forms.py`) include dynamic LibreNMS API-populated choices (location dropdowns, poller groups) and a split-form pattern for settings (server config form + import settings form). +- `import_validation_helpers.py` centralizes validation state mutation during import (role/cluster/rack assignment, issue removal, status recalculation). + +## Data & Sync Conventions +- Devices/VMs map to LibreNMS via the `librenms_id` custom field, then cached if absent. Always call `LibreNMSAPI.get_librenms_id` instead of touching the field directly. +- Matching is intentionally **exact-only** for site, platform, device type, and role. See `utils.py` (`find_matching_site`, `match_librenms_hardware_to_device_type`, `find_matching_platform`). Do not add fuzzy matching. +- Sync pipelines generally fetch LibreNMS data (`librenms_api.py`), cache it (`CacheMixin`), build comparison tables (`tables/`), and render HTMX fragments (`templates/netbox_librenms_plugin/htmx/`). Follow that flow for new resources. +- Virtual chassis support uses `get_virtual_chassis_member()` for port-to-member mapping and `get_librenms_sync_device()` for VC priority-based device selection. + +## Developer Workflow +- Prefer the devcontainer commands (`netbox-run`, `netbox-run-bg`, `netbox-reload`, `netbox-logs`) described in `.devcontainer/README.md`. They manage NetBox + plugin reloading. +- Static assets belong in `static/netbox_librenms_plugin/`; run NetBox's `collectstatic` when bundling, but the devcontainer handles this automatically. + +## Integration Touchpoints +- REST endpoints for imports live in `views/imports/actions.py` (with the list view in `views/imports/list.py`) and surface via `urls.py`. They also emit HTMX fragments (`templates/netbox_librenms_plugin/htmx/device_import_row.html`, etc.). Keep server responses and HTMX targets in sync. +- API serializers (`api/serializers.py`) mirror models for external consumption. Update serializers and `api/views.py` together to avoid contract drift. +- Navigation and menu items are registered in `navigation.py`; extend there for new sections so NetBox renders links correctly. + +## Permission System +- Uses two-tier permissions via `LibreNMSSettings` model: `view_librenmssettings` (read) and `change_librenmssettings` (write). See `docs/development/permissions.md`. +- Permission constants in `constants.py`: `PERM_VIEW_PLUGIN` and `PERM_CHANGE_PLUGIN`. + +### Plugin-Level Permissions +- All views inherit `LibreNMSPermissionMixin` from `views/mixins.py`, which sets `permission_required = PERM_VIEW_PLUGIN` and provides: + - `has_write_permission()` β€” checks `PERM_CHANGE_PLUGIN`. + - `require_write_permission()` β€” returns error response (HTMX `HX-Redirect` or standard redirect) if denied. + - `require_write_permission_json()` β€” returns `JsonResponse(403)` if denied (for AJAX endpoints). + +### Object-Level Permissions +- `NetBoxObjectPermissionMixin` adds a **second layer** of permission checking for NetBox model operations (add/change/delete on Device, Interface, VLAN, etc.). +- Views declare `required_object_permissions` dict mapping HTTP methods to `[(action, Model)]` tuples, e.g.: + ```python + required_object_permissions = {"POST": [("add", VLAN), ("change", VLAN)]} + ``` +- Some views set `required_object_permissions` dynamically per-request (e.g., `SyncInterfacesView` switches between `Interface` and `VMInterface` based on object type). +- Provides: + - `check_object_permissions(method)` β†’ `(bool, missing_perms_list)` + - `require_object_permissions(method)` β€” redirect/HTMX on failure. + - `require_object_permissions_json(method)` β€” JSON 403 on failure. + - `require_all_permissions(method)` β€” combined plugin write + object perms check (redirect/HTMX). + - `require_all_permissions_json(method)` β€” combined check, JSON variant. +- **Sync POST handlers** must call `require_all_permissions("POST")` (not just `require_write_permission()`) and return early if it returns a response. AJAX/JSON endpoints use `require_all_permissions_json("POST")`. +- `_get_safe_redirect_url(request)` validates referrer URLs to prevent open-redirect attacks. + +### Permission Helpers for Background Jobs +- Background jobs run outside view context and cannot use view mixins. Use standalone helpers from `import_utils.py` (`check_user_permissions`, `require_permissions`). See `background-jobs.instructions.md` for details. + +### API & Navigation Permissions +- API endpoints use `LibreNMSPluginPermission` class in `api/views.py` (GET=view, others=change). +- Navigation menu (`navigation.py`) has 3 groups: **Settings** (Plugin Settings, Interface Mappings), **Import** (LibreNMS Import), **Status Check** (Site & Location Sync, Device Status, VM Status). All items use `permissions=[PERM_VIEW_PLUGIN]`. +- **Background job polling requires superuser** β€” non-superusers fall back to synchronous mode. See `background-jobs.instructions.md` for details. + +## When in Doubt +- Check docs in `docs/development/` for structure, view inheritance, mixins, and template conventions before introducing new patterns. +- Review the existing sync views (e.g., `views/sync/interfaces.py`) as reference implementations for data flow and caching patterns. +- Coordinate any schema changes through Django migrations in `migrations/` and update `models.py` + admin/pydantic representations accordingly. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5e142be --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,14 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: + - "*" + - package-ecosystem: "uv" + directory: "/" + schedule: + interval: "weekly" diff --git a/.github/instructions/background-jobs.instructions.md b/.github/instructions/background-jobs.instructions.md new file mode 100644 index 0000000..405d9e0 --- /dev/null +++ b/.github/instructions/background-jobs.instructions.md @@ -0,0 +1,84 @@ +--- +applyTo: "**/jobs.py,**/views/imports/**,**/import_utils.py,**/import_validation_helpers.py" +description: Background job architecture, import workflow, and task management patterns +--- + +# Background Jobs & Import Workflow + +## Job Architecture +- Background jobs use NetBox's `JobRunner` base class (`netbox.jobs.JobRunner`) for long-running operations like device filtering with VC detection. +- Jobs run via Redis Queue (RQ) in Redis, separate from the database Job model. Real-time status must be checked via RQ, not the database. + +## Critical Job Architecture Points +- Job UUID (`job.job_id`) is used for RQ API endpoints: `/api/core/background-tasks/{uuid}/` +- Job PK (`job.pk`) is used for database endpoints and result loading +- RQ status values: `queued`, `started`, `finished`, `stopped`, `failed` (NOT `completed`) +- Database Job status values: `pending`, `scheduled`, `running`, `completed`, `failed`, `errored` (NO `cancelled` status exists) +- Check `rq_job.is_stopped` or `rq_job.is_failed` flags in Redis for cancellation detection, not database status + +## Job Cancellation Flow +1. Call `/api/core/background-tasks/{uuid}/stop/` to stop RQ job +2. Call plugin's sync endpoint `/api/plugins/librenms_plugin/jobs/{pk}/sync-status/` to update database +3. Frontend polling detects status changes and redirects appropriately + +## Polling Implementation +- Poll `/api/core/background-tasks/{uuid}/` for real-time RQ status +- Update modal messages based on status: "Job queued...", "Processing...", "Job completed!" +- Handle all RQ status values explicitly to avoid infinite polling +- Use `cancelInProgress` flag to prevent polling interference during cancellation + +## Superuser Requirement for Background Jobs +- NetBox's `/api/core/background-tasks/` endpoint requires **superuser** (`IsSuperuser` in `BaseRQViewSet`). +- Non-superuser users cannot poll job status; they get 403 Forbidden. +- The plugin automatically falls back to synchronous mode for non-superusersβ€”see `should_use_background_job()` in `list.py` and `actions.py`. +- This is a NetBox core design decision, not a plugin limitation. No amount of permissions (including `core.view_job`) bypasses it. + +## Import Jobs +- **`FilterDevicesJob`** β€” background device filtering with VC detection. `job.data` keys: `device_ids`, `total_processed`, `filters`, `server_key`, `vc_detection_enabled`, `cache_timeout`, `cached_at`, `completed`. Devices are cached individually via shared cache keys from `get_validated_device_cache_key()`. +- **`ImportDevicesJob`** β€” background device/VM import. Calls `bulk_import_devices_shared()` for devices and `bulk_import_vms()` for VMs. `job.data` keys: `imported_device_pks`, `imported_vm_pks`, `imported_libre_device_ids`, `imported_libre_vm_ids`, `server_key`, `total`, `success_count`, `failed_count`, `skipped_count`, `virtual_chassis_created`, `errors`, `completed`. + +## Shared Cache Key Pattern +- Both synchronous and background modes use `get_validated_device_cache_key()` from `import_utils.py` to generate cache keys. This ensures `_load_job_results()` in the list view can retrieve devices regardless of which mode produced them. +- `get_active_cached_searches()` manages multi-search cache to let users run and switch between searches. +- Never hardcode cache key formats; always use the helper functions. + +## Permission Checks in Jobs +- Background jobs run outside view context, so they cannot use view mixins. +- Use standalone helpers from `import_utils.py` for permission checks inside job code: + - `check_user_permissions(user, permissions)` β†’ `(bool, missing_list)` + - `require_permissions(user, permissions, action_description)` β€” raises `PermissionDenied`. + +## Custom Sync Endpoint +`api/views.py::sync_job_status()` syncs database Job status with RQ job status, needed because NetBox worker doesn't always update DB when jobs stop before processing starts. + +## Import Page Flow +The import page (`LibreNMSImportView` in `views/imports/list.py`) supports two modes: + +1. **Synchronous** β€” calls `process_device_filters()` directly, renders results inline. +2. **Background** β€” enqueues `FilterDevicesJob`, returns `JsonResponse` with `job_id`/`job_pk`/`poll_url`. Frontend polls and redirects to `?job_id={pk}` on completion. + +Result loading: `_load_job_results(job_id)` reads `job.data["device_ids"]`, reconstructs devices from per-device cache using `get_validated_device_cache_key()`. + +Filter fields: `librenms_location`, `librenms_type`, `librenms_os`, `librenms_hostname`, `librenms_sysname`, `librenms_hardware`, `enable_vc_detection`, `show_disabled`, `exclude_existing`. + +## Import Action Views (`views/imports/actions.py`) +- **`DeviceImportHelperMixin`** β€” provides `get_validated_device_with_selections()` and `render_device_row()` for HTMX row rendering. Shared by update views. +- **`BulkImportConfirmView`** (POST) β€” renders confirmation modal with selected device list. Returns `htmx/bulk_import_confirm.html`. +- **`BulkImportDevicesView`** (POST) β€” executes import. Background mode enqueues `ImportDevicesJob`; sync mode calls `bulk_import_devices()` + `bulk_import_vms()` and returns OOB row swaps with `HX-Trigger: closeModal`. +- **`DeviceValidationDetailsView`** (GET) β€” renders expandable validation details via `htmx/device_validation_details.html`. +- **`DeviceVCDetailsView`** (GET) β€” renders VC member details via `htmx/device_vc_details.html`. +- **`DeviceRoleUpdateView`**, **`DeviceClusterUpdateView`**, **`DeviceRackUpdateView`** (POST) β€” per-device dropdown updates. Apply selection to validation state and return re-rendered row via `render_device_row()`. + +## Key Import Utilities (`import_utils.py`) +- `process_device_filters(filters, ...)` β€” fetches and validates devices from LibreNMS, returns list. +- `validate_device_for_import(device, ...)` β€” core validation function, produces validation state dict. +- `bulk_import_devices_shared(devices, user, ...)` β€” shared implementation between sync and background import. +- `bulk_import_vms(vm_imports, user, ...)` β€” VM import implementation. +- `fetch_device_with_cache(device_id, ...)` β€” retrieves/caches individual device data. +- Cache key functions: `get_validated_device_cache_key()`, `get_cache_metadata_key()`, `get_active_cached_searches()`, `get_import_device_cache_key()`. + +## Validation Helpers (`import_validation_helpers.py`) +Centralizes validation state mutation used by the role/cluster/rack update views: +- `apply_role_to_validation()`, `apply_cluster_to_validation()`, `apply_rack_to_validation()` β€” update validation state when user selects a role/cluster/rack. +- `remove_validation_issue()`, `recalculate_validation_status()` β€” maintain issue list and overall status. +- `fetch_model_by_id()`, `extract_device_selections()` β€” helpers for reading form data. diff --git a/.github/instructions/frontend.instructions.md b/.github/instructions/frontend.instructions.md new file mode 100644 index 0000000..4f02754 --- /dev/null +++ b/.github/instructions/frontend.instructions.md @@ -0,0 +1,66 @@ +--- +applyTo: "netbox_librenms_plugin/templates/**,netbox_librenms_plugin/static/**" +description: Frontend patterns for templates, HTMX, and static assets +--- + +# Frontend Patterns + +## HTMX Conventions +- HTMX 2.x is the primary async layer. Table row updates return ``. +- Avoid `outerHTML` swaps; use OOB or targeted `innerHTML` swaps to keep table layout intact. +- All HTMX requests and `fetch()` calls must include a CSRF token. The standard pattern is `document.querySelector('[name=csrfmiddlewaretoken]').value` (from a hidden form input). The import JS also uses `getCookie('csrftoken')` as a fallback β€” prefer the hidden input approach for consistency. + +## Modal Implementation +- Modals use Tabler (Bootstrap-like) but **without** `bootstrap.Modal` helpers. +- Buttons target the `htmx-modal-content` element and JavaScript in `librenms_import.html` toggles the wrapper. +- Do not reintroduce `data-bs-toggle` or duplicate modal IDs. +- The import page uses `ModalManager` class and `filterModalManager` instanceβ€”always use this reference in fetch callbacks, not undefined `modalInstance` variables. + +## JavaScript Fetch Patterns +- Always check `response.ok` before processing fetch responses to catch HTTP errors. +- In catch blocks, show `error.message` for debugging rather than generic messages. +- The import filter form uses fetch with `Accept: application/json, text/html`β€”JSON for background jobs, HTML for synchronous mode. + +## Form Controls +- Device import dropdowns rely on TomSelect decorators set up elsewhere. +- Keep `{1}', + port_id, + mark_safe("".join(options)), + ) + + class Meta(LibreNMSCableTable.Meta): + """Define column sequence and attributes for the VC cable table.""" + + sequence = [ + "selection", + "device_selection", + "local_port", + "remote_port", + "remote_device", + "cable_status", + "actions", + ] + row_attrs = { + "data-interface": lambda record: record["local_port_id"], + "data-device": lambda record: record["device_id"], + "data-name": lambda record: record["local_port"], + "id": lambda record: record["local_port_id"], + } + attrs = { + "class": "table table-hover object-list", + "id": "librenms-cable-table-vc", + } diff --git a/netbox_librenms_plugin/tables/device_status.py b/netbox_librenms_plugin/tables/device_status.py new file mode 100644 index 0000000..995a623 --- /dev/null +++ b/netbox_librenms_plugin/tables/device_status.py @@ -0,0 +1,722 @@ +import json + +import django_tables2 as tables +from dcim.models import Device +from dcim.tables import DeviceTable +from django.urls import reverse +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django_tables2 import Column +from virtualization.models import VirtualMachine + +from netbox_librenms_plugin.utils import get_librenms_sync_device + + +class DeviceStatusTable(DeviceTable): + """ + Table for displaying device LibreNMS status. + """ + + librenms_status = Column( + verbose_name="LibreNMS Status", + empty_values=(), + accessor="librenms_status", + orderable=False, + ) + + def render_librenms_status(self, value, record): + """Render LibreNMS sync status with link to sync page.""" + sync_url = reverse( + "plugins:netbox_librenms_plugin:device_librenms_sync", + kwargs={"pk": record.pk}, + ) + + # Check if device is VC member and redirect to sync device if different + if hasattr(record, "virtual_chassis") and record.virtual_chassis: + sync_device = get_librenms_sync_device(record) + if sync_device and record.pk != sync_device.pk: + sync_device_url = reverse( + "plugins:netbox_librenms_plugin:device_librenms_sync", + kwargs={"pk": sync_device.pk}, + ) + return mark_safe( + f'' + f' See {sync_device.name}' + ) + if value: + status = ' Found' + elif value is False: + status = ' Not Found' + else: + status = ' Unknown' + + return mark_safe(f'{status}') + + class Meta(DeviceTable.Meta): + """Meta options for DeviceStatusTable.""" + + model = Device + fields = ( + "pk", + "name", + "status", + "tenant", + "site", + "location", + "rack", + "role", + "manufacturer", + "device_type", + "device_role", + "librenms_status", + ) + default_columns = ( + "name", + "status", + "site", + "location", + "rack", + "device_type", + "role", + "librenms_status", + ) + + +class DeviceImportTable(tables.Table): + """ + Table for displaying LibreNMS devices available for import. + Shows validation status and provides import actions. + Uses plain django_tables2.Table since we're working with dictionaries, not model instances. + """ + + name = "DeviceImportTable" # Required by NetBox table utilities + + def __init__(self, *args, **kwargs): + """Initialize table with cached querysets and apply sorting.""" + super().__init__(*args, **kwargs) + + # Cache querysets to avoid N queries per render + from dcim.models import DeviceRole + from virtualization.models import Cluster + + self._cached_clusters = list(Cluster.objects.all().order_by("name")) + self._cached_roles = list(DeviceRole.objects.all().order_by("name")) + + # Apply sorting if order_by is specified + # Since we're working with dictionaries, not QuerySets, we handle sorting manually + if self.order_by: + self._sort_data() + + def _sort_data(self): + """Sort table data based on order_by parameter.""" + if not self.data: + return + + # Get the ordering field and direction + order_by = self.order_by[0] if isinstance(self.order_by, (list, tuple)) else self.order_by + reverse = order_by.startswith("-") + field = order_by.lstrip("-") + + # Map column names to data keys + field_map = { + "hostname": "hostname", + "sysname": "sysName", + "location": "location", + "hardware": "hardware", + } + + data_key = field_map.get(field) + if not data_key: + return # Unknown field, skip sorting + + # Sort the data list in place + # Handle None values by treating them as empty strings for sorting + def sort_key(item): + """Return lowercase sort value for a data field.""" + value = item.get(data_key, "") + return (value or "").lower() if isinstance(value, str) else str(value or "") + + try: + self.data.data.sort(key=sort_key, reverse=reverse) + except (AttributeError, TypeError): + # If data is a plain list, sort it directly + if isinstance(self.data, list): + self.data.sort(key=sort_key, reverse=reverse) + + # Selection checkbox + selection = Column( + verbose_name="", + empty_values=(), + orderable=False, + accessor="device_id", + ) + + # LibreNMS device fields + hostname = Column(verbose_name="Hostname", accessor="hostname", orderable=True) + sysname = Column(verbose_name="System Name", accessor="sysName", orderable=True) + location = Column(verbose_name="Location", accessor="location", orderable=True) + hardware = Column(verbose_name="Hardware", accessor="hardware", orderable=True) + + # Cluster selection - if selected, import as VM; otherwise import as Device + netbox_cluster = Column( + verbose_name="NetBox Cluster", + empty_values=(), + orderable=False, + accessor="device_id", + ) + + # NetBox role selection (for devices only) + netbox_role = Column( + verbose_name="NetBox Role", + empty_values=(), + orderable=False, + accessor="device_id", + ) + + # NetBox rack selection (for devices only, optional) + netbox_rack = Column( + verbose_name="NetBox Rack", + empty_values=(), + orderable=False, + accessor="device_id", + ) + + # Virtual Chassis detection column + virtual_chassis = Column( + verbose_name="Virtual Chassis", + empty_values=(), + orderable=False, + accessor="device_id", + ) + + # Actions column + actions = Column( + verbose_name="Actions", + empty_values=(), + orderable=False, + accessor="device_id", + ) + + def render_selection(self, value, record): + """ + Render selection checkbox. + Disabled if device can't be imported. + """ + validation = record.get("_validation", {}) + can_import = validation.get("can_import", False) + device_id = record.get("device_id") + hostname = record.get("hostname", "") + sysname = record.get("sysName", "") + + if can_import: + return mark_safe( + f'' + ) + else: + return mark_safe( + '' + ) + + def render_hostname(self, value, record): + """Render hostname with link to LibreNMS if available.""" + return mark_safe(f"{value}") + + def render_netbox_cluster(self, value, record): + """ + Render cluster selection dropdown. + Default is "-- Device (not VM) --" (empty value). + If a cluster is selected, the device will be imported as a VM. + If no cluster is selected, the device will be imported as a Device. + """ + device_id = record.get("device_id") + validation = record.get("_validation", {}) + existing = validation.get("existing_device") + + # Check if existing object is a VM + if existing and isinstance(existing, VirtualMachine): + # VM already exists - show its cluster (cluster is required for VMs) + cluster = existing.cluster + return mark_safe(f'{cluster.name}') + + # If Device already exists (not VM), show it's not a VM + if existing: + return mark_safe('Device (not VM)') + + # Use cached clusters to avoid N queries + clusters = self._cached_clusters + + # Check if a cluster has been selected (from validation) + selected_cluster_id = None + if validation.get("cluster", {}).get("found") and validation.get("cluster", {}).get("cluster"): + selected_cluster_id = validation["cluster"]["cluster"].pk + + # Build dropdown with HTMX attributes to update the row + options = [''] + for cluster in clusters: + selected = " selected" if cluster.pk == selected_cluster_id else "" + options.append(f'') + + # Add HTMX attributes to update the entire row when cluster is selected + from django.urls import reverse + + update_url = reverse( + "plugins:netbox_librenms_plugin:device_cluster_update", + kwargs={"device_id": device_id}, + ) + + # Include VC detection flag in URL if present in validation (from initial load) + vc_detection_flag = "" + if validation.get("_vc_detection_enabled"): + vc_detection_flag = "?enable_vc_detection=true" + + select_html = ( + f'" + ) + + return mark_safe(select_html) + + def render_netbox_role(self, value, record): + """ + Render role selection dropdown. + For Devices: Role is required + For VMs: Role is optional + """ + device_id = record.get("device_id") + validation = record.get("_validation", {}) + is_vm = validation.get("import_as_vm", False) + existing = validation.get("existing_device") + + # If device/VM already exists, show its role with NetBox's defined color + if existing and hasattr(existing, "role") and existing.role: + role = existing.role + # Use the role's color if available, otherwise fallback to info + color = role.color if hasattr(role, "color") and role.color else "6c757d" + return mark_safe( + f'{role.name}' + ) + + # Use cached roles to avoid N queries + roles = self._cached_roles + + # Check if a role has been selected (from validation) + selected_role_id = None + if validation.get("device_role", {}).get("found") and validation.get("device_role", {}).get("role"): + selected_role_id = validation["device_role"]["role"].pk + + # Build dropdown with different text based on import type + if is_vm: + placeholder = "-- Select Role (Optional) --" + else: + placeholder = "-- Select Role --" + + options = [f''] + for role in roles: + selected = " selected" if role.pk == selected_role_id else "" + options.append(f'') + + # Add HTMX attributes to update the entire row when role is selected + from django.urls import reverse + + update_url = reverse( + "plugins:netbox_librenms_plugin:device_role_update", + kwargs={"device_id": device_id}, + ) + + # Include VC detection flag in URL if present in validation (from initial load) + vc_detection_flag = "" + if validation.get("_vc_detection_enabled"): + vc_detection_flag = "?enable_vc_detection=true" + + select_html = ( + f'" + ) + + return mark_safe(select_html) + + def render_netbox_rack(self, value, record): + """ + Render rack selection dropdown (optional). + Shows racks for the matched site in "Location - Rack" format. + Only shown for devices (not VMs) and when site is matched. + """ + device_id = record.get("device_id") + validation = record.get("_validation", {}) + is_vm = validation.get("import_as_vm", False) + existing = validation.get("existing_device") + + # Don't show rack dropdown for VMs + if is_vm: + return mark_safe('N/A (VM)') + + # If device already exists, show its rack + if existing and hasattr(existing, "rack") and existing.rack: + rack = existing.rack + location_name = rack.location.name if rack.location else "No Location" + return mark_safe(f'{location_name} - {rack.name}') + + # If device exists but no rack assigned + if existing: + return mark_safe('No rack') + + # Check if site is matched - rack selection only available when site is known + site_found = validation.get("site", {}).get("found", False) + if not site_found: + return mark_safe('--') + + # Get available racks from validation (cached) + available_racks = validation.get("rack", {}).get("available_racks", []) + + # Check if a rack has been selected + selected_rack_id = None + if validation.get("rack", {}).get("rack"): + selected_rack_id = validation["rack"]["rack"].pk + + # Build dropdown with HTMX attributes + options = [''] + for rack in available_racks: + location_name = rack.location.name if rack.location else "No Location" + display_text = f"{location_name} - {rack.name}" + selected = " selected" if rack.pk == selected_rack_id else "" + options.append(f'') + + # Add HTMX attributes to update the entire row when rack is selected + from django.urls import reverse + + update_url = reverse( + "plugins:netbox_librenms_plugin:device_rack_update", + kwargs={"device_id": device_id}, + ) + + # Include VC detection flag in URL if present in validation (from initial load) + vc_detection_flag = "" + if validation.get("_vc_detection_enabled"): + vc_detection_flag = "?enable_vc_detection=true" + + select_html = ( + f'" + ) + + return mark_safe(select_html) + + def render_actions(self, value, record): + """ + Render action buttons for import using HTMX. + Shows Import button if can import, otherwise shows Preview/Configure. + Permission checks are handled by backend require_write_permission() which shows toast. + """ + validation = record.get("_validation", {}) + device_id = record.get("device_id") + is_ready = validation.get("is_ready", False) + can_import = validation.get("can_import", False) + existing = validation.get("existing_device") + + vc_attributes = self._build_vc_attributes(validation, record) + + buttons = [] + + if existing: + # Link to existing device/VM in NetBox + details button for conflict resolution + if isinstance(existing, VirtualMachine): + url_name = "virtualization:virtualmachine" + title = "View VM in NetBox" + else: + url_name = "dcim:device" + title = "View Device in NetBox" + + device_url = reverse(url_name, kwargs={"pk": existing.pk}) + buttons.append( + f'' + ) + + # Add details/conflict button for conflict resolution actions + details_url = self._build_validation_details_url(device_id, validation) + match_type = validation.get("existing_match_type", "") + serial_action = validation.get("serial_action") + has_mismatch = validation.get("device_type_mismatch", False) + has_actions = match_type == "hostname" or (match_type == "serial" and serial_action is not None) + has_name_sync = validation.get("name_sync_available", False) + has_sync_needed = match_type == "librenms_id" and serial_action in ("update_serial", "conflict") + + if has_mismatch: + btn_class = "btn-outline-danger" + btn_icon = "mdi-alert-circle" + btn_label = " Conflict" + btn_title = "View conflict details" + elif has_actions: + btn_class = "btn-outline-warning" + btn_icon = "mdi-alert" + btn_label = " Conflict" + btn_title = "View conflict details" + elif has_name_sync or has_sync_needed: + btn_class = "btn-outline-warning" + btn_icon = "mdi-information-outline" + btn_label = " Details" + btn_title = "View details" + elif match_type == "librenms_id" and validation.get("librenms_id_needs_migration"): + btn_class = "btn-outline-warning" + btn_icon = "mdi-database-alert" + btn_label = " Legacy ID" + btn_title = "View legacy ID migration details" + else: + btn_class = "btn-outline-success" + btn_icon = "mdi-check-circle" + btn_label = "" + btn_title = "View details" + aria_attr = f'aria-label="{btn_title}" ' + buttons.append( + f'' + ) + elif is_ready: + # Ready to import - show Import and Details buttons + details_url = self._build_validation_details_url(device_id, validation) + + buttons.append( + f'' + ) + buttons.append( + f'' + ) + elif can_import: + # Has warnings - show Review button with Details + details_url = self._build_validation_details_url(device_id, validation) + + buttons.append( + f'' + ) + else: + # Cannot import (usually missing role) - show Import button (disabled until role selected) and Details + details_url = self._build_validation_details_url(device_id, validation) + + buttons.append( + f'' + ) + buttons.append( + f'' + ) + + return mark_safe('
' + " ".join(buttons) + "
") + + def render_virtual_chassis(self, value, record): + """Render Virtual Chassis status and details button.""" + validation = record.get("_validation", {}) + vc_data = validation.get("virtual_chassis", {}) + device_id = record.get("device_id") + + # Show dash for non-VC or single member stacks + if not vc_data.get("is_stack") or vc_data.get("member_count", 0) <= 1: + return mark_safe('β€”') + + vc_url = reverse( + "plugins:netbox_librenms_plugin:device_vc_details", + kwargs={"device_id": device_id}, + ) + + # Show error button if detection failed + if vc_data.get("detection_error"): + return mark_safe( + f'' + ) + + # Show member count button for valid multi-member stacks + member_count = vc_data.get("member_count", 0) + return mark_safe( + f'' + ) + + @staticmethod + def _build_validation_details_url(device_id: int, validation: dict) -> str: + """ + Build validation details URL with appropriate query parameters. + + Constructs the URL for the device validation details modal, adding + cluster_id, role_id, and VC detection flag as query parameters. + + Args: + device_id: LibreNMS device ID + validation: Validation dict from validate_device_for_import() + + Returns: + str: Complete URL with query parameters + """ + details_url = reverse( + "plugins:netbox_librenms_plugin:device_validation_details", + kwargs={"device_id": device_id}, + ) + + # Build query params based on import type + params = [] + + # Add cluster_id if this is a VM import + if validation.get("cluster", {}).get("found") and validation.get("cluster", {}).get("cluster"): + cluster_id = validation["cluster"]["cluster"].id + params.append(f"cluster_id={cluster_id}") + # Add role_id if device role is found + elif validation.get("device_role", {}).get("found") and validation.get("device_role", {}).get("role"): + role_id = validation["device_role"]["role"].id + params.append(f"role_id={role_id}") + + # Add VC detection flag if it was enabled during initial load + if validation.get("_vc_detection_enabled"): + params.append("enable_vc_detection=true") + + if params: + details_url += "?" + "&".join(params) + + return details_url + + @staticmethod + def _build_vc_attributes(validation: dict, record: dict) -> str: + vc_data = validation.get("virtual_chassis") or {} + if not vc_data.get("is_stack"): + return ' data-vc-is-stack="false"' + + members_payload = [] + for member in vc_data.get("members", []): + members_payload.append( + { + "position": member.get("position"), + "serial": member.get("serial"), + "suggested_name": member.get("suggested_name"), + } + ) + + payload = { + "member_count": vc_data.get("member_count", len(members_payload)), + "members": members_payload, + "detection_error": vc_data.get("detection_error"), + } + + payload_json = escape(json.dumps(payload)) + master_name = record.get("hostname") or record.get("sysName") or "" + master_value = escape(master_name) + + return ( + ' data-vc-is-stack="true"' + f' data-vc-member-count="{payload["member_count"]}"' + f' data-vc-info="{payload_json}"' + f' data-vc-master="{master_value}"' + ) + + class Meta: + """Meta options for DeviceImportTable.""" + + # No model - we're working with LibreNMS API dictionaries, not Django model instances + # This prevents NetBoxTable from auto-adding custom fields from Device model + + # Add row attributes to give each row a unique ID for HTMX targeting + row_attrs = { + "id": lambda record: f"device-row-{record.get('device_id')}", + } + + fields = ( + "selection", + "hostname", + "sysname", + "location", + "hardware", + "netbox_cluster", + "netbox_role", + "netbox_rack", + "virtual_chassis", + "actions", + ) + sequence = ( + "selection", + "hostname", + "sysname", + "location", + "hardware", + "netbox_cluster", + "netbox_role", + "netbox_rack", + "virtual_chassis", + "actions", + ) + default_columns = fields + orderable = True + attrs = { + "class": "table table-hover", + "id": "device-import-table", + } diff --git a/netbox_librenms_plugin/tables/interfaces.py b/netbox_librenms_plugin/tables/interfaces.py new file mode 100644 index 0000000..d99d2a7 --- /dev/null +++ b/netbox_librenms_plugin/tables/interfaces.py @@ -0,0 +1,616 @@ +import json as json_module + +import django_tables2 as tables +from django.utils.html import escape, format_html +from django.utils.safestring import mark_safe +from netbox.tables.columns import BooleanColumn, ToggleColumn +from utilities.paginator import EnhancedPaginator +from utilities.templatetags.helpers import humanize_speed + +from netbox_librenms_plugin.models import InterfaceTypeMapping +from netbox_librenms_plugin.utils import ( + check_vlan_group_matches, + convert_speed_to_kbps, + format_mac_address, + get_interface_name_field, + get_librenms_device_id, + get_missing_vlan_warning, + get_table_paginate_count, + get_tagged_vlan_css_class, + get_untagged_vlan_css_class, + get_virtual_chassis_member, +) + + +class LibreNMSInterfaceTable(tables.Table): + """ + Table for displaying LibreNMS interface data. + """ + + class Meta: + """Meta options for LibreNMSInterfaceTable.""" + + sequence = [ + "selection", + "name", + "type", + "speed", + "vlans", + "mac_address", + "mtu", + "enabled", + "description", + "librenms_id", + ] + attrs = { + "class": "table table-hover object-list", + "id": "librenms-interface-table", + } + + def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, server_key=None, **kwargs): + """Initialize table with device context and interface name field.""" + self.device = device + self.interface_name_field = interface_name_field or get_interface_name_field() + self.vlan_groups = vlan_groups or [] + self.server_key = server_key + + # Update column accessors after initialization + for column in ["selection", "name"]: + self.base_columns[column].accessor = self.interface_name_field + + # Set row attributes using interface_name_field + self._meta.row_attrs = { + "data-interface": lambda record: record.get(self.interface_name_field), + "data-name": lambda record: record.get(self.interface_name_field), + "data-enabled": lambda record: ( + str(record.get("ifAdminStatus")).lower() if record.get("ifAdminStatus") is not None else "" + ), + } + + super().__init__(*args, **kwargs) + self.tab = "interfaces" + self.htmx_url = None + self.prefix = "interfaces_" + + selection = ToggleColumn( + orderable=False, + visible=True, + attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}}, + ) + name = tables.Column(verbose_name="Name", attrs={"td": {"data-col": "name"}}) + type = tables.Column( + accessor="ifType", + verbose_name="Interface Type", + attrs={"td": {"data-col": "type"}}, + ) + speed = tables.Column(accessor="ifSpeed", verbose_name="Speed", attrs={"td": {"data-col": "speed"}}) + mac_address = tables.Column( + accessor="ifPhysAddress", + verbose_name="MAC Address", + attrs={"td": {"data-col": "mac_address"}}, + ) + mtu = tables.Column(accessor="ifMtu", verbose_name="MTU", attrs={"td": {"data-col": "mtu"}}) + enabled = BooleanColumn(verbose_name="Enabled", attrs={"td": {"data-col": "enabled"}}) + description = tables.Column( + accessor="ifAlias", + verbose_name="Description", + attrs={"td": {"data-col": "description"}}, + ) + librenms_id = tables.Column( + accessor="port_id", + verbose_name="LibreNMS ID", + attrs={"td": {"data-col": "librenms_id"}}, + ) + vlans = tables.Column( + verbose_name="VLANs", + empty_values=(), + orderable=False, + attrs={"td": {"data-col": "vlans"}}, + ) + + def render_vlans(self, value, record): + """ + Render VLANs column showing untagged and tagged VLANs. + Format: "100(U), 200(T), 300(T)" or "100(U)" for access ports. + + Color logic: + - Red + warning icon: VLAN not in any NetBox group (cannot sync) + - Red: Not present in NetBox (no VLAN assigned on interface) + - Orange: Mismatched (different untagged VLAN assigned) + - Green: Matching (VLAN matches NetBox assignment) + + Compact display: shows up to 3 VLANs inline, then summarizes. + An edit button opens the VLAN detail modal. + Hidden inputs store per-VLAN group assignments for form submission. + """ + untagged = record.get("untagged_vlan") + tagged = record.get("tagged_vlans", []) + missing_vlans = record.get("missing_vlans", []) + + # Get NetBox interface for comparison + exists_in_netbox = record.get("exists_in_netbox", False) + netbox_interface = record.get("netbox_interface") + + # Get NetBox VLAN assignments (VID + group for group-aware comparison) + netbox_untagged_vid = None + netbox_untagged_group_id = None + netbox_tagged_vids = set() + netbox_tagged_group_ids = {} + if netbox_interface: + if netbox_interface.untagged_vlan: + netbox_untagged_vid = netbox_interface.untagged_vlan.vid + netbox_untagged_group_id = netbox_interface.untagged_vlan.group_id + for v in netbox_interface.tagged_vlans.all(): + netbox_tagged_vids.add(v.vid) + netbox_tagged_group_ids[v.vid] = v.group_id + + all_vlans = [] + if untagged: + all_vlans.append(("U", untagged)) + for vid in sorted(tagged): + all_vlans.append(("T", vid)) + + if not all_vlans: + return mark_safe("β€”") + + interface_name = record.get(self.interface_name_field, "") + safe_name = interface_name.replace("/", "_").replace(":", "_") + + # Build compact colored summary (show up to 3 VLANs, summarize rest) + vlan_group_map = record.get("vlan_group_map", {}) + MAX_INLINE = 3 + inline_parts = [] + for vlan_type, vid in all_vlans[:MAX_INLINE]: + selected_gid = self._parse_group_id(vlan_group_map.get(vid, {}).get("group_id", "")) + group_matches = check_vlan_group_matches( + vlan_type, + vid, + selected_gid, + netbox_untagged_group_id, + netbox_tagged_group_ids, + netbox_untagged_vid, + netbox_tagged_vids, + ) + if vlan_type == "U": + css = get_untagged_vlan_css_class( + vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches + ) + else: + css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches) + warning = get_missing_vlan_warning(vid, missing_vlans) + inline_parts.append(f'{vid}({vlan_type}){warning}') + + summary = ", ".join(inline_parts) + if len(all_vlans) > MAX_INLINE: + extra = len(all_vlans) - MAX_INLINE + summary += f' +{extra} more' + + # Build tooltip showing auto-selected VLAN group per VLAN + tooltip_lines = [] + for vlan_type, vid in all_vlans: + if vid in missing_vlans: + tooltip_lines.append(f"VLAN {vid}({vlan_type}) β†’ ⚠ Not in NetBox") + else: + group_info = vlan_group_map.get(vid, {}) + group_name = group_info.get("group_name", "Global") + tooltip_lines.append(f"VLAN {vid}({vlan_type}) β†’ {escape(group_name)}") + tooltip_text = " ".join(tooltip_lines) + + # Build hidden inputs for per-VLAN group selections (submitted with form) + hidden_inputs = [] + for vlan_type, vid in all_vlans: + group_info = vlan_group_map.get(vid, {}) + group_id = group_info.get("group_id", "") + hidden_inputs.append( + format_html( + '', + safe_name, + vid, + group_id, + interface_name, + vid, + ) + ) + + # Build JSON data for modal (use proper json serialization for safety) + vlan_json_items = [] + for vlan_type, vid in all_vlans: + group_info = vlan_group_map.get(vid, {}) + is_missing = vid in missing_vlans + selected_gid = self._parse_group_id(group_info.get("group_id", "")) + group_matches = check_vlan_group_matches( + vlan_type, + vid, + selected_gid, + netbox_untagged_group_id, + netbox_tagged_group_ids, + netbox_untagged_vid, + netbox_tagged_vids, + ) + if vlan_type == "U": + css = get_untagged_vlan_css_class( + vid, netbox_untagged_vid, exists_in_netbox, missing_vlans, group_matches + ) + else: + css = get_tagged_vlan_css_class(vid, netbox_tagged_vids, exists_in_netbox, missing_vlans, group_matches) + display_group_name = "Not in NetBox" if is_missing else group_info.get("group_name", "Global") + vlan_json_items.append( + { + "vid": vid, + "type": vlan_type, + "group_id": group_info.get("group_id", ""), + "group_name": display_group_name, + "css": css, + "missing": is_missing, + } + ) + vlan_json = json_module.dumps(vlan_json_items) + + device_id = self.device.pk if self.device else "" + + # Build vlan_groups JSON for modal dropdowns + group_options = [{"id": "", "name": "-- No Group (Global) --", "scope": ""}] + for group in self.vlan_groups: + scope_info = str(group.scope) if hasattr(group, "scope") and group.scope else "" + group_options.append({"id": str(group.pk), "name": group.name, "scope": scope_info}) + + groups_json = json_module.dumps(group_options) + + # Escape JSON for safe embedding in HTML attributes + escaped_vlan_json = escape(vlan_json) + escaped_groups_json = escape(groups_json) + + edit_btn = format_html( + '', + interface_name, + safe_name, + device_id, + escaped_vlan_json, + escaped_groups_json, + ) + + hidden_inputs_html = mark_safe("".join(str(h) for h in hidden_inputs)) + + return format_html( + '{}{}{}', + mark_safe(tooltip_text), + mark_safe(summary), + edit_btn, + hidden_inputs_html, + ) + + @staticmethod + def _parse_group_id(group_id_str): + """Normalize a group ID string to int or None for comparison.""" + return int(group_id_str) if group_id_str else None + + def render_speed(self, value, record): + """Render interface speed with appropriate styling based on comparison with NetBox""" + kbps_value = convert_speed_to_kbps(value) + return self._render_field(humanize_speed(kbps_value), record, "ifSpeed", "speed") + + def render_name(self, value, record): + """Render interface name with appropriate styling based on comparison with NetBox""" + return self._render_field(value, record, self.interface_name_field, "name") + + def _get_interface_status_display(self, enabled, record): + """ + Determine interface status display and CSS class based on enabled state and NetBox comparison. + + Args: + enabled (bool): Interface enabled state. + record (dict): Interface data record. + + Returns: + tuple: (display_value, css_class) + """ + display_value = "Enabled" if enabled else "Disabled" + + if not record.get("exists_in_netbox"): + return display_value, "text-danger" + + netbox_interface = record.get("netbox_interface") + if netbox_interface: + netbox_enabled = netbox_interface.enabled + if enabled == netbox_enabled: + return display_value, "text-success" + return display_value, "text-warning" + + return display_value, "text-danger" + + def _parse_enabled_status(self, value): + """Convert interface status value to boolean enabled state""" + if isinstance(value, str): + return value.lower() == "up" + return bool(value) + + def render_enabled(self, value, record): + """Render interface enabled status with appropriate styling based on comparison with NetBox""" + enabled = self._parse_enabled_status(value) + display_value, css_class = self._get_interface_status_display(enabled, record) + return format_html('{}', css_class, display_value) + + def render_description(self, value, record): + """Render interface description with appropriate styling based on comparison with NetBox""" + return self._render_field(value, record, "ifAlias", "description") + + def render_mac_address(self, value, record): + """Render MAC address with appropriate styling based on comparison with NetBox""" + formatted_mac = format_mac_address(value) + return self._render_field(formatted_mac, record, "ifPhysAddress", "mac_address") + + def render_mtu(self, value, record): + """Render MTU with appropriate styling based on comparison with NetBox""" + return self._render_field(value, record, "ifMtu", "mtu") + + def render_librenms_id(self, value, record): + """Render the 'librenms_id' field with appropriate styling based on comparison with NetBox.""" + + if not record.get("exists_in_netbox"): + return mark_safe(f'{value}') + + netbox_interface = record.get("netbox_interface") + if not netbox_interface: + return mark_safe(f'{value}') + + netbox_librenms_id = get_librenms_device_id(netbox_interface, self.server_key, auto_save=False) + + if netbox_librenms_id is None: + return mark_safe( + f'{value}' + ) + + # Compare the IDs + if str(value) != str(netbox_librenms_id): + # IDs do not match + return mark_safe( + f'{value}' + ) + else: + # IDs match + return mark_safe(f'{value}') + + def _compare_mac_addresses(self, librenms_mac, netbox_interface): + """ + Compare LibreNMS MAC address against all MAC addresses on NetBox interface. + + Args: + librenms_mac (str): MAC address from LibreNMS. + netbox_interface (Interface): NetBox interface record. + + Returns: + True if MAC exists on interface. + """ + if not netbox_interface: + return False + + interface_macs = [mac.mac_address for mac in netbox_interface.mac_addresses.all()] + return librenms_mac in interface_macs + + def _render_field(self, value, record, librenms_key, netbox_key): + """Render a field value with appropriate styling based on the comparison with NetBox.""" + + if not record.get("exists_in_netbox"): + return mark_safe(f'{value}') + + netbox_interface = record.get("netbox_interface") + if not netbox_interface: + return mark_safe(f'{value}') + + if librenms_key == "ifPhysAddress": + mac_matches = self._compare_mac_addresses(value, netbox_interface) + css_class = "text-success" if mac_matches else "text-warning" + return mark_safe(f'{value}') + + netbox_value = getattr(netbox_interface, netbox_key, None) + librenms_value = record.get(librenms_key) + + if librenms_key == "ifSpeed": + librenms_value = convert_speed_to_kbps(librenms_value) + + if librenms_value != netbox_value: + return mark_safe(f'{value}') + + return mark_safe(f'{value}') + + def render_type(self, value, record): + """Render interface type with appropriate styling based on comparison with NetBox""" + speed = convert_speed_to_kbps(record.get("ifSpeed", 0)) + mapping = self.get_interface_mapping(value, speed) + tooltip_value, icon = self.render_mapping_tooltip(value, speed, mapping) + + combined_display = format_html("{} {}", tooltip_value, icon) + + if not record.get("exists_in_netbox"): + return format_html('{}', combined_display) + + netbox_interface = record.get("netbox_interface") + + if netbox_interface: + netbox_type = getattr(netbox_interface, "type", None) + if mapping and mapping.netbox_type == netbox_type: + return format_html('{}', combined_display) + elif mapping: + return format_html('{}', combined_display) + + return format_html('{}', combined_display) + + def get_interface_mapping(self, librenms_type, speed): + """Get interface type mapping based on type and speed""" + + # First try exact match with type and speed + mapping = InterfaceTypeMapping.objects.filter(librenms_type=librenms_type, librenms_speed=speed).first() + + # If no match found, fall back to type-only match + if not mapping: + mapping = InterfaceTypeMapping.objects.filter( + librenms_type=librenms_type, librenms_speed__isnull=True + ).first() + + return mapping + + def render_mapping_tooltip(self, value, speed, mapping): + """Render tooltip for interface type mapping""" + if mapping: + display = mapping.netbox_type + icon = format_html( + '', + value, + speed, + ) + else: + display = value + icon = mark_safe('') + return display, icon + + def format_interface_data(self, port_data, device): + """Format single interface data using table rendering logic""" + + # Add NetBox interface data + interface_name = port_data.get(self.interface_name_field) + + port_data["netbox_interface"] = device.interfaces.filter(name=interface_name).first() + port_data["exists_in_netbox"] = bool(port_data["netbox_interface"]) + + # Clear description if it matches interface name + if port_data["ifAlias"] == port_data["ifName"] or port_data["ifAlias"] == port_data["ifDescr"]: + port_data["ifAlias"] = "" + + formatted_data = { + "name": self.render_name(interface_name, port_data), + "type": self.render_type(port_data["ifType"], port_data), + "speed": self.render_speed(port_data["ifSpeed"], port_data), + "mac_address": self.render_mac_address(port_data["ifPhysAddress"], port_data), + "mtu": self.render_mtu(port_data["ifMtu"], port_data), + "enabled": self.render_enabled(port_data["ifAdminStatus"], port_data), + "description": self.render_description(port_data["ifAlias"], port_data), + } + + return formatted_data + + def configure(self, request): + """Configure the table with pagination and other options""" + paginate = { + "paginator_class": EnhancedPaginator, + "per_page": get_table_paginate_count(request, self.prefix), + } + + tables.RequestConfig(request, paginate).configure(self) + + +class VCInterfaceTable(LibreNMSInterfaceTable): + """ + Table for displaying Virtual Chassis interface data. + """ + + device_selection = tables.Column( + verbose_name="Virtual Chassis member", + accessor="device", + orderable=False, + empty_values=[], + attrs={"td": {"data-col": "device_selection"}}, + ) + + def __init__(self, *args, device=None, interface_name_field=None, vlan_groups=None, **kwargs): + """Initialize VC interface table with device and name field.""" + super().__init__( + *args, device=device, interface_name_field=interface_name_field, vlan_groups=vlan_groups, **kwargs + ) + # Ensure device_selection column is visible + if hasattr(self.device, "virtual_chassis") and self.device.virtual_chassis: + self.columns.show("device_selection") + # Update selection column accessor to match interface_name_field + self.base_columns["selection"].accessor = self.interface_name_field + + def render_device_selection(self, value, record): + """ + Renders a device selection dropdown for virtual chassis members. + Determines the selected member based on interface type and name. + Returns an HTML select element with appropriate member options. + """ + members = self.device.virtual_chassis.members.all() + if_type = record.get("ifType", "").lower() + interface_name = record.get(self.interface_name_field) + + if "ethernet" in if_type: + chassis_member = get_virtual_chassis_member(self.device, interface_name) + selected_member_id = chassis_member.id if chassis_member else self.device.id + else: + selected_member_id = self.device.id + + # Create unique base ID for TomSelect components + base_id = f"device_selection_{interface_name}_{hash(interface_name)}" + + options = [ + f'' + for member in members + ] + + return format_html( + '', + interface_name, + base_id, + mark_safe("".join(options)), + ) + + def format_interface_data(self, port_data, device): + """Format interface data including VC device selection column.""" + formatted_data = super().format_interface_data(port_data, device) + formatted_data["device_selection"] = self.render_device_selection(None, port_data) + return formatted_data + + class Meta: + """Meta options for VCInterfaceTable.""" + + sequence = [ + "selection", + "device_selection", + "name", + "type", + "speed", + "vlans", + "mac_address", + "mtu", + "enabled", + "description", + ] + attrs = { + "class": "table table-hover object-list", + "id": "librenms-interface-table", + } + + +class LibreNMSVMInterfaceTable(LibreNMSInterfaceTable): + """ + Table for displaying LibreNMS VM interface data. + """ + + class Meta(LibreNMSInterfaceTable.Meta): + """Meta options for LibreNMSVMInterfaceTable.""" + + sequence = [ + "selection", + "name", + "vlans", + "mac_address", + "mtu", + "enabled", + "description", + ] + attrs = { + "class": "table table-hover object-list", + "id": "librenms-interface-table-vm", + } + + # Remove the type and speed column for VMs + type = None + speed = None diff --git a/netbox_librenms_plugin/tables/ipaddresses.py b/netbox_librenms_plugin/tables/ipaddresses.py new file mode 100644 index 0000000..8c9e1bf --- /dev/null +++ b/netbox_librenms_plugin/tables/ipaddresses.py @@ -0,0 +1,122 @@ +import django_tables2 as tables +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 IPAddressTable(tables.Table): + """ + Table for displaying LibreNMS IP address data. + """ + + def __init__(self, *args, **kwargs): + """Initialize IP address table.""" + super().__init__(*args, **kwargs) + + class Meta: + """Meta options for IPAddressTable.""" + + sequence = [ + "selection", + "address", + "prefix_length", + "device", + "interface_name", + "vrf", + ] + attrs = { + "class": "table table-hover object-list", + "id": "librenms-ipaddress-table", + } + row_attrs = { + "data-interface": lambda record: record["ip_address"], + "data-name": lambda record: record["ip_address"], + } + + selection = ToggleColumn( + orderable=False, + visible=True, + attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}}, + accessor="ip_address", + ) + + address = tables.Column( + accessor="ip_address", + verbose_name="IP Address", + linkify=lambda record: record.get("ip_url"), + attrs={"td": {"data-col": "address"}}, + ) + prefix_length = tables.Column( + accessor="prefix_length", + verbose_name="Prefix Length", + attrs={"td": {"data-col": "prefix"}}, + ) + device = tables.Column( + linkify=lambda record: record.get("device_url"), + attrs={"td": {"data-col": "device"}}, + ) + interface_name = tables.Column( + accessor="interface_name", + verbose_name="Interface", + linkify=lambda record: record.get("interface_url"), + attrs={"td": {"data-col": "interface"}}, + ) + vrf = tables.TemplateColumn( + template_code=""" + + """, + attrs={"td": {"data-col": "vrf"}}, + verbose_name="VRF", + ) + status = tables.Column( + verbose_name="Status", + attrs={"td": {"data-col": "status"}}, + ) + + def render_status(self, value, record): + """Render the status column with appropriate buttons or text styling""" + if value == "update": + return format_html( + '', + record["ip_address"], + ) + elif value == "matched": + return mark_safe(' Synced') + elif record.get("interface_url"): + return format_html( + '', + record["ip_address"], + ) + return mark_safe('Missing NetBox Object') + + def render_device(self, value, record): + """Render the device column with a link if available""" + if url := record.get("device_url"): + return format_html('{}', url, value) + return value + + def render_interface_name(self, value, record): + """Render the interface column with a link if available""" + if url := record.get("interface_url"): + return format_html('{}', url, value) + return value + + def configure(self, request): + """Configure the table""" + paginate = { + "paginator_class": EnhancedPaginator, + "per_page": get_table_paginate_count(request, self.prefix), + } + + tables.RequestConfig(request, paginate).configure(self) diff --git a/netbox_librenms_plugin/tables/locations.py b/netbox_librenms_plugin/tables/locations.py new file mode 100644 index 0000000..b37a873 --- /dev/null +++ b/netbox_librenms_plugin/tables/locations.py @@ -0,0 +1,84 @@ +import django_tables2 as tables +from django.middleware.csrf import get_token +from django.utils.html import format_html +from django.utils.safestring import mark_safe +from utilities.paginator import EnhancedPaginator, get_paginate_count + + +class SiteLocationSyncTable(tables.Table): + """ + Table for displaying Netbox Site and Librenms Location data. + """ + + netbox_site = tables.Column(linkify=True) + latitude = tables.Column(accessor="netbox_site__latitude") + longitude = tables.Column(accessor="netbox_site__longitude") + librenms_location = tables.Column(accessor="librenms_location__location", verbose_name="LibreNMS Location") + librenms_latitude = tables.Column(accessor="librenms_location__lat", verbose_name="LibreNMS Latitude") + librenms_longitude = tables.Column(accessor="librenms_location__lng", verbose_name="LibreNMS Longitude") + actions = tables.Column(empty_values=()) + + def render_latitude(self, value, record): + """Render latitude with sync-status styling.""" + return self.render_coordinate(value, record.is_synced) + + def render_longitude(self, value, record): + """Render longitude with sync-status styling.""" + return self.render_coordinate(value, record.is_synced) + + def render_coordinate(self, value, is_synced): + """Render coordinate with success or danger text color.""" + css_class = "text-success" if is_synced else "text-danger" + return format_html('{}', css_class, value) + + def render_actions(self, record): + """Render action buttons with styles based on sync status or action.""" + csrf_token = get_token(self.request) + if record.is_synced: + return mark_safe( + ' Synced' + ) + if record.librenms_location: + return mark_safe( + f'
' + f'' + f'' + f'' + '" + "
" + ) + else: + return mark_safe( + f'
' + f'' + f'' + f'' + '" + "
" + ) + + def configure(self, request): + """Configure the table with pagination and custom attributes.""" + paginate = { + "paginator_class": EnhancedPaginator, + "per_page": get_paginate_count(request), + } + tables.RequestConfig(request, paginate).configure(self) + + class Meta: + """Meta options for SiteLocationSyncTable.""" + + fields = ( + "netbox_site", + "latitude", + "longitude", + "librenms_location", + "librenms_latitude", + "librenms_longitude", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} diff --git a/netbox_librenms_plugin/tables/mappings.py b/netbox_librenms_plugin/tables/mappings.py new file mode 100644 index 0000000..73949fd --- /dev/null +++ b/netbox_librenms_plugin/tables/mappings.py @@ -0,0 +1,38 @@ +import django_tables2 as tables +from netbox.tables import NetBoxTable, columns + +from netbox_librenms_plugin.models import InterfaceTypeMapping + + +class InterfaceTypeMappingTable(NetBoxTable): + """ + Table for displaying InterfaceTypeMapping data. + """ + + librenms_type = tables.Column(verbose_name="LibreNMS Type") + librenms_speed = tables.Column(verbose_name="LibreNMS Speed (Kbps)") + netbox_type = tables.Column(verbose_name="NetBox Type") + description = tables.Column(verbose_name="Description", linkify=False) + actions = columns.ActionsColumn(actions=("edit", "delete")) + + class Meta: + """Meta options for InterfaceTypeMappingTable.""" + + model = InterfaceTypeMapping + fields = ( + "id", + "librenms_type", + "librenms_speed", + "netbox_type", + "description", + "actions", + ) + default_columns = ( + "id", + "librenms_type", + "librenms_speed", + "netbox_type", + "description", + "actions", + ) + attrs = {"class": "table table-hover table-headings table-striped"} diff --git a/netbox_librenms_plugin/tables/vlans.py b/netbox_librenms_plugin/tables/vlans.py new file mode 100644 index 0000000..1b078b8 --- /dev/null +++ b/netbox_librenms_plugin/tables/vlans.py @@ -0,0 +1,183 @@ +import django_tables2 as tables +from django.utils.html import format_html, format_html_join +from django.utils.safestring import mark_safe +from netbox.tables.columns import ToggleColumn +from utilities.paginator import EnhancedPaginator + +from netbox_librenms_plugin.constants import LIBRENMS_VLAN_STATE_ACTIVE +from netbox_librenms_plugin.utils import get_table_paginate_count, get_vlan_sync_css_class + + +class LibreNMSVLANTable(tables.Table): + """ + Table for displaying LibreNMS VLAN data for a device. + Shows VLANs configured on the device and their sync status with NetBox. + Includes per-row VLAN group selection dropdown. + """ + + class Meta: + sequence = [ + "selection", + "vlan_id", + "name", + "vlan_group_selection", + "type", + "state", + ] + attrs = { + "class": "table table-hover object-list", + "id": "librenms-vlan-table", + } + row_attrs = { + "data-vlan-id": lambda record: record.get("vlan_id"), + } + + def __init__(self, *args, vlan_groups=None, **kwargs): + super().__init__(*args, **kwargs) + self.prefix = "vlans_" + self.vlan_groups = vlan_groups or [] + + selection = ToggleColumn( + orderable=False, + visible=True, + attrs={"td": {"data-col": "selection"}, "input": {"name": "select"}}, + accessor="vlan_id", + ) + + vlan_id = tables.Column( + accessor="vlan_id", + verbose_name="VLAN ID", + attrs={"td": {"data-col": "vlan_id"}}, + ) + + name = tables.Column( + accessor="name", + verbose_name="Name", + attrs={"td": {"data-col": "name"}}, + ) + + vlan_group_selection = tables.Column( + verbose_name="VLAN Group", + empty_values=(), + orderable=False, + attrs={"td": {"data-col": "vlan_group_selection"}}, + ) + + type = tables.Column( + accessor="type", + verbose_name="Type", + attrs={"td": {"data-col": "type"}}, + ) + + state = tables.Column( + accessor="state", + verbose_name="State", + attrs={"td": {"data-col": "state"}}, + ) + + def render_vlan_id(self, value, record): + """Render VLAN ID with color based on sync status.""" + css_class = get_vlan_sync_css_class( + record.get("exists_in_netbox", False), + record.get("name_matches", True), + ) + return format_html('{}', css_class, value) + + def render_name(self, value, record): + """Render VLAN name with color based on sync status.""" + css_class = get_vlan_sync_css_class( + record.get("exists_in_netbox", False), + record.get("name_matches", True), + ) + + # Add tooltip on name mismatch + if record.get("exists_in_netbox") and not record.get("name_matches", True): + netbox_name = record.get("netbox_vlan_name", "") + tooltip = f"NetBox: {netbox_name} | LibreNMS: {value}" + return format_html( + '{}', + css_class, + tooltip, + value or "", + ) + + return format_html('{}', css_class, value or "") + + def render_vlan_group_selection(self, value, record): + """ + Render per-row VLAN group dropdown. + + Auto-selects based on matching priority: + 1. Existing NetBox VLAN's group (if exists_in_netbox) + 2. Unique VID match (if VID exists in exactly one group) + 3. No selection (with warning icon if ambiguous) + """ + vlan_id = record.get("vlan_id") + + # Determine which group to auto-select + selected_group_id = None + + # Priority 1: Existing NetBox VLAN group + if record.get("exists_in_netbox") and record.get("netbox_vlan_group_id"): + selected_group_id = record["netbox_vlan_group_id"] + elif record.get("auto_selected_group_id"): + # Priority 2: unique VID match + selected_group_id = record["auto_selected_group_id"] + + # Build the select element using format_html_join to prevent XSS + options_html = format_html_join( + "", + '', + [ + ( + "", + "", + "", + "-- No Group (Global) --", + "", + ), + ] + + [ + ( + group.pk, + group.scope_id if group.scope_id else "", + " selected" if group.pk == selected_group_id else "", + group.name, + f" ({group.scope})" if group.scope else "", + ) + for group in self.vlan_groups + ], + ) + + select_html = format_html( + '', + vlan_id, + vlan_id, + record.get("name", ""), + options_html, + ) + + # Add warning icon if ambiguous (VID exists in multiple groups at same priority level) + if record.get("is_ambiguous") and not record.get("exists_in_netbox"): + warning_html = mark_safe( + '' + ) + return format_html("{}{}", select_html, warning_html) + + return select_html + + def render_state(self, value, record): + """Render VLAN state (active/inactive).""" + if value == LIBRENMS_VLAN_STATE_ACTIVE or value == "active": + return mark_safe('Active') + return mark_safe('Inactive') + + def configure(self, request): + """Configure the table with pagination.""" + paginate = { + "paginator_class": EnhancedPaginator, + "per_page": get_table_paginate_count(request, self.prefix), + } + tables.RequestConfig(request, paginate).configure(self) diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync.html new file mode 100644 index 0000000..1408f65 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync.html @@ -0,0 +1,28 @@ +{% load helpers %} +{% load static %} + + +
+

Cable 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/_cable_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html new file mode 100644 index 0000000..4856763 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_cable_sync_content.html @@ -0,0 +1,118 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + + +{% if cable_sync.table %} +
+ {% csrf_token %} + {% if cable_sync.server_key %}{% endif %} + +
+
+ + + info + +
+ {% if cable_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ +
+
+
+ +
+
+
+ + Filters apply to currently displayed cables. + +
+
+ {% if cable_sync.table.attrs.id == "librenms-cable-table-vc" %} + + {% endif %} + + + +
+
+ +
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=cable_sync.table %} + {% include 'inc/table.html' with table=cable_sync.table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=cable_sync.table %} +
+
+
+
+{% else %} +
+
+ +

No cable data loaded. Click Refresh Cables to fetch data from LibreNMS.

+
+
+{% endif %} + + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html new file mode 100644 index 0000000..fa1d3c0 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync.html @@ -0,0 +1,37 @@ +{% load helpers %} +{% load static %} + + +
+

Interface Sync

+
+
+ {% csrf_token %} + {% if has_librenms_id %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + {% elif model_name == "virtualmachine" %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ + + +
+ {% include 'netbox_librenms_plugin/_interface_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html new file mode 100644 index 0000000..c8a51bd --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_interface_sync_content.html @@ -0,0 +1,358 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + + +{% if interface_sync.table %} + +{% with model_name=interface_sync.object|meta:"model_name" %} +
+ {% endwith %} + {% csrf_token %} + {% if interface_sync.server_key %}{% endif %} + {% block table_actions %} +
+
+ + + info + +
+
+
+
Exclude from Sync:
+
+ Type + +
+
+ Speed + +
+
+ VLANs + +
+
+ MAC + +
+
+ MTU + +
+
+ Enabled + +
+
+ Description + +
+
+
+ + +
+ {% endblock %} +
+
+
+
+ {% if interface_sync.object.virtual_chassis %} + + {% endif %} + {% if interface_sync.netbox_only_interfaces %} + + + {{interface_sync.netbox_only_interfaces|length}} NetBox only interfaces + + {% endif %} +
+
+ {% if interface_sync.cache_expiry %} +
+ Cache expires in: +
+ + {% endif %} +
+ Matching values + Mismatched values + Not present in NetBox +
+
+ +
+
+
+ + Filters apply to currently displayed interfaces. Adjust the + "per page" setting to apply the filters to more interfaces. + +
+
+ + {% if interface_sync.table.attrs.id == 'librenms-interface-table' %} + + + {% endif %} + + + + +
+
+ + +
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=interface_sync.table %} + {% include 'inc/table.html' with table=interface_sync.table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=interface_sync.table %} +
+
+
+ + + +
+{% else %} +
+
+ +

No interface data loaded. Click Refresh Interfaces to fetch data from LibreNMS.

+
+
+{% endif %} + + + + + + + + + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html new file mode 100644 index 0000000..2205369 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync.html @@ -0,0 +1,37 @@ +{% load helpers %} +{% load static %} + + +
+

IP Address Sync

+
+
+ {% csrf_token %} + {% if has_librenms_id %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + {% elif model_name == "virtualmachine" %} + + {% endif %} + {% endwith %} + {% endif %} +
+
+
+ + + +
+ {% include 'netbox_librenms_plugin/_ipaddress_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html new file mode 100644 index 0000000..e73bb83 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_ipaddress_sync_content.html @@ -0,0 +1,84 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + + +{% if ip_sync.table %} +{% with model_name=ip_sync.object|meta:"model_name" %} +
+{% endwith %} + {% csrf_token %} + {% if ip_sync.server_key %}{% endif %} + +
+
+ +
+ {% if ip_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ +
+
+
+ +
+
+
+ + Filters apply to currently displayed IP addresses. + +
+
+ + + + +
+
+ +
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=ip_sync.table %} + {% include 'inc/table.html' with table=ip_sync.table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=ip_sync.table %} +
+
+
+
+{% else %} +
+
+ +

No IP address data loaded. Click Refresh IP Addresses to fetch data from LibreNMS.

+
+
+{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync.html new file mode 100644 index 0000000..ecf4177 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync.html @@ -0,0 +1,30 @@ +{% load helpers %} +{% load static %} + + +
+

VLAN 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/_vlan_sync_content.html' %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync_content.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync_content.html new file mode 100644 index 0000000..8221f29 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/_vlan_sync_content.html @@ -0,0 +1,63 @@ +{% load helpers %} +{% include 'inc/messages.html' %} + +{% if vlan_sync.error_message %} + +
+ {{ vlan_sync.error_message }} +
+{% elif not vlan_sync.vlan_table or not vlan_sync.vlan_table.rows %} + +
+
+ +

No VLAN data loaded. Click Refresh VLANs to fetch data from LibreNMS.

+
+
+{% else %} + +{% with model_name=vlan_sync.object|meta:"model_name" %} +
+{% endwith %} + {% csrf_token %} + {% if vlan_sync.server_key %}{% endif %} + + +
+
+ + + Select a VLAN Group for each row, or leave empty for global VLANs + +
+
+ {% if vlan_sync.cache_expiry %} +
+ Cache expires in: +
+ {% endif %} +
+ Matching values + Mismatched values + Not present in NetBox +
+
+
+ +
+
+
+ {% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %} + {% include 'inc/table.html' with table=vlan_sync.vlan_table %} + {% include 'netbox_librenms_plugin/inc/paginator.html' with table=vlan_sync.vlan_table %} +
+
+
+
+ +{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html new file mode 100644 index 0000000..a83ff7c --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/bulk_import_confirm.html @@ -0,0 +1,212 @@ +{# Confirmation modal content for bulk imports #} + + + {% endwith %} + {% endfor %} + + + + + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html new file mode 100644 index 0000000..76aac95 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_import_row.html @@ -0,0 +1,22 @@ +{% load render_table from django_tables2 %} +{% if table.rows %} + {% for row in table.rows %} + + {% for column, cell in row.items %} + {{ cell }} + {% endfor %} + + {% endfor %} +{% else %} + + +
+ ERROR: No table rows found for device {{ record.device_id }} +
+ + +{% endif %} +{# Consume and clear any pending Django messages to prevent reappearing toasts #} +
+{% for _ in messages %}{% endfor %} +
diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html new file mode 100644 index 0000000..dc69bcf --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_validation_details.html @@ -0,0 +1,625 @@ +{# HTMX template for device validation details modal #} +{# Redesigned to match the sync page's clean table layout #} + + + + + + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_vc_details.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_vc_details.html new file mode 100644 index 0000000..fc37141 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/htmx/device_vc_details.html @@ -0,0 +1,121 @@ +{# HTMX template for virtual chassis details modal #} +{# Shows virtual chassis/stack information for a LibreNMS device #} + + + + + + diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/paginator.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/paginator.html new file mode 100644 index 0000000..306e356 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/inc/paginator.html @@ -0,0 +1,59 @@ +{% load i18n %} + + +{% if table.page %} + {% with page_param=table.prefix|stringformat:"s"|add:"page" %} +
+ {% if table.paginator.num_pages > 1 %} + + {% endif %} + + + {% blocktrans trimmed with start=table.page.start_index end=table.page.end_index total=table.paginator.count %} + Showing {{ start }}-{{ end }} of {{ total }} + {% endblocktrans %} + + + +
+ {% endwith %} +{% endif %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html new file mode 100644 index 0000000..119e4e1 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping.html @@ -0,0 +1,30 @@ +{% extends 'generic/object.html' %} +{% load helpers %} +{% load plugins %} + +{% block content %} +
+
+
+ + + + + + + + + + + + + + + + + +
LibreNMS TypeLibreNMS Speed (Kbps)NetBox TypeDescription
{{ object.librenms_type }}{{ object.librenms_speed }}{{ object.get_netbox_type_display }}{{ object.description|default:"β€”" }}
+
+
+
+{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html new file mode 100644 index 0000000..3044c4d --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/interfacetypemapping_list.html @@ -0,0 +1,12 @@ +{% extends 'generic/object_list.html' %} + +{% block content %} +
+

Interface Type Mapping

+

This section allows you to map LibreNMS interface types to NetBox interface types. + When synchronizing interfaces from LibreNMS, these mappings will be used to ensure + correct interface type assignment in NetBox.

+

Example: Map LibreNMS type "ethernetCsmacd" to NetBox type "1000base-t"

+
+ {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html new file mode 100644 index 0000000..d1bdbd5 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_import.html @@ -0,0 +1,438 @@ +{% extends 'generic/_base.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} +{% load render_table from django_tables2 %} +{% load static %} +{% load i18n %} + +{% block title %}{{ title }}{% endblock %} + +{% block javascript %} +{{ block.super }} + +{% endblock %} + + + +{% block controls %} +
+ {% plugin_list_buttons model %} + {% action_buttons actions model %} +
+{% endblock controls %} + +{% block content %} + + {# Display Django messages #} + {% include 'inc/messages.html' %} + + {# LibreNMS Server Information #} + {% if librenms_server_info %} +
+
+
+
+ + Active LibreNMS Server: + {% if librenms_server_info.is_legacy %} + {{ librenms_server_info.url }} + {% else %} + {{ librenms_server_info.display_name }} ({{ librenms_server_info.url }}) + {% endif %} +
+ {% if not librenms_server_info.is_legacy %} + + Change Server + + {% endif %} +
+
+
+ {% endif %} + + {# Collapsible Filter Section #} + {% if filter_form %} +
+
+
+ Search Filters + {% if filter_form.changed_data %} + {% badge filter_form.changed_data|length bg_color="primary" %} + {% endif %} +
+ +
+
+
+
+ {# Instructions column #} +
+
Search Instructions
+

+ At least one filter is required to search for devices. +

+ +

+ The following matching rules apply: +

    +
  • Location/Type/OS: Exact match
  • +
  • Hostname: Partial match
  • +
  • System Name: Exact match (or partial when combined with other filters)
  • +
+ +
+
    + Performance note:
  • Results are cached (default: 5 minutes).
  • +
  • Large LibreNMS datasets take time to process
  • +
  • Repeating the same filter search will use cached data if available.
  • +
  • Using background jobs is default and recommended.
  • +
  • Background jobs can be cancelled if needed.
  • +
+
+ +

+ Tip: Start with Location and/or Type to narrow results, then refine with additional filters. +

+
+ {# Filters column #} +
+
+ + {% if show_filter_warning %} + + {% endif %} +
+
+ + {{ filter_form.librenms_location }} +
+
+ + {{ filter_form.librenms_type }} +
+
+
+
+ + {{ filter_form.librenms_os }} + {% if filter_form.librenms_os.help_text %} + {{ filter_form.librenms_os.help_text }} + {% endif %} +
+
+ + {{ filter_form.librenms_hostname }} + {% if filter_form.librenms_hostname.help_text %} + {{ filter_form.librenms_hostname.help_text }} + {% endif %} +
+
+
+
+ + {{ filter_form.librenms_sysname }} + {% if filter_form.librenms_sysname.help_text %} + {{ filter_form.librenms_sysname.help_text }} + {% endif %} +
+
+ + {{ filter_form.librenms_hardware }} + {% if filter_form.librenms_hardware.help_text %} + {{ filter_form.librenms_hardware.help_text }} + {% endif %} +
+
+
+
+
+ {{ filter_form.exclude_existing }} + + {% if filter_form.exclude_existing.help_text %} + {{ filter_form.exclude_existing.help_text }} + {% endif %} +
+
+
+
+ {{ filter_form.show_disabled }} + + {% if filter_form.show_disabled.help_text %} + {{ filter_form.show_disabled.help_text }} + {% endif %} +
+
+
+
+
+
+ {{ filter_form.enable_vc_detection }} + + {% if filter_form.enable_vc_detection.help_text %} + {{ filter_form.enable_vc_detection.help_text }} + {% endif %} +
+
+
+
+ {{ filter_form.clear_cache }} + + {% if filter_form.clear_cache.help_text %} + {{ filter_form.clear_cache.help_text }} + {% endif %} +
+
+
+
+
+ {% if can_use_background_jobs %} + {{ filter_form.use_background_job }} + + {% if filter_form.use_background_job.help_text %} + {{ filter_form.use_background_job.help_text }} + {% endif %} + {% else %} + + + + Background jobs require superuser access. Filters will process synchronously. + + {% endif %} +
+
+
+ + + Clear + +
+
+
+
+
+
+
+ {% endif %} + + {# Cached Searches Section #} + {% if cached_searches %} +
+
+
+ Active Cached Searches + {% badge cached_searches|length bg_color="primary" %} +
+ +
+ +
+ {% endif %} + + {# Applied filters #} + {% if filter_form %} + {% applied_filters model filter_form request.GET %} + {% endif %} + + + + {# Results Section #} + {% if table.page.paginator.count == 0 %} +
+
+ No Results Found +
+

+ No devices found matching your filters. Try adjusting your search criteria above. +

+
+ {% else %} + {# Bulk import toolbar #} +
+
+ + + + 0 devices selected + +
+
+ {# Cache expiration info - before Settings section #} + {% if filters_submitted and table.rows %} + {% if cache_metadata_missing %} +
+ + Cache status: unavailable +
+ | + {% elif cache_timestamp and cache_timeout %} +
+ + Cache expires in {{ cache_timeout }}s +
+ | + {% endif %} + {% endif %} + {# Import Settings #} +
+ Settings: +
+ + +
+
+ + +
+
+ +
+
+ {% endif %} + +
+ {% csrf_token %} + + + + {# Objects table #} +
+
+ {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %} +
+ {% include 'inc/table.html' %} +
+ {% include 'inc/paginator.html' with htmx=True table=table paginator=table.paginator page=table.page %} +
+
+ {# /Objects table #} + +
+ + {# HTMX Modal for import actions #} + + + {# Filter Processing Modal #} + + +{% 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 new file mode 100644 index 0000000..3378b51 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/librenms_sync_base.html @@ -0,0 +1,1018 @@ +{% extends 'generic/object.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} +{% load static %} +{% load i18n %} +{% load render_table from django_tables2 %} + +{% block javascript %} +{{ block.super }} + +{% endblock %} + +{% block breadcrumbs %} +{{ block.super }} +{% with model_name=object|meta:"model_name" %} +{% if model_name == "device" %} + +{% elif model_name == "virtualmachine" %} + +{% endif %} +{% endwith %} +{% endblock %} + +{% block content %} + + +{% if all_server_mappings %} +
+
+ LibreNMS Connections + {% if librenms_server_info and not librenms_server_info.is_legacy %} + + Change Server + + {% endif %} +
+
+ + + {% for mapping in all_server_mappings %} + + + + + + {% endfor %} + +
+ {% if mapping.is_active %} + + {% elif mapping.is_configured %} + + {% else %} + + {% endif %} + {% if mapping.is_configured %} + {{ mapping.display_name }} + {% else %} + {{ mapping.server_key }} + Not configured + {% endif %} + + {% if mapping.device_url %} + + ID {{ mapping.device_id }} + + + {% else %} + ID {{ mapping.device_id }} + {% endif %} + + {% if not mapping.is_configured %} + {% if lookup_device_model_name == "device" or lookup_device_model_name == "virtualmachine" %} +
+ {% csrf_token %} + + + +
+ {% endif %} + {% endif %} +
+
+
+{% elif librenms_server_info %} +
+
+
+
+ + Active LibreNMS Server: + {% if librenms_server_info.is_legacy %} + {{ librenms_server_info.url }} + {% else %} + {{ librenms_server_info.display_name }} ({{ librenms_server_info.url }}) + {% endif %} +
+ {% if not librenms_server_info.is_legacy %} + + Change Server + + {% endif %} +
+
+
+{% endif %} + + +{% if not is_vc_member or object.pk == librenms_sync_device.pk %} + +
+ +
+
+
+ LibreNMS Status + {% if mismatched_device %} + + Mismatch found + + {% endif %} +
+
+ + + + + + + + + {% if found_in_librenms %} + + + + + + + + + + + + + + + + + + + + {% if librenms_device_serial and librenms_device_serial != "-" %} + + + + + {% endif %} + + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + + + + + {% elif model_name == "virtualmachine" %} + + + + + + + + + + + + + + + + + + {% endif %} + {% endwith %} + + + + + + + {% else %} + + + + + + {% endif %} + +
Status + + {% if found_in_librenms %} + Found + {% else %} + Not found + {% endif %} + +
ID + {{ librenms_device_id }} + {% if librenms_id_is_legacy %} +
+ {% csrf_token %} + + {% if librenms_id_serial_confirmed %} + + {% else %} + + {% endif %} +
+ {% endif %} +
Hostname + {% if librenms_device_hostname and librenms_device_hostname != "-" %} + {{ librenms_device_hostname }} + {% else %} + - + {% endif %} +
sysName + {% if sysName %} + {{ sysName }} + {% else %} + - + {% endif %} +
+ Serial + {% if object.serial and librenms_device_serial != object.serial %} + + + + {% endif %} + + {{ librenms_device_serial }} + {% if object.serial and librenms_device_serial == object.serial %} + + + + {% endif %} +
OS Version + {% if platform_info.librenms_version and platform_info.librenms_version != "-" %} + {{ platform_info.librenms_version }} + {% else %} + - + {% endif %} +
OS + {% if platform_info.librenms_os and platform_info.librenms_os != "-" %} + {{ platform_info.librenms_os }} + {% else %} + - + {% endif %} +
Version + {% if platform_info.librenms_version and platform_info.librenms_version != "-" %} + {{ platform_info.librenms_version }} + {% else %} + - + {% endif %} +
Hardware + {% if librenms_device_hardware and librenms_device_hardware != "-" %} + {{ librenms_device_hardware }} + {% else %} + - + {% endif %} +
Features + {% if librenms_device_features and librenms_device_features != "-" %} + {{ librenms_device_features }} + {% else %} + - + {% endif %} +
LibreNMS Link + + View + +
Action + +
+
+
+
+ + {% if found_in_librenms %} + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + +
+
+
Device Information Sync
+
+ + + + + + + + + + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {% endif %} + {% endwith %} + +
FieldNetBox ValueLibreNMS Value
Name + + +
+
{{ object.name }}
+
+ {% if resolved_name and resolved_name != object.name %} +
+ {% csrf_token %} + +
+ {% elif resolved_name %} + + + + {% endif %} +
+
+
{{ sysName|default:"β€”" }}
Device Type +
+
+ {% if object.device_type %} + {{ object.device_type }} + {% else %} + Not set + {% endif %} +
+
+ {% if librenms_device_hardware_match.matched %} + {% if not object.device_type or object.device_type.pk != librenms_device_hardware_match.device_type.pk %} +
+ {% csrf_token %} + +
+ {% else %} + + + + {% endif %} + {% endif %} +
+
+
+ {{ librenms_device_hardware }} + {% if not librenms_device_hardware_match.matched and librenms_device_hardware != "-" %} + + DeviceTypes + + {% endif %} +
+ Serial Number + +
+
+ {% if object.serial %} + {{ object.serial }} + {% else %} + Not set + {% endif %} +
+
+ {% if object.virtual_chassis and vc_inventory_serials %} + + {% else %} + {% if librenms_device_serial != "-" %} + {% if object.serial != librenms_device_serial %} +
+ {% csrf_token %} + +
+ {% else %} + + + + {% endif %} + {% endif %} + {% endif %} +
+
+
+ {{ librenms_device_serial }} +
Platform +
+
+ {% if platform_info.netbox_platform %} + + {{ platform_info.netbox_platform }} + + {% else %} + Not set + {% endif %} +
+
+ {% if platform_info.librenms_os and platform_info.librenms_os != "-" %} + {% if platform_info.platform_exists %} + {% if not platform_info.netbox_platform or platform_info.netbox_platform.pk != platform_info.matching_platform.pk %} +
+ {% csrf_token %} + +
+ {% else %} + + + + {% endif %} + {% else %} + + {% endif %} + {% endif %} +
+
+
+
+ {{ platform_info.librenms_os }} + {% if not platform_info.platform_exists and platform_info.librenms_os and platform_info.librenms_os != "-" %} + + Not in NetBox + + {% endif %} +
+
Location{{ object.site.name }} +
+
{{ librenms_device_location }}
+
+ {% if librenms_device_location != object.site.name %} +
+ {% csrf_token %} + +
+ {% else %} + + + + {% endif %} +
+
+
+
+
+
+ {% endif %} + {% endwith %} + {% endif %} +
+{% endif %} + + +{% if last_fetched %} +Last data updated: {{ last_fetched|date:"Y-m-d H:i" }} +{% endif %} + +{% if is_vc_member and not object.cf.librenms_id %} + + {% if sync_device_has_librenms_id %} + +
+ {% if librenms_sync_device %} + + LibreNMS sync for this virtual chassis is managed by {{ librenms_sync_device }} + {% else %} + LibreNMS sync for this virtual chassis is managed by another member + {% endif %} +
+ {% elif sync_device_has_primary_ip %} + +
+ {% if librenms_sync_device %} + + LibreNMS sync for this virtual chassis should be configured on {{ librenms_sync_device }} + {% else %} + LibreNMS sync for this virtual chassis should be configured on the designated sync device + {% endif %} +
+ {% else %} + +
+ {% if librenms_sync_device %} + + Virtual chassis sync device {{ librenms_sync_device }} requires either a primary IP or a LibreNMS ID (custom field) for sync operations + {% else %} + Virtual chassis requires at least one member with a primary IP or LibreNMS ID configured for sync operations + {% endif %} +
+ {% endif %} +{% elif librenms_device_id %} +{% if found_in_librenms %} + + +
+ +
+ + + + + +
+
+ +
+
+ {% include 'netbox_librenms_plugin/_interface_sync.html' %} +
+ +
+ {% include 'netbox_librenms_plugin/_cable_sync.html' %} +
+ +
+ {% include 'netbox_librenms_plugin/_ipaddress_sync.html' %} +
+ + {% with model_name=object|meta:"model_name" %} + {% if model_name == "device" %} +
+ {% include 'netbox_librenms_plugin/_vlan_sync.html' %} +
+ {% endif %} + {% endwith %} +
+ + +{% else %} +
+
+ +
+ Device not found: + Device has custom field librenms_id set to {{ librenms_device_id }} but was not found in LibreNMS. +
+ + Options to resolve: +
    +
  • Remove the custom field value to enable automatic matching. The plugin will attempt to find the device using: +
      +
    • Primary IP address
    • +
    • Primary IP DNS name (FQDN)
    • +
    • Device name
    • +
    +
  • +
  • Update the custom field with the correct LibreNMS device ID
  • +
  • Verify the device exists in LibreNMS at the configured server
  • +
+
+
+
+ +
+{% endif %} +{% else %} +
+ Device not found in LibreNMS. To match a device, use one of these methods: + +
+{% endif %} + + + + + + +{% if mismatched_device %} + + +{% endif %} + + + + + +{% if object.virtual_chassis and vc_inventory_serials %} + + +{% endif %} + +{% if not platform_info.platform_exists and platform_info.platform_name and found_in_librenms %} + + +{% endif %} + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html new file mode 100644 index 0000000..557eacc --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/settings.html @@ -0,0 +1,339 @@ +{% extends 'generic/object_edit.html' %} +{% load form_helpers %} +{% block title %}Plugin Settings{% endblock %} + +{% block tabs %} + +{% endblock tabs %} + +{% block content %} +
+ +
+
+ {% csrf_token %} + + +
+
+
+
+

+ + LibreNMS Server Settings +

+
+
+
+

+ Configure which LibreNMS server to use for synchronization operations. + Multiple servers can be configured in the NetBox configuration file. +

+ {% render_field server_form.selected_server %} +
+
+ +
+
+
+
+ +
+
+
+

+ + Connection Test +

+
+
+
+

Test the connection to the selected LibreNMS server to verify configuration.

+ + +
+
+
+
+
+
+ + +
+ + +
+
+
+
+

+ + Configuration Example +

+
+
+

+ To configure multiple LibreNMS servers, update your NetBox + configuration.py: +

+
+
PLUGINS_CONFIG = {
+    'netbox_librenms_plugin': {
+        'servers': {
+            'production': {
+                'display_name': 'Production LibreNMS',
+                'librenms_url': 'https://librenms-prod.example.com',
+                'api_token': 'your_production_token',
+                'cache_timeout': 300,
+                'verify_ssl': True,
+                'interface_name_field': 'ifDescr'
+            },
+            'testing': {
+                'display_name': 'Test LibreNMS',
+                'librenms_url': 'https://librenms-test.example.com',
+                'api_token': 'your_test_token',
+                'cache_timeout': 300,
+                'verify_ssl': False,
+                'interface_name_field': 'ifName'
+            }
+        }
+    }
+}
+
+
+ + Note: For backward compatibility, the legacy single-server configuration + is still supported if no "servers" configuration is provided. +
+
+
+
+
+
+
+ + +
+
+ {% csrf_token %} + + +
+ +
+ +
+
+

+ + Device Naming Defaults +

+
+
+

+ Configure default naming preferences for imported devices. +

+
+ + User preferences: These defaults apply to users who have not yet changed their own toggle settings on the import page. Once a user changes a toggle, their personal preference is saved and takes priority over these defaults. + Saving these settings will also update your own preferences to match. +
+ +
+
+
+
+ {{ import_form.use_sysname_default }} + + {% if import_form.use_sysname_default.help_text %} + {{ import_form.use_sysname_default.help_text }} + {% endif %} +
+
+ +
+
+ {{ import_form.strip_domain_default }} + + {% if import_form.strip_domain_default.help_text %} + {{ import_form.strip_domain_default.help_text }} + {% endif %} +
+
+ +
+
+
+
+ + +
+
+

+ + Virtual Chassis Member Naming +

+
+
+

+ Configure how virtual chassis member devices are named during import. +

+ +
+
+
+ + {{ import_form.vc_member_name_pattern }} + {% if import_form.vc_member_name_pattern.errors %} +
+ {{ import_form.vc_member_name_pattern.errors }} +
+ {% endif %} +
+
+
+ +
+
+
+

Available placeholders:

+
    +
  • {position} - VC position number
  • +
  • {serial} - Member serial number
  • +
+

Note: The pattern is appended to the master device name. At least one placeholder is required to ensure each member gets a unique name.

+
+
+
+ +
+
+

Examples:

+
    +
  • -M{position} β†’ switch01-M1, switch01-M2
  • +
  • ({position}) β†’ switch01 (1), switch01 (2)
  • +
  • -SW{position} β†’ switch01-SW1, switch01-SW2
  • +
  • [{serial}] β†’ switch01 [ABC123], switch01 [ABC124]
  • +
+
+
+
+
+
+
+ + +
+
+
+ +
+
+
+
+
+
+ + +
+ + +{% endblock %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/site_location_sync.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/site_location_sync.html new file mode 100644 index 0000000..a7a4513 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/site_location_sync.html @@ -0,0 +1,51 @@ +{% extends 'base/layout.html' %} +{% load buttons %} +{% load helpers %} +{% load plugins %} +{% load static %} +{% load i18n %} +{% load render_table from django_tables2 %} + +{% block header %} +
+
+

Site and Location Sync

+

This page displays the synchronization status between NetBox sites and LibreNMS locations. It allows you to update or create locations in LibreNMS based on NetBox data.

+

Note: Only LibreNMS locations that match a Netbox site are shown on this page.

+
+
+
+
+ +
+ + Clear +
+
+
+
+
+{% endblock %} + + +{% block content %} + +
+
+ {% csrf_token %} +
+
+
+ {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} + {% render_table table 'inc/table.html' %} + {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} +
+
+
+
+
+{% endblock content %} diff --git a/netbox_librenms_plugin/templates/netbox_librenms_plugin/status_check.html b/netbox_librenms_plugin/templates/netbox_librenms_plugin/status_check.html new file mode 100644 index 0000000..09bd240 --- /dev/null +++ b/netbox_librenms_plugin/templates/netbox_librenms_plugin/status_check.html @@ -0,0 +1,15 @@ +{% extends 'generic/object_list.html' %} +{% load helpers %} + + +{% block title %}LibreNMS Status Check{% endblock %} + +{% block content %} + + {% if table.page.paginator.count == 0 %} +
+ Select filters to check device status in LibreNMS. Click the status to view device LibreNMS sync details. +
+ {% endif %} + {{ block.super }} +{% endblock %} diff --git a/netbox_librenms_plugin/tests/__init__.py b/netbox_librenms_plugin/tests/__init__.py new file mode 100644 index 0000000..ba2191c --- /dev/null +++ b/netbox_librenms_plugin/tests/__init__.py @@ -0,0 +1 @@ +"""Unit test package for netbox_librenms_plugin.""" diff --git a/netbox_librenms_plugin/tests/conftest.py b/netbox_librenms_plugin/tests/conftest.py new file mode 100644 index 0000000..a6bba48 --- /dev/null +++ b/netbox_librenms_plugin/tests/conftest.py @@ -0,0 +1,336 @@ +"""Shared pytest fixtures for NetBox LibreNMS Plugin tests.""" + +from unittest.mock import MagicMock, patch + +import pytest + +# ============================================================================= +# Configuration Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_multi_server_config(): + """Multi-server configuration dict.""" + return { + "default": { + "librenms_url": "https://librenms-default.example.com", + "api_token": "default-token-12345", + "cache_timeout": 300, + "verify_ssl": True, + }, + "secondary": { + "librenms_url": "https://librenms-secondary.example.com", + "api_token": "secondary-token-67890", + "cache_timeout": 600, + "verify_ssl": False, + }, + } + + +@pytest.fixture +def mock_legacy_config(): + """Legacy single-server configuration dict (flat structure).""" + return { + "librenms_url": "https://librenms.example.com", + "api_token": "legacy-token-abcdef", + "cache_timeout": 300, + "verify_ssl": True, + } + + +# ============================================================================= +# API Instance Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_librenms_api(mock_multi_server_config): + """Pre-configured LibreNMSAPI instance with mocked dependencies.""" + with patch("netbox_librenms_plugin.librenms_api.get_plugin_config") as mock_config: + mock_config.return_value = mock_multi_server_config + from netbox_librenms_plugin.librenms_api import LibreNMSAPI + + api = LibreNMSAPI(server_key="default") + yield api + + +# ============================================================================= +# NetBox Object Mocks (Avoid Database) +# ============================================================================= + + +@pytest.fixture +def mock_netbox_device(): + """Mock NetBox Device object without database.""" + device = MagicMock() + device.name = "test-device" + device.cf = {} # Custom fields + device.primary_ip4 = MagicMock() + device.primary_ip4.address = MagicMock() + device.primary_ip4.address.ip = "192.168.1.1" + device.primary_ip4.__str__ = lambda self: "192.168.1.1/24" + device.primary_ip6 = None + device._meta.model_name = "device" + return device + + +@pytest.fixture +def mock_netbox_vm(): + """Mock NetBox VirtualMachine object without database.""" + vm = MagicMock() + vm.name = "test-vm" + vm.cf = {} + vm.primary_ip4 = MagicMock() + vm.primary_ip4.address = MagicMock() + vm.primary_ip4.address.ip = "10.0.0.1" + vm.primary_ip6 = None + vm._meta.model_name = "virtualmachine" + return vm + + +# ============================================================================= +# HTTP Response Fixtures +# ============================================================================= + + +@pytest.fixture +def mock_response_factory(): + """Factory for creating mock HTTP responses.""" + + def _create_response(status_code=200, json_data=None, raise_for_status=None): + response = MagicMock() + response.status_code = status_code + response.json.return_value = json_data or {} + response.ok = 200 <= status_code < 300 + if raise_for_status: + response.raise_for_status.side_effect = raise_for_status + return response + + return _create_response + + +@pytest.fixture +def mock_success_response(mock_response_factory): + """Standard successful API response.""" + return mock_response_factory(status_code=200, json_data={"status": "ok", "message": "Success"}) + + +@pytest.fixture +def mock_device_response(mock_response_factory): + """Mock response for device info endpoint.""" + return mock_response_factory( + status_code=200, + json_data={ + "status": "ok", + "devices": [ + { + "device_id": 42, + "hostname": "test-device.example.com", + "sysName": "test-device", + "ip": "192.168.1.1", + "status": 1, + "location": "Data Center 1", + } + ], + }, + ) + + +@pytest.fixture +def mock_error_response(mock_response_factory): + """Standard error API response.""" + return mock_response_factory( + status_code=500, + json_data={"status": "error", "message": "Internal server error"}, + ) + + +@pytest.fixture +def mock_auth_error_response(mock_response_factory): + """Authentication error response (401).""" + return mock_response_factory(status_code=401, json_data={"status": "error", "message": "Unauthorized"}) + + +# ============================================================================= +# Phase 2: Import Utilities Fixtures +# ============================================================================= + + +@pytest.fixture +def sample_librenms_device(): + """Sample LibreNMS device data for import tests.""" + return { + "device_id": 1, + "hostname": "switch-01.example.com", + "sysName": "switch-01", + "ip": "192.168.1.1", + "location": "DC1", + "os": "ios", + "hardware": "C9300-48P", + "version": "17.3.1", + "status": 1, + } + + +@pytest.fixture +def sample_librenms_device_minimal(): + """Minimal LibreNMS device data with missing fields.""" + return { + "device_id": 2, + "hostname": "10.0.0.1", + "status": 1, + } + + +@pytest.fixture +def sample_validation_state(): + """Sample validation state for testing updates.""" + return { + "device_id": 1, + "hostname": "switch-01", + "is_ready": False, + "can_import": False, + "import_as_vm": False, + "existing_device": None, + "issues": ["Device role must be manually selected before import"], + "warnings": [], + "site": { + "found": True, + "site": MagicMock(id=1, name="DC1"), + "match_type": "exact", + }, + "device_type": { + "found": True, + "device_type": MagicMock(id=1, model="C9300-48P"), + "match_type": "exact", + }, + "device_role": {"found": False, "role": None, "available_roles": []}, + "cluster": {"found": False, "cluster": None, "available_clusters": []}, + "platform": { + "found": True, + "platform": MagicMock(id=1, name="ios"), + "match_type": "exact", + }, + } + + +@pytest.fixture +def sample_validation_state_vm(): + """Sample validation state for VM import testing.""" + return { + "device_id": 1, + "hostname": "vm-01", + "is_ready": False, + "can_import": False, + "import_as_vm": True, + "existing_device": None, + "issues": ["Cluster must be manually selected before import"], + "warnings": [], + "cluster": {"found": False, "cluster": None, "available_clusters": []}, + "device_role": {"found": False, "role": None, "available_roles": []}, + } + + +@pytest.fixture +def mock_netbox_site(): + """Mock NetBox Site object.""" + site = MagicMock() + site.id = 1 + site.name = "DC1" + site.slug = "dc1" + return site + + +@pytest.fixture +def mock_netbox_platform(): + """Mock NetBox Platform object.""" + platform = MagicMock() + platform.id = 1 + platform.name = "Cisco IOS" + platform.slug = "cisco_ios" + return platform + + +@pytest.fixture +def mock_netbox_device_type(): + """Mock NetBox DeviceType object.""" + dt = MagicMock() + dt.id = 1 + dt.model = "C9300-48P" + dt.manufacturer = MagicMock(name="Cisco") + return dt + + +@pytest.fixture +def mock_netbox_device_role(): + """Mock NetBox DeviceRole object.""" + role = MagicMock() + role.id = 1 + role.name = "Access Switch" + role.slug = "access-switch" + return role + + +@pytest.fixture +def mock_netbox_cluster(): + """Mock NetBox Cluster object.""" + cluster = MagicMock() + cluster.id = 1 + cluster.name = "VMware Cluster 1" + return cluster + + +@pytest.fixture +def mock_netbox_rack(): + """Mock NetBox Rack object.""" + rack = MagicMock() + rack.id = 1 + rack.name = "Rack A1" + rack.site = MagicMock(id=1, name="DC1") + return rack + + +# ============================================================================= +# Server Mapping Fixtures (used by test_sync_view_mismatch.py) +# ============================================================================= + + +@pytest.fixture +def mock_plugins_config_single_server(): + """PLUGINS_CONFIG with a single 'production' server (for _build_all_server_mappings tests).""" + return { + "netbox_librenms_plugin": { + "servers": { + "production": { + "display_name": "Production LibreNMS", + "librenms_url": "https://librenms.example.com", + }, + } + } + } + + +@pytest.fixture +def mock_plugins_config_empty_servers(): + """PLUGINS_CONFIG with no configured servers (simulates all orphaned).""" + return {"netbox_librenms_plugin": {"servers": {}}} + + +@pytest.fixture +def mock_plugins_config_multi_server_mapping(): + """PLUGINS_CONFIG with 'production' and 'mock-dev' servers (for multi-server mapping tests).""" + return { + "netbox_librenms_plugin": { + "servers": { + "production": { + "display_name": "Production LibreNMS", + "librenms_url": "https://librenms.example.com", + }, + "mock-dev": { + "display_name": "Mock", + "librenms_url": "http://mock.example.com", + }, + } + } + } diff --git a/netbox_librenms_plugin/tests/mock_librenms_server.py b/netbox_librenms_plugin/tests/mock_librenms_server.py new file mode 100644 index 0000000..83a9258 --- /dev/null +++ b/netbox_librenms_plugin/tests/mock_librenms_server.py @@ -0,0 +1,262 @@ +""" +Minimal HTTP mock for LibreNMS API responses. + +Usage in tests (add to conftest.py or inline): + + from netbox_librenms_plugin.tests.mock_librenms_server import librenms_mock_server + + @pytest.fixture + def librenms_server(): + with librenms_mock_server() as server: + yield server +""" + +import json +import threading +from contextlib import contextmanager +from http.server import BaseHTTPRequestHandler, HTTPServer +from urllib.parse import parse_qs, urlparse + + +class _LibreNMSHandler(BaseHTTPRequestHandler): + """Request handler that dispatches to registered route responses.""" + + def log_message(self, format, *args): # noqa: A002 + pass # Suppress request logs in tests + + def _send_json(self, status, body): + data = json.dumps(body).encode() + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(data))) + self.end_headers() + self.wfile.write(data) + + def _handle_request(self, method, body=None): + """Dispatch to the registered route for this path, with optional method+query fallback.""" + parsed = urlparse(self.path) + path = parsed.path + query = parsed.query + routes = self.server.routes # type: ignore[attr-defined] + + # Build lookup keys: prefer method+path+query, then path+query, then path-only. + candidates = [] + if query: + candidates.append(f"{method} {path}?{query}") + candidates.append(f"{path}?{query}") + candidates.append(f"{method} {path}") + candidates.append(path) + + for key in candidates: + if key in routes: + entry = routes[key] + if callable(entry): + status, resp_body = entry( + method=method, + path=path, + query=parse_qs(query), + headers=dict(self.headers), + body=body, + ) + else: + status, resp_body = entry + self._send_json(status, resp_body) + return + + self._send_json(404, {"status": "error", "message": f"No mock for {self.path}"}) + + def do_GET(self): + self._handle_request("GET") + + def do_POST(self): + length = int(self.headers.get("Content-Length", 0)) + raw_body = self.rfile.read(length) if length else b"" + try: + body = json.loads(raw_body) if raw_body else None + except json.JSONDecodeError: + body = raw_body.decode(errors="replace") + self._handle_request("POST", body=body) + + +class MockLibreNMSServer: + """ + Context-manager wrapper around a simple HTTP mock server. + + Attributes: + url (str): Base URL for the mock server (e.g. "http://127.0.0.1:PORT"). + routes (dict): Mapping of URL path β†’ (status_code, body_dict) or callable. + Callable routes receive keyword arguments: method, path, query, headers, body + and must return (status_code, body_dict). + Routes can also be keyed as "METHOD /path" for method-specific matching, + or "/path?query" for query-specific matching. + """ + + def __init__(self): + self._server = HTTPServer(("127.0.0.1", 0), _LibreNMSHandler) + self._server.routes = {} + self.routes = self._server.routes # expose on wrapper as documented + self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) + _, port = self._server.server_address + self.url = f"http://127.0.0.1:{port}" + + def register(self, path: str, body, status: int = 200, method: str | None = None): + """ + Register a mock response for a URL path. + + If *method* is given the route is stored as ``"METHOD /path"`` and only + matches requests using that HTTP verb. Omit *method* (or pass ``None``) + to match any verb on that path. + + *body* may be a ``dict`` (serialised to JSON) or a callable. When a + callable is provided it is stored directly and invoked by the handler on + each matching request; the *status* argument is ignored in that case. + """ + key = f"{method} {path}" if method else path + if callable(body): + self._server.routes[key] = body + else: + self._server.routes[key] = (status, body) + + def start(self): + self._thread.start() + return self + + def stop(self): + self._server.shutdown() + self._server.server_close() + self._thread.join(timeout=5) + if self._thread.is_alive(): + import warnings + + warnings.warn( + f"MockLibreNMSServer thread {self._thread.ident} did not exit within 5 s; " + "socket may not be fully released", + ResourceWarning, + stacklevel=2, + ) + + # ------- default LibreNMS-shaped responses ------- + + def add_device_response(self, device_id: int = 1, hostname: str = "test-host"): + self.register( + "/api/v0/devices", + {"status": "ok", "id": device_id, "hostname": hostname}, + method="POST", + ) + + def device_info_response( + self, + device_id: int = 1, + hostname: str = "test-host", + hardware: str = "WS-C3560X-24T-S", + os: str = "ios", + serial: str = "SN123", + ip: str = "192.168.1.1", + version: str = "15.2(4)E7", + features: str = "-", + location: str = "-", + ): + self.register( + f"/api/v0/devices/{device_id}", + { + "status": "ok", + "devices": [ + { + "device_id": device_id, + "hostname": hostname, + "hardware": hardware, + "os": os, + "serial": serial, + "sysName": hostname, + "ip": ip, + "version": version, + "features": features, + "location": location, + } + ], + }, + ) + + def ports_response(self, device_id: int = 1, ports=None): + if ports is None: + ports = [ + { + "port_id": 101, + "ifName": "GigabitEthernet0/1", + "ifDescr": "GigabitEthernet0/1", + "ifType": "ethernetCsmacd", + "ifSpeed": 1_000_000_000, + "ifAdminStatus": "up", + "ifAlias": "uplink", + "ifPhysAddress": "aa:bb:cc:dd:ee:01", + "ifMtu": 1500, + "ifVlan": 1, + "ifTrunk": 0, + } + ] + self.register(f"/api/v0/devices/{device_id}/ports", {"status": "ok", "ports": ports}) + + def auth_error_response(self, path="/api/v0/devices"): + self.register(path, {"status": "error", "message": "Authentication failed"}, status=401) + + def inventory_response(self, device_id: int, items: list, status: int = 200): + """Register a plain inventory response for /api/v0/inventory/{device_id}/all.""" + payload_status = "ok" if 200 <= status < 300 else "error" + payload = ( + {"status": payload_status, "inventory": items} if payload_status == "ok" else {"status": payload_status} + ) + self.register( + f"/api/v0/inventory/{device_id}/all", + payload, + status=status, + method="GET", + ) + + def vc_inventory_callable(self, device_id: int, root_items: list, children_by_parent_index: dict): + """ + Register a callable route for VC detection two-call pattern. + + detect_virtual_chassis_from_inventory() calls get_inventory_filtered() twice: + 1. entPhysicalContainedIn=0 β†’ root items + 2. entPhysicalClass=chassis&entPhysicalContainedIn= β†’ member chassis items + + children_by_parent_index: dict mapping parent index (int) β†’ list of chassis items + """ + root = root_items + children = children_by_parent_index + + def _handler(method, path, query, headers, body): + contained_in = query.get("entPhysicalContainedIn", [None])[0] + if contained_in == "0": + return 200, {"status": "ok", "inventory": root} + if contained_in is not None: + # Require entPhysicalClass=chassis for child queries so tests catch + # any regression where the production code stops sending the class filter. + phy_class = query.get("entPhysicalClass", [None])[0] + if phy_class != "chassis": + return 200, {"status": "ok", "inventory": []} + try: + idx = int(contained_in) + except (TypeError, ValueError): + return 404, {"status": "error", "message": "bad contained_in"} + items = children.get(idx, []) + return 200, {"status": "ok", "inventory": items} + # No filter β†’ return all (fallback for /all) + all_items = list(root) + for v in children.values(): + all_items.extend(v) + return 200, {"status": "ok", "inventory": all_items} + + self.register(f"/api/v0/inventory/{device_id}", _handler, method="GET") + self.register(f"/api/v0/inventory/{device_id}/all", _handler, method="GET") + + +@contextmanager +def librenms_mock_server(): + """Context manager that starts and stops a MockLibreNMSServer.""" + server = MockLibreNMSServer() + server.start() + try: + yield server + finally: + server.stop() diff --git a/netbox_librenms_plugin/tests/test_background_jobs.py b/netbox_librenms_plugin/tests/test_background_jobs.py new file mode 100644 index 0000000..f1d1bd2 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_background_jobs.py @@ -0,0 +1,967 @@ +""" +Tests for background job implementation. + +Tests the FilterDevicesJob, ImportDevicesJob, should_use_background_job logic, +job result loading, and graceful fallback behavior. + +Refactored to use pure pytest without Django database dependencies. +All tests use mocking and direct attribute manipulation instead of HTTP requests. +""" + +from unittest.mock import MagicMock, patch + + +class TestShouldUseBackgroundJob: + """Test background job decision logic.""" + + def test_checkbox_checked_returns_true(self): + """When use_background_job form field is True, return True for superusers.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = LibreNMSImportView() + view._filter_form_data = {"use_background_job": True} + view.request = MagicMock() + view.request.user.is_superuser = True + + assert view.should_use_background_job() is True + + def test_checkbox_unchecked_returns_false(self): + """When use_background_job form field is False, return False.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = LibreNMSImportView() + view._filter_form_data = {"use_background_job": False} + view.request = MagicMock() + view.request.user.is_superuser = True + + assert view.should_use_background_job() is False + + def test_default_when_field_missing(self): + """When field is missing, default to True for superusers.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = LibreNMSImportView() + view._filter_form_data = {"some_other_field": "value"} + view.request = MagicMock() + view.request.user.is_superuser = True + + assert view.should_use_background_job() is True + + def test_empty_form_data_returns_default(self): + """Empty form data returns default True for superusers.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = LibreNMSImportView() + view._filter_form_data = {} + view.request = MagicMock() + view.request.user.is_superuser = True + + assert view.should_use_background_job() is True + + def test_non_superuser_always_returns_false(self): + """Non-superuser users always get synchronous mode.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + view = LibreNMSImportView() + view._filter_form_data = {"use_background_job": True} + view.request = MagicMock() + view.request.user.is_superuser = False + + # Even when checkbox is True, non-superusers get False + assert view.should_use_background_job() is False + + +def create_mock_job_runner(job_class, job_pk=123): + """Create a mock job runner instance without invoking real __init__.""" + # Create instance without calling __init__ + job = object.__new__(job_class) + # Set up required attributes + job.job = MagicMock() + job.job.pk = job_pk + job.job.data = {} + job.logger = MagicMock() + return job + + +class TestFilterDevicesJob: + """Test FilterDevicesJob background job.""" + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_processes_filters_successfully(self, mock_process, mock_api_class): + """Job runs and processes filters correctly.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + # Setup mocks + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api.server_key = "default" + mock_api_class.return_value = mock_api + + validated_devices = [ + {"device_id": 1, "hostname": "test1", "_validation": {}}, + {"device_id": 2, "hostname": "test2", "_validation": {}}, + ] + mock_process.return_value = validated_devices + + # Create job instance without calling real __init__ + job = create_mock_job_runner(FilterDevicesJob) + + # Run job + filters = {"location": "site1"} + job.run( + filters=filters, + vc_detection_enabled=True, + clear_cache=False, + show_disabled=False, + ) + + # Verify process_device_filters was called with correct args + mock_process.assert_called_once() + call_kwargs = mock_process.call_args.kwargs + assert call_kwargs["filters"] == filters + assert call_kwargs["vc_detection_enabled"] is True + assert call_kwargs["clear_cache"] is False + assert call_kwargs["job"] == job + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_with_vc_detection_enabled(self, mock_process, mock_api_class): + """vc_detection_enabled=True passed to processor.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api_class.return_value = mock_api + mock_process.return_value = [] + + job = create_mock_job_runner(FilterDevicesJob) + + job.run( + filters={}, + vc_detection_enabled=True, + clear_cache=False, + show_disabled=False, + ) + + call_kwargs = mock_process.call_args.kwargs + assert call_kwargs["vc_detection_enabled"] is True + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_with_clear_cache(self, mock_process, mock_api_class): + """clear_cache=True triggers cache refresh.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api_class.return_value = mock_api + mock_process.return_value = [] + + job = create_mock_job_runner(FilterDevicesJob) + + job.run( + filters={}, + vc_detection_enabled=False, + clear_cache=True, + show_disabled=False, + ) + + call_kwargs = mock_process.call_args.kwargs + assert call_kwargs["clear_cache"] is True + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_with_show_disabled(self, mock_process, mock_api_class): + """show_disabled=True includes disabled devices.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api_class.return_value = mock_api + mock_process.return_value = [] + + job = create_mock_job_runner(FilterDevicesJob) + + job.run( + filters={}, + vc_detection_enabled=False, + clear_cache=False, + show_disabled=True, + ) + + call_kwargs = mock_process.call_args.kwargs + assert call_kwargs["show_disabled"] is True + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_with_exclude_existing(self, mock_process, mock_api_class): + """exclude_existing=True filters out NetBox devices.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api_class.return_value = mock_api + mock_process.return_value = [] + + job = create_mock_job_runner(FilterDevicesJob) + + job.run( + filters={}, + vc_detection_enabled=False, + clear_cache=False, + show_disabled=False, + exclude_existing=True, + ) + + call_kwargs = mock_process.call_args.kwargs + assert call_kwargs["exclude_existing"] is True + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_with_custom_server_key(self, mock_process, mock_api_class): + """Non-default server_key used for API.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api.server_key = "secondary" + mock_api_class.return_value = mock_api + mock_process.return_value = [{"device_id": 1, "hostname": "test1"}] + + job = create_mock_job_runner(FilterDevicesJob) + + job.run( + filters={}, + vc_detection_enabled=False, + clear_cache=False, + show_disabled=False, + server_key="secondary", + ) + + # Verify API was initialized with correct server_key + mock_api_class.assert_called_once_with(server_key="secondary") + # Verify server_key stored in job data + assert job.job.data["server_key"] == "secondary" + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_stores_job_data_correctly(self, mock_process, mock_api_class): + """Job stores expected data structure.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api.server_key = "secondary" + mock_api_class.return_value = mock_api + + mock_process.return_value = [ + {"device_id": 1, "hostname": "test1"}, + {"device_id": 2, "hostname": "test2"}, + ] + + job = create_mock_job_runner(FilterDevicesJob, job_pk=456) + + job.run( + filters={"location": "dc1"}, + vc_detection_enabled=True, + clear_cache=False, + show_disabled=False, + server_key="secondary", + ) + + # Verify job.data structure + assert job.job.data["device_ids"] == [1, 2] + assert job.job.data["total_processed"] == 2 + assert job.job.data["filters"] == {"location": "dc1"} + assert job.job.data["server_key"] == "secondary" + assert job.job.data["vc_detection_enabled"] is True + assert job.job.data["cache_timeout"] == 300 + assert "cached_at" in job.job.data + assert job.job.data["completed"] is True + job.job.save.assert_called() + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_handles_empty_results(self, mock_process, mock_api_class): + """Empty filter results handled gracefully.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api_class.return_value = mock_api + + mock_process.return_value = [] + + job = create_mock_job_runner(FilterDevicesJob, job_pk=789) + + job.run( + filters={"location": "nonexistent"}, + vc_detection_enabled=False, + clear_cache=False, + show_disabled=False, + ) + + # Verify job data shows zero devices + assert job.job.data["device_ids"] == [] + assert job.job.data["total_processed"] == 0 + assert job.job.data["completed"] is True + + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + @patch("netbox_librenms_plugin.import_utils.process_device_filters") + def test_run_logs_progress(self, mock_process, mock_api_class): + """Logger called with expected messages.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + mock_api = MagicMock() + mock_api.cache_timeout = 300 + mock_api_class.return_value = mock_api + mock_process.return_value = [{"device_id": 1, "hostname": "test1"}] + + job = create_mock_job_runner(FilterDevicesJob) + + job.run( + filters={"location": "site1"}, + vc_detection_enabled=True, + clear_cache=False, + show_disabled=False, + ) + + # Verify logger was called with expected messages + assert job.logger.info.call_count >= 3 + info_calls = [call[0][0] for call in job.logger.info.call_args_list] + assert any("Starting" in msg for msg in info_calls) + assert any("completed" in msg.lower() for msg in info_calls) + + def test_job_meta_name(self): + """Job has correct Meta.name.""" + from netbox_librenms_plugin.jobs import FilterDevicesJob + + assert FilterDevicesJob.Meta.name == "LibreNMS Device Filter" + + +class TestImportDevicesJob: + """Test ImportDevicesJob background job.""" + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_device_only_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Import devices without VMs.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + # Mock successful device imports + mock_device_1 = MagicMock() + mock_device_1.pk = 100 + mock_device_2 = MagicMock() + mock_device_2.pk = 101 + + mock_bulk_devices.return_value = { + "success": [ + {"device": mock_device_1, "device_id": 1}, + {"device": mock_device_2, "device_id": 2}, + ], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=789) + + job.run( + device_ids=[1, 2], + vm_imports={}, + server_key="default", + sync_options={"sync_interfaces": True}, + ) + + # Verify device import was called + mock_bulk_devices.assert_called_once() + # VM import should not be called with empty dict + mock_bulk_vms.assert_not_called() + + # Verify job.data + assert job.job.data["imported_device_pks"] == [100, 101] + assert job.job.data["imported_vm_pks"] == [] + assert job.job.data["success_count"] == 2 + assert job.job.data["failed_count"] == 0 + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_vm_only_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Import VMs without devices.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + # Mock successful VM imports + mock_vm_1 = MagicMock() + mock_vm_1.pk = 200 + mock_vm_2 = MagicMock() + mock_vm_2.pk = 201 + + mock_bulk_vms.return_value = { + "success": [ + {"device": mock_vm_1, "device_id": 10}, + {"device": mock_vm_2, "device_id": 11}, + ], + "failed": [], + "skipped": [], + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=790) + + job.run( + device_ids=[], + vm_imports={10: {"cluster_id": 1}, 11: {"cluster_id": 1}}, + server_key="default", + ) + + # Verify device import was not called with empty list + mock_bulk_devices.assert_not_called() + # VM import should be called + mock_bulk_vms.assert_called_once() + + # Verify job.data + assert job.job.data["imported_device_pks"] == [] + assert job.job.data["imported_vm_pks"] == [200, 201] + assert job.job.data["success_count"] == 2 + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_mixed_device_and_vm_import(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Import both devices and VMs.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api = MagicMock() + mock_api.server_key = "non-default" + mock_api_class.return_value = mock_api + + # Mock device imports + mock_device = MagicMock() + mock_device.pk = 100 + + mock_bulk_devices.return_value = { + "success": [{"device": mock_device, "device_id": 1}], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + "cancelled": False, + } + + # Mock VM imports + mock_vm = MagicMock() + mock_vm.pk = 200 + + mock_bulk_vms.return_value = { + "success": [{"device": mock_vm, "device_id": 10}], + "failed": [], + "skipped": [], + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=791) + + job.run( + device_ids=[1], + vm_imports={10: {"cluster_id": 1}}, + server_key="non-default", + ) + + # Both should be called + mock_bulk_devices.assert_called_once() + mock_bulk_vms.assert_called_once() + + # Verify server_key (via api.server_key) is forwarded to bulk_import_devices_shared + bulk_devices_kwargs = mock_bulk_devices.call_args[1] + assert bulk_devices_kwargs.get("server_key") == "non-default" + + # Verify bulk_import_vms received the api with the correct server_key + bulk_vms_positional = mock_bulk_vms.call_args[0] + assert bulk_vms_positional[1].server_key == "non-default" + + # Verify combined results + assert job.job.data["imported_device_pks"] == [100] + assert job.job.data["imported_vm_pks"] == [200] + assert job.job.data["success_count"] == 2 + assert job.job.data["total"] == 2 + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_with_sync_options(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Sync options passed to bulk import.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + mock_bulk_devices.return_value = { + "success": [], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=792) + + sync_options = { + "sync_interfaces": True, + "sync_cables": False, + "sync_ips": True, + "use_sysname": True, + "strip_domain": True, + } + + job.run( + device_ids=[1], + vm_imports={}, + server_key="default", + sync_options=sync_options, + ) + + # Verify sync_options passed to bulk_import_devices_shared + call_kwargs = mock_bulk_devices.call_args.kwargs + assert call_kwargs["sync_options"] == sync_options + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_with_manual_mappings(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Manual mappings passed correctly.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + mock_bulk_devices.return_value = { + "success": [], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=793) + + manual_mappings = { + 1: {"site_id": 10, "device_role_id": 5}, + 2: {"site_id": 11, "device_role_id": 6}, + } + + job.run( + device_ids=[1, 2], + vm_imports={}, + manual_mappings_per_device=manual_mappings, + ) + + # Verify manual_mappings passed to bulk_import_devices_shared + call_kwargs = mock_bulk_devices.call_args.kwargs + assert call_kwargs["manual_mappings_per_device"] == manual_mappings + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_stores_imported_pks(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Imported device/VM PKs stored in job.data.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + mock_device = MagicMock() + mock_device.pk = 100 + + mock_bulk_devices.return_value = { + "success": [{"device": mock_device, "device_id": 1}], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=794) + + job.run(device_ids=[1], vm_imports={}) + + assert 100 in job.job.data["imported_device_pks"] + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_stores_libre_device_ids(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """LibreNMS device IDs stored for re-render.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + mock_device = MagicMock() + mock_device.pk = 100 + + mock_bulk_devices.return_value = { + "success": [{"device": mock_device, "device_id": 42}], + "failed": [], + "skipped": [], + "virtual_chassis_created": 0, + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=795) + + job.run(device_ids=[42], vm_imports={}) + + assert 42 in job.job.data["imported_libre_device_ids"] + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_aggregates_errors(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """Device and VM errors are combined in job.data.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + # Mock mixed results + mock_bulk_devices.return_value = { + "success": [], + "failed": [{"device_id": 1, "error": "Device type not found"}], + "skipped": [], + "virtual_chassis_created": 0, + } + mock_bulk_vms.return_value = { + "success": [], + "failed": [{"device_id": 10, "error": "Cluster not specified"}], + "skipped": [], + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=999) + + job.run( + device_ids=[1], + vm_imports={10: {"cluster": None}}, + ) + + # Verify errors aggregated + assert len(job.job.data["errors"]) == 2 + assert job.job.data["failed_count"] == 2 + assert job.job.data["success_count"] == 0 + + @patch("netbox_librenms_plugin.import_utils.bulk_import_vms") + @patch("netbox_librenms_plugin.import_utils.bulk_import_devices_shared") + @patch("netbox_librenms_plugin.librenms_api.LibreNMSAPI") + def test_run_handles_all_failures(self, mock_api_class, mock_bulk_devices, mock_bulk_vms): + """All imports fail gracefully.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + mock_api_class.return_value = MagicMock() + + mock_bulk_devices.return_value = { + "success": [], + "failed": [ + {"device_id": 1, "error": "Error 1"}, + {"device_id": 2, "error": "Error 2"}, + ], + "skipped": [], + "virtual_chassis_created": 0, + } + + job = create_mock_job_runner(ImportDevicesJob, job_pk=800) + + job.run(device_ids=[1, 2], vm_imports={}) + + # Should complete without exception + assert job.job.data["success_count"] == 0 + assert job.job.data["failed_count"] == 2 + assert job.job.data["completed"] is True + job.job.save.assert_called() + + def test_job_meta_name(self): + """Job has correct Meta.name.""" + from netbox_librenms_plugin.jobs import ImportDevicesJob + + assert ImportDevicesJob.Meta.name == "LibreNMS Device Import" + + +class TestLoadJobResults: + """Test loading results from completed background jobs.""" + + @patch("netbox_librenms_plugin.views.imports.list.cache") + @patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key") + @patch("core.models.Job") + def test_load_success_uses_correct_cache_keys(self, mock_job_class, mock_get_key, mock_cache): + """Load uses get_validated_device_cache_key with job data.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + # Setup mock job + mock_job = MagicMock() + mock_job.status = "completed" + mock_job.data = { + "device_ids": [1, 2], + "filters": {"location": "dc1"}, + "server_key": "primary", + "vc_detection_enabled": True, + "cached_at": "2026-01-20T10:00:00Z", + "cache_timeout": 600, + "use_sysname": True, + "strip_domain": False, + } + mock_job_class.objects.get.return_value = mock_job + + # Mock cache key generation + mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}" + + # Mock cache returns + mock_cache.get.side_effect = [ + {"device_id": 1, "hostname": "test1"}, + {"device_id": 2, "hostname": "test2"}, + ] + + view = LibreNMSImportView() + results = view._load_job_results(123) + + # Verify cache key function called with correct params + assert mock_get_key.call_count == 2 + mock_get_key.assert_any_call( + server_key="primary", + filters={"location": "dc1"}, + device_id=1, + vc_enabled=True, + use_sysname=True, + strip_domain=False, + ) + mock_get_key.assert_any_call( + server_key="primary", + filters={"location": "dc1"}, + device_id=2, + vc_enabled=True, + use_sysname=True, + strip_domain=False, + ) + + assert len(results) == 2 + + @patch("netbox_librenms_plugin.views.imports.list.cache") + @patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key") + @patch("core.models.Job") + def test_load_extracts_filters_from_job_data(self, mock_job_class, mock_get_key, mock_cache): + """Filters, server_key, vc_enabled extracted from job data.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + mock_job = MagicMock() + mock_job.status = "completed" + mock_job.data = { + "device_ids": [1], + "filters": {"location": "dc2", "type": "router"}, + "server_key": "secondary", + "vc_detection_enabled": False, + "cached_at": "2026-01-20T10:00:00Z", + "cache_timeout": 300, + "use_sysname": True, + "strip_domain": False, + } + mock_job_class.objects.get.return_value = mock_job + mock_get_key.return_value = "test_key" + mock_cache.get.return_value = {"device_id": 1} + + view = LibreNMSImportView() + view._load_job_results(456) + + # Verify get_validated_device_cache_key called with extracted values + mock_get_key.assert_called_once_with( + server_key="secondary", + filters={"location": "dc2", "type": "router"}, + device_id=1, + vc_enabled=False, + use_sysname=True, + strip_domain=False, + ) + + @patch("netbox_librenms_plugin.views.imports.list.cache") + @patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key") + @patch("core.models.Job") + def test_load_returns_cached_devices(self, mock_job_class, mock_get_key, mock_cache): + """Devices retrieved from cache.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + mock_job = MagicMock() + mock_job.status = "completed" + mock_job.data = { + "device_ids": [1, 2], + "filters": {}, + "server_key": "default", + "vc_detection_enabled": False, + "cached_at": "2026-01-20T10:00:00Z", + "cache_timeout": 300, + } + mock_job_class.objects.get.return_value = mock_job + mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}" + mock_cache.get.side_effect = [ + {"device_id": 1, "hostname": "device1"}, + {"device_id": 2, "hostname": "device2"}, + ] + + view = LibreNMSImportView() + results = view._load_job_results(789) + + assert len(results) == 2 + assert results[0]["hostname"] == "device1" + assert results[1]["hostname"] == "device2" + + @patch("netbox_librenms_plugin.views.imports.list.cache") + @patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key") + @patch("core.models.Job") + def test_load_sets_cache_metadata(self, mock_job_class, mock_get_key, mock_cache): + """Load sets _cache_timestamp and _cache_timeout on view.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + mock_job = MagicMock() + mock_job.status = "completed" + mock_job.data = { + "device_ids": [1], + "filters": {}, + "server_key": "default", + "vc_detection_enabled": False, + "cached_at": "2026-01-20T12:00:00Z", + "cache_timeout": 900, + } + mock_job_class.objects.get.return_value = mock_job + mock_get_key.return_value = "test_key" + mock_cache.get.return_value = {"device_id": 1} + + view = LibreNMSImportView() + view._load_job_results(456) + + assert view._cache_timestamp == "2026-01-20T12:00:00Z" + assert view._cache_timeout == 900 + + @patch("core.models.Job") + def test_load_job_not_found_returns_empty(self, mock_job_class): + """Non-existent job returns empty list.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + # Create a mock DoesNotExist exception + mock_job_class.DoesNotExist = Exception + mock_job_class.objects.get.side_effect = mock_job_class.DoesNotExist + + view = LibreNMSImportView() + results = view._load_job_results(999) + + assert results == [] + + @patch("core.models.Job") + def test_load_job_not_completed_returns_empty(self, mock_job_class): + """Running job returns empty list.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + mock_job = MagicMock() + mock_job.status = "running" + mock_job_class.objects.get.return_value = mock_job + + view = LibreNMSImportView() + results = view._load_job_results(123) + + assert results == [] + + @patch("netbox_librenms_plugin.views.imports.list.cache") + @patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key") + @patch("core.models.Job") + def test_load_expired_cache_returns_empty(self, mock_job_class, mock_get_key, mock_cache): + """All cache misses returns empty list.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + mock_job = MagicMock() + mock_job.status = "completed" + mock_job.data = { + "device_ids": [1, 2], + "filters": {}, + "server_key": "default", + "vc_detection_enabled": False, + "cached_at": "2026-01-20T10:00:00Z", + "cache_timeout": 300, + } + mock_job_class.objects.get.return_value = mock_job + mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}" + + # Simulate expired cache (returns None) + mock_cache.get.return_value = None + + view = LibreNMSImportView() + results = view._load_job_results(123) + + assert results == [] + + @patch("netbox_librenms_plugin.views.imports.list.cache") + @patch("netbox_librenms_plugin.import_utils.get_validated_device_cache_key") + @patch("core.models.Job") + def test_load_partial_cache_returns_available(self, mock_job_class, mock_get_key, mock_cache): + """Some expired, returns available devices.""" + from netbox_librenms_plugin.views.imports.list import LibreNMSImportView + + mock_job = MagicMock() + mock_job.status = "completed" + mock_job.data = { + "device_ids": [1, 2, 3], + "filters": {}, + "server_key": "default", + "vc_detection_enabled": False, + "cached_at": "2026-01-20T10:00:00Z", + "cache_timeout": 300, + } + mock_job_class.objects.get.return_value = mock_job + mock_get_key.side_effect = lambda **kw: f"key_{kw['device_id']}" + + # First device in cache, second expired, third in cache + mock_cache.get.side_effect = [ + {"device_id": 1, "hostname": "device1"}, + None, # Expired + {"device_id": 3, "hostname": "device3"}, + ] + + view = LibreNMSImportView() + results = view._load_job_results(123) + + # Should return available devices only + assert len(results) == 2 + assert results[0]["device_id"] == 1 + assert results[1]["device_id"] == 3 + + +class TestGracefulFallback: + """Test graceful fallback when RQ workers unavailable.""" + + @patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue") + def test_no_workers_triggers_synchronous_processing(self, mock_get_workers): + """No RQ workers triggers synchronous fallback.""" + mock_get_workers.return_value = 0 + + # This test verifies the condition check, not full request handling + from netbox_librenms_plugin.views.imports.list import get_workers_for_queue + + workers = get_workers_for_queue("default") + assert workers == 0 + + # When workers == 0, the code path skips job enqueuing + # and falls through to synchronous get_queryset processing + + @patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue") + def test_workers_available_allows_background_job(self, mock_get_workers): + """Available workers allow background job enqueue.""" + mock_get_workers.return_value = 2 + + from netbox_librenms_plugin.views.imports.list import get_workers_for_queue + + workers = get_workers_for_queue("default") + assert workers > 0 + # When workers > 0, the code path proceeds to FilterDevicesJob.enqueue() + + @patch("netbox_librenms_plugin.views.imports.list.get_workers_for_queue") + @patch("netbox_librenms_plugin.views.imports.list.logger") + def test_fallback_logs_warning(self, mock_logger, mock_get_workers): + """Warning logged when falling back (checked via worker count).""" + mock_get_workers.return_value = 0 + + # Verify the function returns 0 workers which would trigger fallback + from netbox_librenms_plugin.views.imports.list import get_workers_for_queue + + workers = get_workers_for_queue("default") + assert workers == 0 + + # The view would log a warning when it detects no workers and falls back + # This test verifies the condition that triggers the fallback path diff --git a/netbox_librenms_plugin/tests/test_cable_verify.py b/netbox_librenms_plugin/tests/test_cable_verify.py new file mode 100644 index 0000000..36aaf39 --- /dev/null +++ b/netbox_librenms_plugin/tests/test_cable_verify.py @@ -0,0 +1,311 @@ +""" +Regression tests for SingleCableVerifyView.post(). + +Covers: +- Stale derived fields are stripped before re-enrichment (prevents + DoesNotExist when remote objects are deleted after caching). +- LibreNMS-sourced labels are HTML-escaped to prevent XSS. +""" + +import json +from unittest.mock import MagicMock, patch + + +def _make_view(server_key="default"): + """Create a SingleCableVerifyView instance without database access.""" + from netbox_librenms_plugin.views.base.cables_view import SingleCableVerifyView + + view = object.__new__(SingleCableVerifyView) + view._librenms_api = MagicMock() + view._librenms_api.server_key = server_key + view.request = MagicMock() + return view + + +def _make_request(body_dict): + """Create a mock POST request with JSON body.""" + request = MagicMock() + request.method = "POST" + request.body = json.dumps(body_dict).encode() + request.META = {"HTTP_X_REQUESTED_WITH": "XMLHttpRequest"} + return request + + +class TestStaleFieldStripping: + """Cached link data with stale derived fields must be stripped before use.""" + + def test_stale_remote_fields_stripped_before_enrichment(self): + """Stale netbox_remote_device_id / remote_device_url must not reach check_cable_status().""" + view = _make_view() + + # Cached link with stale derived fields (from a previous enrichment) + cached_link = { + "local_port": "eth0", + "local_port_id": 100, + "remote_port": "eth1", + "remote_device": "switch-remote", + "remote_port_id": 200, + "remote_device_id": 42, + # Stale derived fields β€” remote device was deleted after caching + "netbox_remote_device_id": 999, + "remote_device_url": "/dcim/devices/999/", + "netbox_remote_interface_id": 888, + "remote_port_url": "/dcim/interfaces/888/", + "cable_status": "No Cable", + "can_create_cable": True, + } + + cached_data = {"links": [cached_link]} + + device = MagicMock() + device.pk = 1 + device.id = 1 + device.virtual_chassis = None + interface_mock = MagicMock() + interface_mock.pk = 10 + + # Track what link_data check_cable_status receives + received_link_data = {} + + def fake_check_cable_status(link): + received_link_data.update(link) + link["cable_status"] = "No Cable" + link["can_create_cable"] = True + return link + + def fake_process_remote_device(link, hostname, device_id, server_key=None): + assert link is not None + assert hostname is not None + assert device_id is not None + assert server_key == "default" + # Simulate successful remote enrichment with fresh IDs + link["remote_device_url"] = "/dcim/devices/777/" + link["netbox_remote_device_id"] = 777 + link["remote_port_url"] = "/dcim/interfaces/666/" + link["netbox_remote_interface_id"] = 666 + link["remote_port_name"] = "eth1" + return link + + request = _make_request({"device_id": 1, "local_port_id": 100}) + + with ( + patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, + patch.object(view, "get_cache_key", return_value="test_key"), + patch.object(view, "check_cable_status", side_effect=fake_check_cable_status), + patch.object(view, "process_remote_device", side_effect=fake_process_remote_device), + patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device), + patch("netbox_librenms_plugin.views.base.cables_view.get_virtual_chassis_member", return_value=device), + patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"), + patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"), + ): + mock_cache.get.return_value = cached_data + # Make the interface filter return our mock + device.interfaces.filter.return_value.first.return_value = interface_mock + + view.post(request) + + # check_cable_status should have received fresh IDs from process_remote_device, + # NOT the stale 999/888 from cache + assert received_link_data.get("netbox_remote_device_id") == 777 + assert received_link_data.get("netbox_remote_interface_id") == 666 + + def test_post_strips_derived_fields_from_cached_link(self): + """post() must strip derived fields (URLs, IDs) before re-enrichment. + + Both _prepare_context and post() define a _raw_keys set that controls + which cached fields survive into re-enrichment. This test verifies the + behavior: derived fields in the cached link must not leak through. + """ + view = _make_view() + + # Cached link with both raw and derived (stale) fields + cached_link = { + "local_port": "eth0", + "local_port_id": 100, + "remote_port": "eth1", + "remote_device": "switch-a", + "remote_port_id": 200, + "remote_device_id": 42, + # Derived fields that must be stripped: + "netbox_local_interface_id": 999, + "netbox_remote_interface_id": 888, + "netbox_remote_device_id": 777, + "local_port_url": "/stale/", + "remote_port_url": "/stale/", + "remote_device_url": "/stale/", + "cable_status": "stale", + "can_create_cable": True, + } + + # Mock process_remote_device to avoid DB access during re-enrichment; + # it should receive the link WITHOUT derived fields. + received_link = {} + + def fake_process_remote(link, hostname, device_id, server_key=None): + received_link.update(link) + return link + + view.process_remote_device = fake_process_remote + + with ( + patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404") as mock_get, + patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, + patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=None), + patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="tok"), + ): + device = MagicMock() + device.pk = 1 + device.virtual_chassis = None + device.interfaces.filter.return_value.first.return_value = None + mock_get.return_value = device + mock_cache.get.return_value = {"links": [cached_link]} + + request = MagicMock() + request.body = json.dumps( + { + "device_id": 1, + "local_port_id": 100, + "server_key": "default", + } + ) + view.post(request) + + # The link passed to process_remote_device must have derived fields stripped + assert "netbox_local_interface_id" not in received_link + assert "netbox_remote_interface_id" not in received_link + assert "netbox_remote_device_id" not in received_link + assert "local_port_url" not in received_link + assert "cable_status" not in received_link + + +class TestXSSEscaping: + """LibreNMS-sourced labels must be HTML-escaped in cable verify output.""" + + def test_xss_in_local_port_name_escaped(self): + """A malicious local_port name must be escaped in the HTML output.""" + view = _make_view() + + xss_port_name = '' + cached_link = { + "local_port": xss_port_name, + "local_port_id": 100, + "remote_port": "eth1", + "remote_device": "safe-switch", + "remote_port_id": 200, + "remote_device_id": 42, + } + + cached_data = {"links": [cached_link]} + + device = MagicMock() + device.pk = 1 + device.id = 1 + device.virtual_chassis = None + interface_mock = MagicMock() + interface_mock.pk = 10 + + def fake_process_remote_device(link, hostname, device_id, server_key=None): + link["remote_device_url"] = "/dcim/devices/2/" + link["netbox_remote_device_id"] = 2 + link["remote_port_url"] = "/dcim/interfaces/20/" + link["netbox_remote_interface_id"] = 20 + link["remote_port_name"] = "eth1" + return link + + def fake_check_cable_status(link): + link["cable_status"] = "No Cable" + link["can_create_cable"] = False + return link + + request = _make_request({"device_id": 1, "local_port_id": 100}) + + with ( + patch("netbox_librenms_plugin.views.base.cables_view.get_object_or_404", return_value=device), + patch("netbox_librenms_plugin.views.base.cables_view.cache") as mock_cache, + patch.object(view, "get_cache_key", return_value="test_key"), + patch.object(view, "check_cable_status", side_effect=fake_check_cable_status), + patch.object(view, "process_remote_device", side_effect=fake_process_remote_device), + patch("netbox_librenms_plugin.views.base.cables_view.get_librenms_sync_device", return_value=device), + patch("netbox_librenms_plugin.views.base.cables_view._librenms_id_q", return_value=MagicMock()), + patch("netbox_librenms_plugin.views.base.cables_view.get_token", return_value="csrf123"), + patch("netbox_librenms_plugin.views.base.cables_view.reverse", return_value="/fake/"), + ): + mock_cache.get.return_value = cached_data + device.interfaces.filter.return_value.first.return_value = interface_mock + + response = view.post(request) + + content = json.loads(response.content) + row = content.get("formatted_row", {}) + local_port_html = row.get("local_port", "") + + # The raw script tag must NOT appear unescaped + assert "' + vc.members.all.return_value = [member] + device.virtual_chassis = vc + + table = VCCableTable([], device=device) + record = {"local_port": "eth0", "local_port_id": "42"} + + with patch( + "netbox_librenms_plugin.tables.cables.get_virtual_chassis_member", + return_value=member, + ): + html = str(table.render_device_selection(None, record)) + + # The raw ' + vc.members.all.return_value = [member] + device.virtual_chassis = vc + + table = VCCableTable([], device=device) + record = {"local_port": "eth0", "local_port_id": "42"} + + with patch( + "netbox_librenms_plugin.tables.cables.get_virtual_chassis_member", + return_value=member, + ): + html = str(table.render_device_selection(None, record)) + + assert "