Просмотр исходного кода

Merge pull request #8185 from netbox-community/develop

Release v3.1.3
Jeremy Stretch 4 лет назад
Родитель
Сommit
1f575a2a47
100 измененных файлов с 973 добавлено и 1254 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 4 0
      docs/models/extras/customlink.md
  4. 26 0
      docs/release-notes/version-3.1.md
  5. 10 20
      netbox/circuits/forms/filtersets.py
  6. 3 3
      netbox/dcim/api/views.py
  7. 61 122
      netbox/dcim/forms/filtersets.py
  8. 6 12
      netbox/dcim/forms/models.py
  9. 0 39
      netbox/dcim/models/__init__.py
  10. 8 16
      netbox/dcim/tables/devicetypes.py
  11. 20 7
      netbox/dcim/views.py
  12. 3 3
      netbox/extras/api/serializers.py
  13. 7 2
      netbox/extras/forms/bulk_import.py
  14. 15 30
      netbox/extras/forms/filtersets.py
  15. 18 0
      netbox/extras/models/models.py
  16. 10 14
      netbox/extras/templatetags/custom_links.py
  17. 4 4
      netbox/extras/tests/test_views.py
  18. 26 4
      netbox/extras/views.py
  19. 2 0
      netbox/ipam/choices.py
  20. 27 54
      netbox/ipam/forms/filtersets.py
  21. 2 0
      netbox/ipam/forms/models.py
  22. 11 4
      netbox/ipam/views.py
  23. 5 5
      netbox/netbox/authentication.py
  24. 1 1
      netbox/netbox/settings.py
  25. 0 1
      netbox/project-static/bundle.js
  26. 0 0
      netbox/project-static/dist/jobs.js
  27. 0 2
      netbox/project-static/dist/jobs.js.map
  28. 0 0
      netbox/project-static/dist/netbox-dark.css
  29. 0 0
      netbox/project-static/dist/netbox-light.css
  30. 0 0
      netbox/project-static/dist/netbox-print.css
  31. 0 0
      netbox/project-static/dist/netbox.js
  32. 0 0
      netbox/project-static/dist/netbox.js.map
  33. 0 9
      netbox/project-static/src/forms/elements.ts
  34. 0 32
      netbox/project-static/src/global.d.ts
  35. 23 0
      netbox/project-static/src/htmx.ts
  36. 0 104
      netbox/project-static/src/jobs.ts
  37. 2 0
      netbox/project-static/src/netbox.ts
  38. 1 1
      netbox/project-static/src/select/api/apiSelect.ts
  39. 13 0
      netbox/project-static/styles/netbox.scss
  40. 1 0
      netbox/project-static/styles/theme-light.scss
  41. 5 5
      netbox/templates/base/base.html
  42. 2 3
      netbox/templates/base/layout.html
  43. 1 1
      netbox/templates/base/sidenav.html
  44. 1 1
      netbox/templates/circuits/circuit_terminations_swap.html
  45. 1 1
      netbox/templates/circuits/inc/circuit_termination.html
  46. 2 2
      netbox/templates/circuits/provider.html
  47. 1 1
      netbox/templates/dcim/bulk_disconnect.html
  48. 1 1
      netbox/templates/dcim/consoleport_delete.html
  49. 1 1
      netbox/templates/dcim/consoleserverport_delete.html
  50. 27 27
      netbox/templates/dcim/device/base.html
  51. 4 4
      netbox/templates/dcim/device/devicebays.html
  52. 1 1
      netbox/templates/dcim/devicebay_delete.html
  53. 1 1
      netbox/templates/dcim/devicebay_depopulate.html
  54. 1 7
      netbox/templates/dcim/devicerole.html
  55. 1 7
      netbox/templates/dcim/devicetype.html
  56. 32 32
      netbox/templates/dcim/devicetype/base.html
  57. 4 4
      netbox/templates/dcim/devicetype/component_templates.html
  58. 2 14
      netbox/templates/dcim/interface.html
  59. 1 1
      netbox/templates/dcim/interface_delete.html
  60. 1 1
      netbox/templates/dcim/inventoryitem_delete.html
  61. 1 1
      netbox/templates/dcim/poweroutlet_delete.html
  62. 1 1
      netbox/templates/dcim/powerport_delete.html
  63. 1 1
      netbox/templates/dcim/virtualchassis.html
  64. 1 1
      netbox/templates/dcim/virtualchassis_remove_member.html
  65. 1 7
      netbox/templates/extras/customfield.html
  66. 1 7
      netbox/templates/extras/customlink.html
  67. 1 7
      netbox/templates/extras/exporttemplate.html
  68. 73 0
      netbox/templates/extras/htmx/report_result.html
  69. 50 0
      netbox/templates/extras/htmx/script_result.html
  70. 16 0
      netbox/templates/extras/imageattachment_edit.html
  71. 6 0
      netbox/templates/extras/inc/result_pending.html
  72. 4 94
      netbox/templates/extras/report_result.html
  73. 26 95
      netbox/templates/extras/script_result.html
  74. 5 35
      netbox/templates/extras/webhook.html
  75. 0 0
      netbox/templates/generic/confirmation_form.html
  76. 1 1
      netbox/templates/generic/object_bulk_import.html
  77. 1 1
      netbox/templates/generic/object_delete.html
  78. 1 2
      netbox/templates/home.html
  79. 1 1
      netbox/templates/inc/panels/comments.html
  80. 2 2
      netbox/templates/inc/panels/custom_fields.html
  81. 0 30
      netbox/templates/inc/plugin_menu_items.html
  82. 53 57
      netbox/templates/inc/profile_button.html
  83. 121 129
      netbox/templates/ipam/ipaddress.html
  84. 146 119
      netbox/templates/ipam/prefix.html
  85. 1 7
      netbox/templates/ipam/rir.html
  86. 0 2
      netbox/templates/ipam/service_edit.html
  87. 1 7
      netbox/templates/ipam/vrf.html
  88. 3 2
      netbox/templates/users/api_tokens.html
  89. 2 14
      netbox/templates/users/profile.html
  90. 1 1
      netbox/templates/virtualization/virtualmachine/interfaces.html
  91. 4 8
      netbox/tenancy/forms/filtersets.py
  92. 2 4
      netbox/tenancy/forms/forms.py
  93. 1 0
      netbox/utilities/constants.py
  94. 2 2
      netbox/utilities/forms/fields.py
  95. 42 10
      netbox/utilities/tables.py
  96. 0 0
      netbox/utilities/templates/form_helpers/render_custom_fields.html
  97. 0 0
      netbox/utilities/templates/form_helpers/render_errors.html
  98. 0 0
      netbox/utilities/templates/form_helpers/render_field.html
  99. 0 0
      netbox/utilities/templates/form_helpers/render_form.html
  100. 0 0
      netbox/utilities/templates/helpers/applied_filters.html

+ 1 - 1
.github/ISSUE_TEMPLATE/bug_report.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.2
+      placeholder: v3.1.3
     validations:
       required: true
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/feature_request.yaml

@@ -14,7 +14,7 @@ body:
     attributes:
       label: NetBox version
       description: What version of NetBox are you currently running?
-      placeholder: v3.1.2
+      placeholder: v3.1.3
     validations:
       required: true
   - type: dropdown

+ 4 - 0
docs/models/extras/customlink.md

@@ -55,3 +55,7 @@ The link will only appear when viewing a device with a manufacturer name of "Cis
 ## Link Groups
 
 Group names can be specified to organize links into groups. Links with the same group name will render as a dropdown menu beneath a single button bearing the name of the group.
+
+## Table Columns
+
+Custom links can also be included in object tables by selecting the desired links from the table configuration form. When displayed, each link will render as a hyperlink for its corresponding object. When exported (e.g. as CSV data), each link render only its URL.

+ 26 - 0
docs/release-notes/version-3.1.md

@@ -1,5 +1,31 @@
 # NetBox v3.1
 
+## v3.1.3 (2021-12-29)
+
+### Enhancements
+
+* [#6782](https://github.com/netbox-community/netbox/issues/6782) - Enable the inclusion of custom links in tables
+* [#7600](https://github.com/netbox-community/netbox/issues/7600) - Include count of available IPs on prefix view
+* [#8034](https://github.com/netbox-community/netbox/issues/8034) - Enable specifying custom field validators during CSV import
+* [#8100](https://github.com/netbox-community/netbox/issues/8100) - Add "other" choice for FHRP group protocol
+* [#8175](https://github.com/netbox-community/netbox/issues/8175) - Display parent object when attaching an image
+
+### Bug Fixes
+
+* [#7246](https://github.com/netbox-community/netbox/issues/7246) - Don't attempt to URL-decode NAPALM response payloads
+* [#7290](https://github.com/netbox-community/netbox/issues/7290) - Defer loading API-backed form fields
+* [#7887](https://github.com/netbox-community/netbox/issues/7887) - Forward `HTTP_X_FORWARDED_FOR` to custom scripts
+* [#7962](https://github.com/netbox-community/netbox/issues/7962) - Fix user menu under report/script result view
+* [#7972](https://github.com/netbox-community/netbox/issues/7972) - Standardize name of `RemoteUserBackend` logger
+* [#8097](https://github.com/netbox-community/netbox/issues/8097) - Fix styling of Markdown tables
+* [#8127](https://github.com/netbox-community/netbox/issues/8127) - Fix disassociation of interface under IP address edit view
+* [#8131](https://github.com/netbox-community/netbox/issues/8131) - Restore annotation of available IPs under prefix IPs view
+* [#8134](https://github.com/netbox-community/netbox/issues/8134) - Fix bulk editing of objects within dynamic tables
+* [#8139](https://github.com/netbox-community/netbox/issues/8139) - Fix rendering of table configuration form under VM interfaces view
+* [#8140](https://github.com/netbox-community/netbox/issues/8140) - Restore missing fields on wireless LAN & link REST API serializers
+
+---
+
 ## v3.1.2 (2021-12-20)
 
 ### Enhancements

+ 10 - 20
netbox/circuits/forms/filtersets.py

@@ -26,14 +26,12 @@ class ProviderFilterForm(CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -42,8 +40,7 @@ class ProviderFilterForm(CustomFieldModelFilterForm):
             'region_id': '$region_id',
             'site_group_id': '$site_group_id',
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     asn = forms.IntegerField(
         required=False,
@@ -61,8 +58,7 @@ class ProviderNetworkFilterForm(CustomFieldModelFilterForm):
     provider_id = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         required=False,
-        label=_('Provider'),
-        fetch_trigger='open'
+        label=_('Provider')
     )
     tag = TagFilterField(model)
 
@@ -84,14 +80,12 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     type_id = DynamicModelMultipleChoiceField(
         queryset=CircuitType.objects.all(),
         required=False,
-        label=_('Type'),
-        fetch_trigger='open'
+        label=_('Type')
     )
     provider_id = DynamicModelMultipleChoiceField(
         queryset=Provider.objects.all(),
         required=False,
-        label=_('Provider'),
-        fetch_trigger='open'
+        label=_('Provider')
     )
     provider_network_id = DynamicModelMultipleChoiceField(
         queryset=ProviderNetwork.objects.all(),
@@ -99,8 +93,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'provider_id': '$provider_id'
         },
-        label=_('Provider network'),
-        fetch_trigger='open'
+        label=_('Provider network')
     )
     status = forms.MultipleChoiceField(
         choices=CircuitStatusChoices,
@@ -110,14 +103,12 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -126,8 +117,7 @@ class CircuitFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
             'region_id': '$region_id',
             'site_group_id': '$site_group_id',
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     commit_rate = forms.IntegerField(
         required=False,

+ 3 - 3
netbox/dcim/api/views.py

@@ -15,14 +15,14 @@ from circuits.models import Circuit
 from dcim import filtersets
 from dcim.models import *
 from extras.api.views import ConfigContextQuerySetMixin, CustomFieldModelViewSet
-from ipam.models import Prefix, VLAN, ASN
+from ipam.models import Prefix, VLAN
 from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired
 from netbox.api.exceptions import ServiceUnavailable
 from netbox.api.metadata import ContentTypeMetadata
 from netbox.api.views import ModelViewSet
 from netbox.config import get_config
 from utilities.api import get_serializer_for_model
-from utilities.utils import count_related, decode_dict
+from utilities.utils import count_related
 from virtualization.models import VirtualMachine
 from . import serializers
 from .exceptions import MissingFilterException
@@ -501,7 +501,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
                 response[method] = {'error': 'Only get_* NAPALM methods are supported'}
                 continue
             try:
-                response[method] = decode_dict(getattr(d, method)())
+                response[method] = getattr(d, method)()
             except NotImplementedError:
                 response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)}
             except Exception as e:

+ 61 - 122
netbox/dcim/forms/filtersets.py

@@ -57,14 +57,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -73,8 +71,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
             'region_id': '$region_id',
             'group_id': '$site_group_id',
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     location_id = DynamicModelMultipleChoiceField(
         queryset=Location.objects.all(),
@@ -82,14 +79,12 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
         query_params={
             'site_id': '$site_id',
         },
-        label=_('Location'),
-        fetch_trigger='open'
+        label=_('Location')
     )
     virtual_chassis_id = DynamicModelMultipleChoiceField(
         queryset=VirtualChassis.objects.all(),
         required=False,
-        label=_('Virtual Chassis'),
-        fetch_trigger='open'
+        label=_('Virtual Chassis')
     )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
@@ -99,8 +94,7 @@ class DeviceComponentFilterForm(CustomFieldModelFilterForm):
             'location_id': '$location_id',
             'virtual_chassis_id': '$virtual_chassis_id'
         },
-        label=_('Device'),
-        fetch_trigger='open'
+        label=_('Device')
     )
 
 
@@ -109,8 +103,7 @@ class RegionFilterForm(CustomFieldModelFilterForm):
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Parent region'),
-        fetch_trigger='open'
+        label=_('Parent region')
     )
     tag = TagFilterField(model)
 
@@ -120,8 +113,7 @@ class SiteGroupFilterForm(CustomFieldModelFilterForm):
     parent_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Parent group'),
-        fetch_trigger='open'
+        label=_('Parent group')
     )
     tag = TagFilterField(model)
 
@@ -142,20 +134,17 @@ class SiteFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     asn_id = DynamicModelMultipleChoiceField(
         queryset=ASN.objects.all(),
         required=False,
-        label=_('ASNs'),
-        fetch_trigger='open'
+        label=_('ASNs')
     )
     tag = TagFilterField(model)
 
@@ -170,14 +159,12 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -186,8 +173,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
             'region_id': '$region_id',
             'group_id': '$site_group_id',
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     parent_id = DynamicModelMultipleChoiceField(
         queryset=Location.objects.all(),
@@ -196,8 +182,7 @@ class LocationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
             'region_id': '$region_id',
             'site_id': '$site_id',
         },
-        label=_('Parent'),
-        fetch_trigger='open'
+        label=_('Parent')
     )
     tag = TagFilterField(model)
 
@@ -219,8 +204,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -228,8 +212,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     location_id = DynamicModelMultipleChoiceField(
         queryset=Location.objects.all(),
@@ -238,8 +221,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Location'),
-        fetch_trigger='open'
+        label=_('Location')
     )
     status = forms.MultipleChoiceField(
         choices=RackStatusChoices,
@@ -260,8 +242,7 @@ class RackFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         queryset=RackRole.objects.all(),
         required=False,
         null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
+        label=_('Role')
     )
     serial = forms.CharField(
         required=False
@@ -280,8 +261,7 @@ class RackElevationFilterForm(RackFilterForm):
         query_params={
             'site_id': '$site_id',
             'location_id': '$location_id',
-        },
-        fetch_trigger='open'
+        }
     )
 
 
@@ -296,8 +276,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -305,15 +284,13 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     location_id = DynamicModelMultipleChoiceField(
         queryset=Location.objects.prefetch_related('site'),
         required=False,
         label=_('Location'),
-        null_option='None',
-        fetch_trigger='open'
+        null_option='None'
     )
     user_id = DynamicModelMultipleChoiceField(
         queryset=User.objects.all(),
@@ -321,8 +298,7 @@ class RackReservationFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         label=_('User'),
         widget=APISelectMultiple(
             api_url='/api/users/users/',
-        ),
-        fetch_trigger='open'
+        )
     )
     tag = TagFilterField(model)
 
@@ -342,8 +318,7 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     subdevice_role = forms.MultipleChoiceField(
         choices=add_blank_choice(SubdeviceRoleChoices),
@@ -410,8 +385,7 @@ class PlatformFilterForm(CustomFieldModelFilterForm):
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     tag = TagFilterField(model)
 
@@ -432,14 +406,12 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -448,8 +420,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
             'region_id': '$region_id',
             'group_id': '$site_group_id',
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     location_id = DynamicModelMultipleChoiceField(
         queryset=Location.objects.all(),
@@ -458,8 +429,7 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Location'),
-        fetch_trigger='open'
+        label=_('Location')
     )
     rack_id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
@@ -469,20 +439,17 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
             'site_id': '$site_id',
             'location_id': '$location_id',
         },
-        label=_('Rack'),
-        fetch_trigger='open'
+        label=_('Rack')
     )
     role_id = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         required=False,
-        label=_('Role'),
-        fetch_trigger='open'
+        label=_('Role')
     )
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     device_type_id = DynamicModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
@@ -490,15 +457,13 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
         query_params={
             'manufacturer_id': '$manufacturer_id'
         },
-        label=_('Model'),
-        fetch_trigger='open'
+        label=_('Model')
     )
     platform_id = DynamicModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         required=False,
         null_option='None',
-        label=_('Platform'),
-        fetch_trigger='open'
+        label=_('Platform')
     )
     status = forms.MultipleChoiceField(
         choices=DeviceStatusChoices,
@@ -589,14 +554,12 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -605,8 +568,7 @@ class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
             'region_id': '$region_id',
             'group_id': '$site_group_id',
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     tag = TagFilterField(model)
 
@@ -622,8 +584,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -631,8 +592,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     rack_id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
@@ -641,8 +601,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         null_option='None',
         query_params={
             'site_id': '$site_id'
-        },
-        fetch_trigger='open'
+        }
     )
     type = forms.MultipleChoiceField(
         choices=add_blank_choice(CableTypeChoices),
@@ -665,8 +624,7 @@ class CableFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
             'tenant_id': '$tenant_id',
             'rack_id': '$rack_id',
         },
-        label=_('Device'),
-        fetch_trigger='open'
+        label=_('Device')
     )
     tag = TagFilterField(model)
 
@@ -680,14 +638,12 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -696,8 +652,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
             'region_id': '$region_id',
             'group_id': '$site_group_id',
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     location_id = DynamicModelMultipleChoiceField(
         queryset=Location.objects.all(),
@@ -706,8 +661,7 @@ class PowerPanelFilterForm(CustomFieldModelFilterForm):
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Location'),
-        fetch_trigger='open'
+        label=_('Location')
     )
     tag = TagFilterField(model)
 
@@ -723,14 +677,12 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -738,8 +690,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     power_panel_id = DynamicModelMultipleChoiceField(
         queryset=PowerPanel.objects.all(),
@@ -748,8 +699,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Power panel'),
-        fetch_trigger='open'
+        label=_('Power panel')
     )
     rack_id = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
@@ -758,8 +708,7 @@ class PowerFeedFilterForm(CustomFieldModelFilterForm):
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Rack'),
-        fetch_trigger='open'
+        label=_('Rack')
     )
     status = forms.MultipleChoiceField(
         choices=PowerFeedStatusChoices,
@@ -990,8 +939,7 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         required=False,
-        label=_('Manufacturer'),
-        fetch_trigger='open'
+        label=_('Manufacturer')
     )
     serial = forms.CharField(
         required=False
@@ -1016,8 +964,7 @@ class ConsoleConnectionFilterForm(FilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -1025,8 +972,7 @@ class ConsoleConnectionFilterForm(FilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
@@ -1034,8 +980,7 @@ class ConsoleConnectionFilterForm(FilterForm):
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Device'),
-        fetch_trigger='open'
+        label=_('Device')
     )
 
 
@@ -1043,8 +988,7 @@ class PowerConnectionFilterForm(FilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -1052,8 +996,7 @@ class PowerConnectionFilterForm(FilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
@@ -1061,8 +1004,7 @@ class PowerConnectionFilterForm(FilterForm):
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Device'),
-        fetch_trigger='open'
+        label=_('Device')
     )
 
 
@@ -1070,8 +1012,7 @@ class InterfaceConnectionFilterForm(FilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -1079,8 +1020,7 @@ class InterfaceConnectionFilterForm(FilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     device_id = DynamicModelMultipleChoiceField(
         queryset=Device.objects.all(),
@@ -1088,6 +1028,5 @@ class InterfaceConnectionFilterForm(FilterForm):
         query_params={
             'site_id': '$site_id'
         },
-        label=_('Device'),
-        fetch_trigger='open'
+        label=_('Device')
     )

+ 6 - 12
netbox/dcim/forms/models.py

@@ -301,16 +301,14 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
         required=False,
         initial_params={
             'sites': '$site'
-        },
-        fetch_trigger='open'
+        }
     )
     site_group = DynamicModelChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
         initial_params={
             'sites': '$site'
-        },
-        fetch_trigger='open'
+        }
     )
     site = DynamicModelChoiceField(
         queryset=Site.objects.all(),
@@ -318,24 +316,21 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
         query_params={
             'region_id': '$region',
             'group_id': '$site_group',
-        },
-        fetch_trigger='open'
+        }
     )
     location = DynamicModelChoiceField(
         queryset=Location.objects.all(),
         required=False,
         query_params={
             'site_id': '$site'
-        },
-        fetch_trigger='open'
+        }
     )
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         query_params={
             'site_id': '$site',
             'location_id': '$location',
-        },
-        fetch_trigger='open'
+        }
     )
     units = NumericArrayField(
         base_field=forms.IntegerField(),
@@ -349,8 +344,7 @@ class RackReservationForm(TenancyForm, CustomFieldModelForm):
     )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
-        required=False,
-        fetch_trigger='open'
+        required=False
     )
 
     class Meta:

+ 0 - 39
netbox/dcim/models/__init__.py

@@ -5,42 +5,3 @@ from .devices import *
 from .power import *
 from .racks import *
 from .sites import *
-
-__all__ = (
-    'BaseInterface',
-    'Cable',
-    'CablePath',
-    'LinkTermination',
-    'ConsolePort',
-    'ConsolePortTemplate',
-    'ConsoleServerPort',
-    'ConsoleServerPortTemplate',
-    'Device',
-    'DeviceBay',
-    'DeviceBayTemplate',
-    'DeviceRole',
-    'DeviceType',
-    'FrontPort',
-    'FrontPortTemplate',
-    'Interface',
-    'InterfaceTemplate',
-    'InventoryItem',
-    'Location',
-    'Manufacturer',
-    'Platform',
-    'PowerFeed',
-    'PowerOutlet',
-    'PowerOutletTemplate',
-    'PowerPanel',
-    'PowerPort',
-    'PowerPortTemplate',
-    'Rack',
-    'RackReservation',
-    'RackRole',
-    'RearPort',
-    'RearPortTemplate',
-    'Region',
-    'Site',
-    'SiteGroup',
-    'VirtualChassis',
-)

+ 8 - 16
netbox/dcim/tables/devicetypes.py

@@ -111,8 +111,7 @@ class ComponentTemplateTable(BaseTable):
 class ConsolePortTemplateTable(ComponentTemplateTable):
     actions = ButtonsColumn(
         model=ConsolePortTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_consoleports'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):
@@ -124,8 +123,7 @@ class ConsolePortTemplateTable(ComponentTemplateTable):
 class ConsoleServerPortTemplateTable(ComponentTemplateTable):
     actions = ButtonsColumn(
         model=ConsoleServerPortTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_consoleserverports'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):
@@ -137,8 +135,7 @@ class ConsoleServerPortTemplateTable(ComponentTemplateTable):
 class PowerPortTemplateTable(ComponentTemplateTable):
     actions = ButtonsColumn(
         model=PowerPortTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_powerports'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):
@@ -150,8 +147,7 @@ class PowerPortTemplateTable(ComponentTemplateTable):
 class PowerOutletTemplateTable(ComponentTemplateTable):
     actions = ButtonsColumn(
         model=PowerOutletTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_poweroutlets'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):
@@ -166,8 +162,7 @@ class InterfaceTemplateTable(ComponentTemplateTable):
     )
     actions = ButtonsColumn(
         model=InterfaceTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_interfaces'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):
@@ -183,8 +178,7 @@ class FrontPortTemplateTable(ComponentTemplateTable):
     color = ColorColumn()
     actions = ButtonsColumn(
         model=FrontPortTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_frontports'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):
@@ -197,8 +191,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
     color = ColorColumn()
     actions = ButtonsColumn(
         model=RearPortTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_rearports'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):
@@ -210,8 +203,7 @@ class RearPortTemplateTable(ComponentTemplateTable):
 class DeviceBayTemplateTable(ComponentTemplateTable):
     actions = ButtonsColumn(
         model=DeviceBayTemplate,
-        buttons=('edit', 'delete'),
-        return_url_extra='%23tab_devicebays'
+        buttons=('edit', 'delete')
     )
 
     class Meta(ComponentTemplateTable.Meta):

+ 20 - 7
netbox/dcim/views.py

@@ -27,13 +27,7 @@ from virtualization.models import VirtualMachine
 from . import filtersets, forms, tables
 from .choices import DeviceFaceChoices
 from .constants import NONCONNECTABLE_IFACE_TYPES
-from .models import (
-    Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
-    DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel,
-    PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
-    SiteGroup, VirtualChassis,
-)
+from .models import *
 
 
 class DeviceComponentsView(generic.ObjectChildrenView):
@@ -51,10 +45,21 @@ class DeviceComponentsView(generic.ObjectChildrenView):
 class DeviceTypeComponentsView(DeviceComponentsView):
     queryset = DeviceType.objects.all()
     template_name = 'dcim/devicetype/component_templates.html'
+    viewname = None  # Used for return_url resolution
 
     def get_children(self, request, parent):
         return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
 
+    def get_extra_context(self, request, instance):
+        if self.viewname:
+            return_url = reverse(self.viewname, kwargs={'pk': instance.pk})
+        else:
+            return_url = instance.get_absolute_url()
+        return {
+            'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
+            'return_url': return_url,
+        }
+
 
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
     """
@@ -798,48 +803,56 @@ class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
     child_model = ConsolePortTemplate
     table = tables.ConsolePortTemplateTable
     filterset = filtersets.ConsolePortTemplateFilterSet
+    viewname = 'dcim:devicetype_consoleports'
 
 
 class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
     child_model = ConsoleServerPortTemplate
     table = tables.ConsoleServerPortTemplateTable
     filterset = filtersets.ConsoleServerPortTemplateFilterSet
+    viewname = 'dcim:devicetype_consoleserverports'
 
 
 class DeviceTypePowerPortsView(DeviceTypeComponentsView):
     child_model = PowerPortTemplate
     table = tables.PowerPortTemplateTable
     filterset = filtersets.PowerPortTemplateFilterSet
+    viewname = 'dcim:devicetype_powerports'
 
 
 class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
     child_model = PowerOutletTemplate
     table = tables.PowerOutletTemplateTable
     filterset = filtersets.PowerOutletTemplateFilterSet
+    viewname = 'dcim:devicetype_poweroutlets'
 
 
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     child_model = InterfaceTemplate
     table = tables.InterfaceTemplateTable
     filterset = filtersets.InterfaceTemplateFilterSet
+    viewname = 'dcim:devicetype_interfaces'
 
 
 class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
     child_model = FrontPortTemplate
     table = tables.FrontPortTemplateTable
     filterset = filtersets.FrontPortTemplateFilterSet
+    viewname = 'dcim:devicetype_frontports'
 
 
 class DeviceTypeRearPortsView(DeviceTypeComponentsView):
     child_model = RearPortTemplate
     table = tables.RearPortTemplateTable
     filterset = filtersets.RearPortTemplateFilterSet
+    viewname = 'dcim:devicetype_rearports'
 
 
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
     child_model = DeviceBayTemplate
     table = tables.DeviceBayTemplateTable
     filterset = filtersets.DeviceBayTemplateFilterSet
+    viewname = 'dcim:devicetype_devicebays'
 
 
 class DeviceTypeEditView(generic.ObjectEditView):

+ 3 - 3
netbox/extras/api/serializers.py

@@ -5,10 +5,10 @@ from drf_yasg.utils import swagger_serializer_method
 from rest_framework import serializers
 
 from dcim.api.nested_serializers import (
-    NestedDeviceSerializer, NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer,
-    NestedRackSerializer, NestedRegionSerializer, NestedSiteSerializer, NestedSiteGroupSerializer,
+    NestedDeviceRoleSerializer, NestedDeviceTypeSerializer, NestedPlatformSerializer, NestedRegionSerializer,
+    NestedSiteSerializer, NestedSiteGroupSerializer,
 )
-from dcim.models import Device, DeviceRole, DeviceType, Platform, Rack, Region, Site, SiteGroup
+from dcim.models import DeviceRole, DeviceType, Platform, Region, Site, SiteGroup
 from extras.choices import *
 from extras.models import *
 from extras.utils import FeatureQuery

+ 7 - 2
netbox/extras/forms/bulk_import.py

@@ -3,9 +3,10 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.postgres.forms import SimpleArrayField
 from django.utils.safestring import mark_safe
 
+from extras.choices import CustomFieldTypeChoices
 from extras.models import *
 from extras.utils import FeatureQuery
-from utilities.forms import CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
+from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField
 
 __all__ = (
     'CustomFieldCSVForm',
@@ -22,6 +23,10 @@ class CustomFieldCSVForm(CSVModelForm):
         limit_choices_to=FeatureQuery('custom_fields'),
         help_text="One or more assigned object types"
     )
+    type = CSVChoiceField(
+        choices=CustomFieldTypeChoices,
+        help_text='Field data type (e.g. text, integer, etc.)'
+    )
     choices = SimpleArrayField(
         base_field=forms.CharField(),
         required=False,
@@ -32,7 +37,7 @@ class CustomFieldCSVForm(CSVModelForm):
         model = CustomField
         fields = (
             'name', 'label', 'type', 'content_types', 'required', 'description', 'weight', 'filter_logic', 'default',
-            'choices', 'weight',
+            'choices', 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex',
         )
 
 

+ 15 - 30
netbox/extras/forms/filtersets.py

@@ -164,69 +164,58 @@ class ConfigContextFilterForm(FilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Regions'),
-        fetch_trigger='open'
+        label=_('Regions')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site groups'),
-        fetch_trigger='open'
+        label=_('Site groups')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        label=_('Sites'),
-        fetch_trigger='open'
+        label=_('Sites')
     )
     device_type_id = DynamicModelMultipleChoiceField(
         queryset=DeviceType.objects.all(),
         required=False,
-        label=_('Device types'),
-        fetch_trigger='open'
+        label=_('Device types')
     )
     role_id = DynamicModelMultipleChoiceField(
         queryset=DeviceRole.objects.all(),
         required=False,
-        label=_('Roles'),
-        fetch_trigger='open'
+        label=_('Roles')
     )
     platform_id = DynamicModelMultipleChoiceField(
         queryset=Platform.objects.all(),
         required=False,
-        label=_('Platforms'),
-        fetch_trigger='open'
+        label=_('Platforms')
     )
     cluster_group_id = DynamicModelMultipleChoiceField(
         queryset=ClusterGroup.objects.all(),
         required=False,
-        label=_('Cluster groups'),
-        fetch_trigger='open'
+        label=_('Cluster groups')
     )
     cluster_id = DynamicModelMultipleChoiceField(
         queryset=Cluster.objects.all(),
         required=False,
-        label=_('Clusters'),
-        fetch_trigger='open'
+        label=_('Clusters')
     )
     tenant_group_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False,
-        label=_('Tenant groups'),
-        fetch_trigger='open'
+        label=_('Tenant groups')
     )
     tenant_id = DynamicModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
         required=False,
-        label=_('Tenant'),
-        fetch_trigger='open'
+        label=_('Tenant')
     )
     tag = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         to_field_name='slug',
         required=False,
-        label=_('Tags'),
-        fetch_trigger='open'
+        label=_('Tags')
     )
 
 
@@ -263,8 +252,7 @@ class JournalEntryFilterForm(FilterForm):
         label=_('User'),
         widget=APISelectMultiple(
             api_url='/api/users/users/',
-        ),
-        fetch_trigger='open'
+        )
     )
     assigned_object_type_id = DynamicModelMultipleChoiceField(
         queryset=ContentType.objects.all(),
@@ -272,8 +260,7 @@ class JournalEntryFilterForm(FilterForm):
         label=_('Object Type'),
         widget=APISelectMultiple(
             api_url='/api/extras/content-types/',
-        ),
-        fetch_trigger='open'
+        )
     )
     kind = forms.ChoiceField(
         choices=add_blank_choice(JournalEntryKindChoices),
@@ -310,8 +297,7 @@ class ObjectChangeFilterForm(FilterForm):
         label=_('User'),
         widget=APISelectMultiple(
             api_url='/api/users/users/',
-        ),
-        fetch_trigger='open'
+        )
     )
     changed_object_type_id = DynamicModelMultipleChoiceField(
         queryset=ContentType.objects.all(),
@@ -319,6 +305,5 @@ class ObjectChangeFilterForm(FilterForm):
         label=_('Object Type'),
         widget=APISelectMultiple(
             api_url='/api/extras/content-types/',
-        ),
-        fetch_trigger='open'
+        )
     )

+ 18 - 0
netbox/extras/models/models.py

@@ -229,6 +229,24 @@ class CustomLink(ChangeLoggedModel):
     def get_absolute_url(self):
         return reverse('extras:customlink', args=[self.pk])
 
+    def render(self, context):
+        """
+        Render the CustomLink given the provided context, and return the text, link, and link_target.
+
+        :param context: The context passed to Jinja2
+        """
+        text = render_jinja2(self.link_text, context)
+        if not text:
+            return {}
+        link = render_jinja2(self.link_url, context)
+        link_target = ' target="_blank"' if self.new_window else ''
+
+        return {
+            'text': text,
+            'link': link,
+            'link_target': link_target,
+        }
+
 
 @extras_features('webhooks', 'export_templates')
 class ExportTemplate(ChangeLoggedModel):

+ 10 - 14
netbox/extras/templatetags/custom_links.py

@@ -62,16 +62,14 @@ def custom_links(context, obj):
         # Add non-grouped links
         else:
             try:
-                text_rendered = render_jinja2(cl.link_text, link_context)
-                if text_rendered:
-                    link_rendered = render_jinja2(cl.link_url, link_context)
-                    link_target = ' target="_blank"' if cl.new_window else ''
+                rendered = cl.render(link_context)
+                if rendered:
                     template_code += LINK_BUTTON.format(
-                        link_rendered, link_target, cl.button_class, text_rendered
+                        rendered['link'], rendered['link_target'], cl.button_class, rendered['text']
                     )
             except Exception as e:
-                template_code += '<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{}">' \
-                                 '<i class="mdi mdi-alert"></i> {}</a>\n'.format(e, cl.name)
+                template_code += f'<a class="btn btn-sm btn-outline-dark" disabled="disabled" title="{e}">' \
+                                 f'<i class="mdi mdi-alert"></i> {cl.name}</a>\n'
 
     # Add grouped links to template
     for group, links in group_names.items():
@@ -80,17 +78,15 @@ def custom_links(context, obj):
 
         for cl in links:
             try:
-                text_rendered = render_jinja2(cl.link_text, link_context)
-                if text_rendered:
-                    link_target = ' target="_blank"' if cl.new_window else ''
-                    link_rendered = render_jinja2(cl.link_url, link_context)
+                rendered = cl.render(link_context)
+                if rendered:
                     links_rendered.append(
-                        GROUP_LINK.format(link_rendered, link_target, text_rendered)
+                        GROUP_LINK.format(rendered['link'], rendered['link_target'], rendered['text'])
                     )
             except Exception as e:
                 links_rendered.append(
-                    '<li><a class="dropdown-item" disabled="disabled" title="{}"><span class="text-muted">'
-                    '<i class="mdi mdi-alert"></i> {}</span></a></li>'.format(e, cl.name)
+                    f'<li><a class="dropdown-item" disabled="disabled" title="{e}"><span class="text-muted">'
+                    f'<i class="mdi mdi-alert"></i> {cl.name}</span></a></li>'
                 )
 
         if links_rendered:

+ 4 - 4
netbox/extras/tests/test_views.py

@@ -39,10 +39,10 @@ class CustomFieldTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
         cls.csv_data = (
-            'name,label,type,content_types,weight,filter_logic,choices',
-            'field4,Field 4,text,dcim.site,100,exact,',
-            'field5,Field 5,integer,dcim.site,100,exact,',
-            'field6,Field 6,select,dcim.site,100,exact,"A,B,C"',
+            'name,label,type,content_types,weight,filter_logic,choices,validation_minimum,validation_maximum,validation_regex',
+            'field4,Field 4,text,dcim.site,100,exact,,,,[a-z]{3}',
+            'field5,Field 5,integer,dcim.site,100,exact,,1,100,',
+            'field6,Field 6,select,dcim.site,100,exact,"A,B,C",,,',
         )
 
         cls.bulk_edit_data = {

+ 26 - 4
netbox/extras/views.py

@@ -10,6 +10,7 @@ from rq import Worker
 
 from netbox.views import generic
 from utilities.forms import ConfirmationForm
+from utilities.htmx import is_htmx
 from utilities.tables import paginate_table
 from utilities.utils import copy_safe_request, count_related, normalize_querydict, shallow_compare_dict
 from utilities.views import ContentTypePermissionRequiredMixin
@@ -471,6 +472,7 @@ class ObjectChangeLogView(View):
 class ImageAttachmentEditView(generic.ObjectEditView):
     queryset = ImageAttachment.objects.all()
     model_form = forms.ImageAttachmentForm
+    template_name = 'extras/imageattachment_edit.html'
 
     def alter_obj(self, instance, request, args, kwargs):
         if not instance.pk:
@@ -693,16 +695,26 @@ class ReportResultView(ContentTypePermissionRequiredMixin, View):
 
     def get(self, request, job_result_pk):
         report_content_type = ContentType.objects.get(app_label='extras', model='report')
-        jobresult = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
+        result = get_object_or_404(JobResult.objects.all(), pk=job_result_pk, obj_type=report_content_type)
 
         # Retrieve the Report and attach the JobResult to it
-        module, report_name = jobresult.name.split('.')
+        module, report_name = result.name.split('.')
         report = get_report(module, report_name)
-        report.result = jobresult
+        report.result = result
+
+        # If this is an HTMX request, return only the result HTML
+        if is_htmx(request):
+            response = render(request, 'extras/htmx/report_result.html', {
+                'report': report,
+                'result': result,
+            })
+            if result.completed:
+                response.status_code = 286
+            return response
 
         return render(request, 'extras/report_result.html', {
             'report': report,
-            'result': jobresult,
+            'result': result,
         })
 
 
@@ -820,6 +832,16 @@ class ScriptResultView(ContentTypePermissionRequiredMixin, GetScriptMixin, View)
 
         script = self._get_script(result.name)
 
+        # If this is an HTMX request, return only the result HTML
+        if is_htmx(request):
+            response = render(request, 'extras/htmx/script_result.html', {
+                'script': script,
+                'result': result,
+            })
+            if result.completed:
+                response.status_code = 286
+            return response
+
         return render(request, 'extras/script_result.html', {
             'script': script,
             'result': result,

+ 2 - 0
netbox/ipam/choices.py

@@ -135,6 +135,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
     PROTOCOL_HSRP = 'hsrp'
     PROTOCOL_GLBP = 'glbp'
     PROTOCOL_CARP = 'carp'
+    PROTOCOL_OTHER = 'other'
 
     CHOICES = (
         (PROTOCOL_VRRP2, 'VRRPv2'),
@@ -142,6 +143,7 @@ class FHRPGroupProtocolChoices(ChoiceSet):
         (PROTOCOL_HSRP, 'HSRP'),
         (PROTOCOL_GLBP, 'GLBP'),
         (PROTOCOL_CARP, 'CARP'),
+        (PROTOCOL_OTHER, 'Other'),
     )
 
 

+ 27 - 54
netbox/ipam/forms/filtersets.py

@@ -48,14 +48,12 @@ class VRFFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     import_target_id = DynamicModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         required=False,
-        label=_('Import targets'),
-        fetch_trigger='open'
+        label=_('Import targets')
     )
     export_target_id = DynamicModelMultipleChoiceField(
         queryset=RouteTarget.objects.all(),
         required=False,
-        label=_('Export targets'),
-        fetch_trigger='open'
+        label=_('Export targets')
     )
     tag = TagFilterField(model)
 
@@ -70,14 +68,12 @@ class RouteTargetFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     importing_vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         required=False,
-        label=_('Imported by VRF'),
-        fetch_trigger='open'
+        label=_('Imported by VRF')
     )
     exporting_vrf_id = DynamicModelMultipleChoiceField(
         queryset=VRF.objects.all(),
         required=False,
-        label=_('Exported by VRF'),
-        fetch_trigger='open'
+        label=_('Exported by VRF')
     )
     tag = TagFilterField(model)
 
@@ -110,8 +106,7 @@ class AggregateFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     rir_id = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         required=False,
-        label=_('RIR'),
-        fetch_trigger='open'
+        label=_('RIR')
     )
     tag = TagFilterField(model)
 
@@ -127,14 +122,12 @@ class ASNFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     rir_id = DynamicModelMultipleChoiceField(
         queryset=RIR.objects.all(),
         required=False,
-        label=_('RIR'),
-        fetch_trigger='open'
+        label=_('RIR')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
 
 
@@ -180,14 +173,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         queryset=VRF.objects.all(),
         required=False,
         label=_('Assigned VRF'),
-        null_option='Global',
-        fetch_trigger='open'
+        null_option='Global'
     )
     present_in_vrf_id = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
-        label=_('Present in VRF'),
-        fetch_trigger='open'
+        label=_('Present in VRF')
     )
     status = forms.MultipleChoiceField(
         choices=PrefixStatusChoices,
@@ -197,14 +188,12 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -213,15 +202,13 @@ class PrefixFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'region_id': '$region_id'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     role_id = DynamicModelMultipleChoiceField(
         queryset=Role.objects.all(),
         required=False,
         null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
+        label=_('Role')
     )
     is_pool = forms.NullBooleanField(
         required=False,
@@ -257,8 +244,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         queryset=VRF.objects.all(),
         required=False,
         label=_('Assigned VRF'),
-        null_option='Global',
-        fetch_trigger='open'
+        null_option='Global'
     )
     status = forms.MultipleChoiceField(
         choices=PrefixStatusChoices,
@@ -269,8 +255,7 @@ class IPRangeFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         queryset=Role.objects.all(),
         required=False,
         null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
+        label=_('Role')
     )
     tag = TagFilterField(model)
 
@@ -308,14 +293,12 @@ class IPAddressFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         queryset=VRF.objects.all(),
         required=False,
         label=_('Assigned VRF'),
-        null_option='Global',
-        fetch_trigger='open'
+        null_option='Global'
     )
     present_in_vrf_id = DynamicModelChoiceField(
         queryset=VRF.objects.all(),
         required=False,
-        label=_('Present in VRF'),
-        fetch_trigger='open'
+        label=_('Present in VRF')
     )
     status = forms.MultipleChoiceField(
         choices=IPAddressStatusChoices,
@@ -376,32 +359,27 @@ class VLANGroupFilterForm(CustomFieldModelFilterForm):
     region = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     sitegroup = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
         required=False,
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     location = DynamicModelMultipleChoiceField(
         queryset=Location.objects.all(),
         required=False,
-        label=_('Location'),
-        fetch_trigger='open'
+        label=_('Location')
     )
     rack = DynamicModelMultipleChoiceField(
         queryset=Rack.objects.all(),
         required=False,
-        label=_('Rack'),
-        fetch_trigger='open'
+        label=_('Rack')
     )
     tag = TagFilterField(model)
 
@@ -417,14 +395,12 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     region_id = DynamicModelMultipleChoiceField(
         queryset=Region.objects.all(),
         required=False,
-        label=_('Region'),
-        fetch_trigger='open'
+        label=_('Region')
     )
     site_group_id = DynamicModelMultipleChoiceField(
         queryset=SiteGroup.objects.all(),
         required=False,
-        label=_('Site group'),
-        fetch_trigger='open'
+        label=_('Site group')
     )
     site_id = DynamicModelMultipleChoiceField(
         queryset=Site.objects.all(),
@@ -433,8 +409,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'region': '$region'
         },
-        label=_('Site'),
-        fetch_trigger='open'
+        label=_('Site')
     )
     group_id = DynamicModelMultipleChoiceField(
         queryset=VLANGroup.objects.all(),
@@ -443,8 +418,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         query_params={
             'region': '$region'
         },
-        label=_('VLAN group'),
-        fetch_trigger='open'
+        label=_('VLAN group')
     )
     status = forms.MultipleChoiceField(
         choices=VLANStatusChoices,
@@ -455,8 +429,7 @@ class VLANFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
         queryset=Role.objects.all(),
         required=False,
         null_option='None',
-        label=_('Role'),
-        fetch_trigger='open'
+        label=_('Role')
     )
     vid = forms.IntegerField(
         required=False,

+ 2 - 0
netbox/ipam/forms/models.py

@@ -471,6 +471,8 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
             })
         elif selected_objects:
             self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
+        else:
+            self.instance.assigned_object = None
 
         # Primary IP assignment is only available if an interface has been assigned.
         interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')

+ 11 - 4
netbox/ipam/views.py

@@ -5,18 +5,18 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 
 from dcim.filtersets import InterfaceFilterSet
-from dcim.models import Device, Interface, Site
+from dcim.models import Interface, Site
 from dcim.tables import SiteTable
 from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.utils import count_related
 from virtualization.filtersets import VMInterfaceFilterSet
-from virtualization.models import VirtualMachine, VMInterface
+from virtualization.models import VMInterface
 from . import filtersets, forms, tables
 from .constants import *
 from .models import *
 from .models import ASN
-from .utils import add_requested_prefixes, add_available_vlans
+from .utils import add_requested_prefixes, add_available_ipaddresses, add_available_vlans
 
 
 #
@@ -418,7 +418,7 @@ class PrefixView(generic.ObjectView):
         ).filter(
             prefix__net_contains=str(instance.prefix)
         ).prefetch_related(
-            'site', 'role'
+            'site', 'role', 'tenant'
         )
         parent_prefix_table = tables.PrefixTable(
             list(parent_prefixes),
@@ -502,6 +502,13 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     def get_children(self, request, parent):
         return parent.get_child_ips().restrict(request.user, 'view')
 
+    def prep_table_data(self, request, queryset, parent):
+        show_available = bool(request.GET.get('show_available', 'true') == 'true')
+        if show_available:
+            return add_available_ipaddresses(parent.prefix, queryset, parent.is_pool)
+
+        return queryset
+
     def get_extra_context(self, request, instance):
         return {
             'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",

+ 5 - 5
netbox/netbox/authentication.py

@@ -105,7 +105,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         return settings.REMOTE_AUTH_AUTO_CREATE_USER
 
     def configure_groups(self, user, remote_groups):
-        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger = logging.getLogger('netbox.auth.RemoteUserBackend')
 
         # Assign default groups to the user
         group_list = []
@@ -141,7 +141,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         Return None if ``create_unknown_user`` is ``False`` and a ``User``
         object with the given username is not found in the database.
         """
-        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger = logging.getLogger('netbox.auth.RemoteUserBackend')
         logger.debug(
             f"trying to authenticate {remote_user} with groups {remote_groups}")
         if not remote_user:
@@ -173,7 +173,7 @@ class RemoteUserBackend(_RemoteUserBackend):
             return None
 
     def _is_superuser(self, user):
-        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger = logging.getLogger('netbox.auth.RemoteUserBackend')
         superuser_groups = settings.REMOTE_AUTH_SUPERUSER_GROUPS
         logger.debug(f"Superuser Groups: {superuser_groups}")
         superusers = settings.REMOTE_AUTH_SUPERUSERS
@@ -189,7 +189,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         return bool(result)
 
     def _is_staff(self, user):
-        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger = logging.getLogger('netbox.auth.RemoteUserBackend')
         staff_groups = settings.REMOTE_AUTH_STAFF_GROUPS
         logger.debug(f"Superuser Groups: {staff_groups}")
         staff_users = settings.REMOTE_AUTH_STAFF_USERS
@@ -204,7 +204,7 @@ class RemoteUserBackend(_RemoteUserBackend):
         return bool(result)
 
     def configure_user(self, request, user):
-        logger = logging.getLogger('netbox.authentication.RemoteUserBackend')
+        logger = logging.getLogger('netbox.auth.RemoteUserBackend')
         if not settings.REMOTE_AUTH_GROUP_SYNC_ENABLED:
             # Assign default groups to the user
             group_list = []

+ 1 - 1
netbox/netbox/settings.py

@@ -19,7 +19,7 @@ from netbox.config import PARAMS
 # Environment setup
 #
 
-VERSION = '3.1.2'
+VERSION = '3.1.3'
 
 # Hostname
 HOSTNAME = platform.node()

+ 0 - 1
netbox/project-static/bundle.js

@@ -40,7 +40,6 @@ async function bundleGraphIQL() {
 async function bundleNetBox() {
   const entryPoints = {
     netbox: 'src/index.ts',
-    jobs: 'src/jobs.ts',
     lldp: 'src/device/lldp.ts',
     config: 'src/device/config.ts',
     status: 'src/device/status.ts',

Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/jobs.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 2
netbox/project-static/dist/jobs.js.map


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox-print.css


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js


Разница между файлами не показана из-за своего большого размера
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 0 - 9
netbox/project-static/src/forms/elements.ts

@@ -35,11 +35,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
   for (const element of form.querySelectorAll<FormControls>('*[name]')) {
     if (!element.validity.valid) {
       invalids.add(element.name);
-
-      // If the field is invalid, but contains the .is-valid class, remove it.
-      if (element.classList.contains('is-valid')) {
-        element.classList.remove('is-valid');
-      }
       // If the field is invalid, but doesn't contain the .is-invalid class, add it.
       if (!element.classList.contains('is-invalid')) {
         element.classList.add('is-invalid');
@@ -49,10 +44,6 @@ function handleFormSubmit(event: Event, form: HTMLFormElement): void {
       if (element.classList.contains('is-invalid')) {
         element.classList.remove('is-invalid');
       }
-      // If the field is valid, but doesn't contain the .is-valid class, add it.
-      if (!element.classList.contains('is-valid')) {
-        element.classList.add('is-valid');
-      }
     }
   }
 

+ 0 - 32
netbox/project-static/src/global.d.ts

@@ -98,38 +98,6 @@ type APISecret = {
   url: string;
 };
 
-type JobResultLog = {
-  message: string;
-  status: 'success' | 'warning' | 'danger' | 'info';
-};
-
-type JobStatus = {
-  label: string;
-  value: 'completed' | 'failed' | 'errored' | 'running';
-};
-
-type APIJobResult = {
-  completed: string;
-  created: string;
-  data: {
-    log: JobResultLog[];
-    output: string;
-  };
-  display: string;
-  id: number;
-  job_id: string;
-  name: string;
-  obj_type: string;
-  status: JobStatus;
-  url: string;
-  user: {
-    display: string;
-    username: string;
-    id: number;
-    url: string;
-  };
-};
-
 type APIUserConfig = {
   tables: { [k: string]: { columns: string[]; available_columns: string[] } };
   [k: string]: unknown;

+ 23 - 0
netbox/project-static/src/htmx.ts

@@ -0,0 +1,23 @@
+import { getElements, isTruthy } from './util';
+import { initButtons } from './buttons';
+
+function initDepedencies(): void {
+  for (const init of [initButtons]) {
+    init();
+  }
+}
+
+/**
+ * Hook into HTMX's event system to reinitialize specific native event listeners when HTMX swaps
+ * elements.
+ */
+export function initHtmx(): void {
+  for (const element of getElements('[hx-target]')) {
+    const targetSelector = element.getAttribute('hx-target');
+    if (isTruthy(targetSelector)) {
+      for (const target of getElements(targetSelector)) {
+        target.addEventListener('htmx:afterSettle', initDepedencies);
+      }
+    }
+  }
+}

+ 0 - 104
netbox/project-static/src/jobs.ts

@@ -1,104 +0,0 @@
-import { createToast } from './bs';
-import { apiGetBase, hasError, getNetboxData } from './util';
-
-let timeout: number = 1000;
-
-interface JobInfo {
-  url: Nullable<string>;
-  complete: boolean;
-}
-
-/**
- * Mimic the behavior of setTimeout() in an async function.
- */
-function asyncTimeout(ms: number) {
-  return new Promise(resolve => setTimeout(resolve, ms));
-}
-
-/**
- * Job ID & Completion state are only from Django context, which can only be used from the HTML
- * template. Hidden elements are present in the template to provide access to these values from
- * JavaScript.
- */
-function getJobInfo(): JobInfo {
-  let complete = false;
-
-  // Determine the API URL for the job status
-  const url = getNetboxData('data-job-url');
-
-  // Determine the job completion status, if present. If the job is not complete, the value will be
-  // "None". Otherwise, it will be a stringified date.
-  const jobComplete = getNetboxData('data-job-complete');
-  if (typeof jobComplete === 'string' && jobComplete.toLowerCase() !== 'none') {
-    complete = true;
-  }
-  return { url, complete };
-}
-
-/**
- * Update the job status label element based on the API response.
- */
-function updateLabel(status: JobStatus) {
-  const element = document.querySelector<HTMLSpanElement>('#pending-result-label > span.badge');
-  if (element !== null) {
-    let labelClass = 'secondary';
-    switch (status.value) {
-      case 'failed' || 'errored':
-        labelClass = 'danger';
-        break;
-      case 'running':
-        labelClass = 'warning';
-        break;
-      case 'completed':
-        labelClass = 'success';
-        break;
-    }
-    element.setAttribute('class', `badge bg-${labelClass}`);
-    element.innerText = status.label;
-  }
-}
-
-/**
- * Recursively check the job's status.
- * @param url API URL for job result
- */
-async function checkJobStatus(url: string) {
-  const res = await apiGetBase<APIJobResult>(url);
-  if (hasError(res)) {
-    // If the response is an API error, display an error message and stop checking for job status.
-    const toast = createToast('danger', 'Error', res.error);
-    toast.show();
-    return;
-  } else {
-    // Update the job status label.
-    updateLabel(res.status);
-
-    // If the job is complete, reload the page.
-    if (['completed', 'failed', 'errored'].includes(res.status.value)) {
-      location.reload();
-      return;
-    } else {
-      // Otherwise, keep checking the job's status, backing off 1 second each time, until a 10
-      // second interval is reached.
-      if (timeout < 10000) {
-        timeout += 1000;
-      }
-      await Promise.all([checkJobStatus(url), asyncTimeout(timeout)]);
-    }
-  }
-}
-
-function initJobs() {
-  const { url, complete } = getJobInfo();
-
-  if (url !== null && !complete) {
-    // If there is a job ID and it is not completed, check for the job's status.
-    Promise.resolve(checkJobStatus(url));
-  }
-}
-
-if (document.readyState !== 'loading') {
-  initJobs();
-} else {
-  document.addEventListener('DOMContentLoaded', initJobs);
-}

+ 2 - 0
netbox/project-static/src/netbox.ts

@@ -12,6 +12,7 @@ import { initInterfaceTable } from './tables';
 import { initSideNav } from './sidenav';
 import { initRackElevation } from './racks';
 import { initLinks } from './links';
+import { initHtmx } from './htmx';
 
 function initDocument(): void {
   for (const init of [
@@ -29,6 +30,7 @@ function initDocument(): void {
     initSideNav,
     initRackElevation,
     initLinks,
+    initHtmx,
   ]) {
     init();
   }

+ 1 - 1
netbox/project-static/src/select/api/apiSelect.ts

@@ -251,7 +251,7 @@ export class APISelect {
     } else if (collapse !== null) {
       this.trigger = 'collapse';
     } else {
-      this.trigger = 'load';
+      this.trigger = 'open';
     }
 
     switch (this.trigger) {

+ 13 - 0
netbox/project-static/styles/netbox.scss

@@ -965,6 +965,19 @@ div.card-overlay {
   max-width: unset;
 }
 
+/* Rendered Markdown */
+.rendered-markdown table {
+  width: 100%;
+}
+.rendered-markdown th {
+  border-bottom: 2px solid #dddddd;
+  padding: 8px;
+}
+.rendered-markdown td {
+  border-top: 1px solid #dddddd;
+  padding: 8px;
+}
+
 // Preformatted text blocks
 td pre {
   margin-bottom: 0

+ 1 - 0
netbox/project-static/styles/theme-light.scss

@@ -8,6 +8,7 @@ $theme-colors: map-merge(
   $theme-colors,
   (
     'primary': #337ab7,
+    'info': #54d6f0,
     'red': $red-500,
     'yellow': $yellow-500,
     'green': $green-500,

+ 5 - 5
netbox/templates/base/base.html

@@ -104,23 +104,23 @@
     {# Static resources #}
     <link
       rel="stylesheet"
-      href="{% static 'netbox-external.css'%}"
+      href="{% static 'netbox-external.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-external.css'"
     />
     <link
       rel="stylesheet"
-      href="{% static 'netbox-light.css'%}"
+      href="{% static 'netbox-light.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-light.css'"
     />
     <link
       rel="stylesheet"
-      href="{% static 'netbox-dark.css'%}"
+      href="{% static 'netbox-dark.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-dark.css'"
     />
     <link
       rel="stylesheet"
       media="print"
-      href="{% static 'netbox-print.css'%}"
+      href="{% static 'netbox-print.css'%}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-print.css'"
     />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
@@ -129,7 +129,7 @@
     {# Javascript #}
     <script
       type="text/javascript"
-      src="{% static 'netbox.js' %}"
+      src="{% static 'netbox.js' %}?v={{ settings.VERSION }}"
       onerror="window.location='{% url 'media_failure' %}?filename=netbox.js'">
     </script>
 

+ 2 - 3
netbox/templates/base/layout.html

@@ -1,8 +1,7 @@
 {# Base layout for the core NetBox UI w/navbar and page content #}
 {% extends 'base/base.html' %}
 {% load helpers %}
-{% load nav %}
-{% load search_options %}
+{% load search %}
 {% load static %}
 
 {% block layout %}
@@ -21,7 +20,7 @@
         </div>
 
         {# Top bar #}
-        <nav class="navbar navbar-light sticky-top flex-md-nowrap ps-6 p-3 search container-fluid noprint">
+        <nav class="navbar navbar-light sticky-top flex-md-nowrap p-1 mb-3 search container-fluid border-bottom noprint">
 
             {# Mobile Navigation #}
             <div class="nav-mobile">

+ 1 - 1
netbox/templates/base/sidenav.html

@@ -1,4 +1,4 @@
-{% load nav %}
+{% load navigation %}
 {% load static %}
 
 <nav class="sidenav noprint" id="sidenav" data-simplebar>

+ 1 - 1
netbox/templates/circuits/circuit_terminations_swap.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 
 {% block title %}Swap Circuit Terminations{% endblock %}
 

+ 1 - 1
netbox/templates/circuits/inc/circuit_termination.html

@@ -10,7 +10,7 @@
                 </a>
             {% endif %}
             {% if termination and perms.circuits.change_circuittermination %}
-                <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-sm btn-yellow lh-1">
+                <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}" class="btn btn-sm btn-warning lh-1">
                     <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
                 </a>
                 <a href="{% url 'circuits:circuit_terminations_swap' pk=object.pk %}" class="btn btn-sm btn-primary lh-1">

+ 2 - 2
netbox/templates/circuits/provider.html

@@ -41,11 +41,11 @@
                     </tr>
                     <tr>
                         <th scope="row">NOC Contact</th>
-                        <td class="rendered-markdown">{{ object.noc_contact|render_markdown|placeholder }}</td>
+                        <td>{{ object.noc_contact|render_markdown|placeholder }}</td>
                     </tr>
                     <tr>
                         <th scope="row">Admin Contact</th>
-                        <td class="rendered-markdown">{{ object.admin_contact|render_markdown|placeholder }}</td>
+                        <td>{{ object.admin_contact|render_markdown|placeholder }}</td>
                     </tr>
                     <tr>
                         <th scope="row">Circuits</th>

+ 1 - 1
netbox/templates/dcim/bulk_disconnect.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load helpers %}
 
 {% block title %}Disconnect {{ obj_type_plural|bettertitle }}{% endblock %}

+ 1 - 1
netbox/templates/dcim/consoleport_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete console port {{ consoleport }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/consoleserverport_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete console server port {{ consoleserverport }}?{% endblock %}

+ 27 - 27
netbox/templates/dcim/device/base.html

@@ -95,74 +95,74 @@
         </a>
     </li>
 
-    {% with interface_count=object.interfaces_count %}
-        {% if interface_count %}
+    {% with tab_name='interfaces' interface_count=object.interfaces_count %}
+        {% if active_tab == tab_name or interface_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'interfaces' %} active{% endif %}" href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with frontport_count=object.frontports.count %}
-        {% if frontport_count %}
+    {% with tab_name='front-ports' frontport_count=object.frontports.count %}
+        {% if active_tab == tab_name or frontport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'front-ports' %} active{% endif %}" href="{% url 'dcim:device_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with rearport_count=object.rearports.count %}
-        {% if rearport_count %}
+    {% with tab_name='rear-ports' rearport_count=object.rearports.count %}
+        {% if active_tab == tab_name or rearport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'rear-ports' %} active{% endif %}" href="{% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with consoleport_count=object.consoleports.count %}
-        {% if consoleport_count %}
+    {% with tab_name='console-ports' consoleport_count=object.consoleports.count %}
+        {% if active_tab == tab_name or consoleport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'console-ports' %} active{% endif %}" href="{% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with consoleserverport_count=object.consoleserverports.count %}
-        {% if consoleserverport_count %}
+    {% with tab_name='console-server-ports' consoleserverport_count=object.consoleserverports.count %}
+        {% if active_tab == tab_name or consoleserverport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'console-server-ports' %} active{% endif %}" href="{% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with powerport_count=object.powerports.count %}
-        {% if powerport_count %}
+    {% with tab_name='power-ports' powerport_count=object.powerports.count %}
+        {% if active_tab == tab_name or powerport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'power-ports' %} active{% endif %}" href="{% url 'dcim:device_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with poweroutlet_count=object.poweroutlets.count %}
-        {% if poweroutlet_count %}
+    {% with tab_name='power-outlets' poweroutlet_count=object.poweroutlets.count %}
+        {% if active_tab == tab_name or poweroutlet_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'power-outlets' %} active{% endif %}" href="{% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with devicebay_count=object.devicebays.count %}
-        {% if devicebay_count %}
+    {% with tab_name='device-bays' devicebay_count=object.devicebays.count %}
+        {% if active_tab == tab_name or devicebay_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'device-bays' %} active{% endif %}" href="{% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with inventoryitem_count=object.inventoryitems.count %}
-        {% if inventoryitem_count %}
+    {% with tab_name='inventory-items' inventoryitem_count=object.inventoryitems.count %}
+        {% if active_tab == tab_name or inventoryitem_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'inventory' %} active{% endif %}" href="{% url 'dcim:device_inventory' pk=object.pk %}">Inventory {% badge inventoryitem_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:device_inventory' pk=object.pk %}">Inventory {% badge inventoryitem_count %}</a>
             </li>
         {% endif %}
     {% endwith %}

+ 4 - 4
netbox/templates/dcim/device/devicebays.html

@@ -17,22 +17,22 @@
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_devicebay %}
-                <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-outline-warning btn-sm">
+                <button type="submit" name="_rename" formaction="{% url 'dcim:devicebay_bulk_rename' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-warning btn-sm">
                     <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
                 </button>
-                <button type="submit" name="_edit" formaction="{% url 'dcim:devicebay_bulk_edit' %}?device={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-warning btn-sm">
+                <button type="submit" name="_edit" formaction="{% url 'dcim:devicebay_bulk_edit' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-warning btn-sm">
                     <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
                 </button>
             {% endif %}
             {% if perms.dcim.delete_devicebay %}
-                <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-outline-danger btn-sm">
+                <button type="submit" formaction="{% url 'dcim:devicebay_bulk_delete' %}?return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-outline-danger btn-sm">
                     <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
                 </button>
             {% endif %}
         </div>
         {% if perms.dcim.add_devicebay %}
             <div class="bulk-button-group">
-                <a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_devicebays" class="btn btn-primary btn-sm">
+                <a href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}" class="btn btn-primary btn-sm">
                     <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Device Bays
                 </a>
             </div>

+ 1 - 1
netbox/templates/dcim/devicebay_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete device bay {{ devicebay }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/devicebay_depopulate.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Remove {{ device_bay.installed_device }} from {{ device_bay }}?{% endblock %}

+ 1 - 7
netbox/templates/dcim/devicerole.html

@@ -40,13 +40,7 @@
           </tr>
           <tr>
             <th scope="row">VM Role</th>
-            <td>
-              {% if object.vm_role %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.vm_role %}</td>
           </tr>
           <tr>
             <th scope="row">Devices</th>

+ 1 - 7
netbox/templates/dcim/devicetype.html

@@ -33,13 +33,7 @@
                         </tr>
                         <tr>
                             <td>Full Depth</td>
-                            <td>
-                                {% if object.is_full_depth %}
-                                    <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-                                {% else %}
-                                    <i class="mdi mdi-close-thick text-danger" title="No"></i>
-                                {% endif %}
-                            </td>
+                            <td>{% checkmark object.is_full_depth %}</td>
                         </tr>
                         <tr>
                             <td>Parent/Child</td>

+ 32 - 32
netbox/templates/dcim/devicetype/base.html

@@ -18,28 +18,28 @@
       </button>
       <ul class="dropdown-menu">
         {% if perms.dcim.add_consoleporttemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:consoleporttemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_consoleports' pk=object.pk %}">Console Ports</a></li>
         {% endif %}
         {% if perms.dcim.add_consoleserverporttemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:consoleserverporttemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_consoleserverports' pk=object.pk %}">Console Server Ports</a></li>
         {% endif %}
         {% if perms.dcim.add_powerporttemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:powerporttemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_powerports">Power Ports</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:powerporttemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_powerports' pk=object.pk %}">Power Ports</a></li>
         {% endif %}
         {% if perms.dcim.add_poweroutlettemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_poweroutlets">Power Outlets</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:poweroutlettemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_poweroutlets' pk=object.pk %}">Power Outlets</a></li>
         {% endif %}
         {% if perms.dcim.add_interfacetemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_interfaces">Interfaces</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:interfacetemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_interfaces' pk=object.pk %}">Interfaces</a></li>
         {% endif %}
         {% if perms.dcim.add_frontporttemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:frontporttemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_frontports">Front Ports</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:frontporttemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_frontports' pk=object.pk %}">Front Ports</a></li>
         {% endif %}
         {% if perms.dcim.add_rearporttemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_rearports' pk=object.pk %}">Rear Ports</a></li>
         {% endif %}
         {% if perms.dcim.add_devicebaytemplate %}
-          <li><a class="dropdown-item" href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_devicebays">Device Bays</a></li>
+          <li><a class="dropdown-item" href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays</a></li>
         {% endif %}
       </ul>
     </div>
@@ -53,66 +53,66 @@
         </a>
     </li>
 
-    {% with interface_count=object.interfacetemplates.count %}
-        {% if interface_count %}
+    {% with tab_name='interface-templates' interface_count=object.interfacetemplates.count %}
+        {% if active_tab == tab_name or interface_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'interface-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with frontport_count=object.frontporttemplates.count %}
-        {% if frontport_count %}
+    {% with tab_name='front-port-templates' frontport_count=object.frontporttemplates.count %}
+        {% if active_tab == tab_name or frontport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'front-port-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with rearport_count=object.rearporttemplates.count %}
-        {% if rearport_count %}
+    {% with tab_name='rear-port-templates' rearport_count=object.rearporttemplates.count %}
+        {% if active_tab == tab_name or rearport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'rear-port-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with consoleport_count=object.consoleporttemplates.count %}
-        {% if consoleport_count %}
+    {% with tab_name='console-port-templates' consoleport_count=object.consoleporttemplates.count %}
+        {% if active_tab == tab_name or consoleport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'console-port-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with consoleserverport_count=object.consoleserverporttemplates.count %}
-        {% if consoleserverport_count %}
+    {% with tab_name='console-server-port-templates' consoleserverport_count=object.consoleserverporttemplates.count %}
+        {% if active_tab == tab_name or consoleserverport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'console-server-port-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with powerport_count=object.powerporttemplates.count %}
-        {% if powerport_count %}
+    {% with tab_name='power-port-templates' powerport_count=object.powerporttemplates.count %}
+        {% if active_tab == tab_name or powerport_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'power-port-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with poweroutlet_count=object.poweroutlettemplates.count %}
-        {% if poweroutlet_count %}
+    {% with tab_name='power-outlet-templates' poweroutlet_count=object.poweroutlettemplates.count %}
+        {% if active_tab == tab_name or poweroutlet_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'power-outlet-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
             </li>
         {% endif %}
     {% endwith %}
 
-    {% with devicebay_count=object.devicebaytemplates.count %}
-        {% if devicebay_count %}
+    {% with tab_name='device-bay-templates' devicebay_count=object.devicebaytemplates.count %}
+        {% if active_tab == tab_name or devicebay_count %}
             <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'device-bay-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
             </li>
         {% endif %}
     {% endwith %}

+ 4 - 4
netbox/templates/dcim/devicetype/component_templates.html

@@ -13,18 +13,18 @@
             </div>
             <div class="card-footer noprint">
                 {% if table.rows %}
-                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning">
+                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ return_url }}" class="btn btn-sm btn-warning">
                         <span class="mdi mdi-pencil-outline" aria-hidden="true"></span> Rename
                     </button>
-                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning">
+                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ return_url }}" class="btn btn-sm btn-warning">
                         <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
                     </button>
-                    <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-danger">
+                    <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ return_url }}" class="btn btn-sm btn-danger">
                         <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
                     </button>
                 {% endif %}
                 <div class="float-end">
-                    <a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-sm">
+                    <a href="{% url table.Meta.model|viewname:"add" %}?device_type={{ object.pk }}&return_url={{ return_url }}" class="btn btn-primary btn-sm">
                         <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
                         Add {{ title }}
                     </a>

+ 2 - 14
netbox/templates/dcim/interface.html

@@ -48,23 +48,11 @@
                         </tr>
                         <tr>
                             <th scope="row">Enabled</th>
-                            <td>
-                                {% if object.enabled %}
-                                    <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-                                {% else %}
-                                    <i class="mdi mdi-close-thick text-danger" title="No"></i>
-                                {% endif %}
-                            </td>
+                            <td>{% checkmark object.enabled %}</td>
                         </tr>
                         <tr>
                             <th scope="row">Management Only</th>
-                            <td>
-                                {% if object.mgmt_only %}
-                                    <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-                                {% else %}
-                                    <i class="mdi mdi-close-thick text-danger" title="No"></i>
-                                {% endif %}
-                            </td>
+                            <td>{% checkmark object.mgmt_only %}</td>
                         </tr>
                         <tr>
                             <th scope="row">Parent</th>

+ 1 - 1
netbox/templates/dcim/interface_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete interface {{ interface }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/inventoryitem_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete inventory item {{ inventoryitem }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/poweroutlet_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete power outlet {{ poweroutlet }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/powerport_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete power port {{ powerport }}?{% endblock %}

+ 1 - 1
netbox/templates/dcim/virtualchassis.html

@@ -65,7 +65,7 @@
                             </td>
                             <td>
                               {% if object.master == vc_member %}
-                                <i class="mdi mdi-check-bold text-success"></i>
+                                {% checkmark True %}
                               {% endif %}
                             </td>
                             <td>

+ 1 - 1
netbox/templates/dcim/virtualchassis_remove_member.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Remove Virtual Chassis Member?{% endblock %}

+ 1 - 7
netbox/templates/extras/customfield.html

@@ -29,13 +29,7 @@
           </tr>
           <tr>
             <th scope="row">Required</th>
-            <td>
-              {% if object.required %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.required %}</td>
           </tr>
           <tr>
             <th scope="row">Weight</th>

+ 1 - 7
netbox/templates/extras/customlink.html

@@ -33,13 +33,7 @@
           </tr>
           <tr>
             <th scope="row">New Window</th>
-            <td>
-              {% if object.new_window %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.new_window %}</td>
           </tr>
         </table>
       </div>

+ 1 - 7
netbox/templates/extras/exporttemplate.html

@@ -40,13 +40,7 @@
           </tr>
           <tr>
             <th scope="row">Attachment</th>
-            <td>
-              {% if object.as_attachment %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.as_attachment %}</td>
           </tr>
         </table>
       </div>

+ 73 - 0
netbox/templates/extras/htmx/report_result.html

@@ -0,0 +1,73 @@
+{% load helpers %}
+
+<p>
+  Initiated: <strong>{{ result.created|annotated_date }}</strong>
+  {% if result.completed %}
+    Duration: <strong>{{ result.duration }}</strong>
+  {% endif %}
+  <span id="pending-result-label">{% include 'extras/inc/job_label.html' %}</span>
+</p>
+{% if result.completed %}
+  <div class="card">
+    <h5 class="card-header">Report Methods</h5>
+    <div class="card-body">
+      <table class="table table-hover">
+        {% for method, data in result.data.items %}
+          <tr>
+            <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
+            <td class="text-end report-stats">
+              <span class="badge bg-success">{{ data.success }}</span>
+              <span class="badge bg-info">{{ data.info }}</span>
+              <span class="badge bg-warning">{{ data.warning }}</span>
+              <span class="badge bg-danger">{{ data.failure }}</span>
+            </td>
+          </tr>
+        {% endfor %}
+      </table>
+    </div>
+  </div>
+  <div class="card">
+    <h5 class="card-header">Report Results</h5>
+    <div class="card-body">
+      <table class="table table-hover report">
+        <thead>
+          <tr class="table-headings">
+            <th>Time</th>
+            <th>Level</th>
+            <th>Object</th>
+            <th>Message</th>
+          </tr>
+        </thead>
+        <tbody>
+          {% for method, data in result.data.items %}
+            <tr>
+              <th colspan="4" style="font-family: monospace">
+                <a name="{{ method }}"></a>{{ method }}
+              </th>
+            </tr>
+            {% for time, level, obj, url, message in data.log %}
+              <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
+                <td>{{ time }}</td>
+                <td>
+                  <label class="badge bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
+                </td>
+                <td>
+                  {% if obj and url %}
+                    <a href="{{ url }}">{{ obj }}</a>
+                  {% elif obj %}
+                    {{ obj }}
+                  {% else %}
+                    <span class="muted">&mdash;</span>
+                  {% endif %}
+                </td>
+                <td class="rendered-markdown">{{ message|render_markdown }}</td>
+              </tr>
+            {% endfor %}
+          {% endfor %}
+        </tbody>
+      </table>
+    </div>
+  </div>
+{% else %}
+  {% include 'extras/inc/result_pending.html' %}
+{% endif %}

+ 50 - 0
netbox/templates/extras/htmx/script_result.html

@@ -0,0 +1,50 @@
+{% load helpers %}
+{% load log_levels %}
+
+<p>
+  Initiated: <strong>{{ result.created|annotated_date }}</strong>
+  {% if result.completed %}
+    Duration: <strong>{{ result.duration }}</strong>
+  {% endif %}
+  <span id="pending-result-label">{% include 'extras/inc/job_label.html' %}</span>
+</p>
+{% if result.completed %}
+  <div class="card mb-3">
+    <h5 class="card-header">Script Log</h5>
+    <div class="card-body">
+      <table class="table table-hover panel-body">
+        <tr>
+          <th>Line</th>
+          <th>Level</th>
+          <th>Message</th>
+        </tr>
+        {% for log in result.data.log %}
+          <tr>
+            <td>{{ forloop.counter }}</td>
+            <td>{% log_level log.status %}</td>
+            <td class="rendered-markdown">{{ log.message|render_markdown }}</td>
+          </tr>
+        {% empty %}
+          <tr>
+            <td colspan="3" class="text-center text-muted">
+              No log output
+            </td>
+          </tr>
+        {% endfor %}
+      </table>
+    </div>
+    {% if execution_time %}
+      <div class="card-footer text-end text-muted">
+        <small>Exec Time: {{ execution_time|floatformat:3 }}s</small>
+      </div>
+    {% endif %}
+  </div>
+  <h4>Output</h4>
+  {% if result.data.output %}
+    <pre class="block">{{ result.data.output }}</pre>
+  {% else %}
+    <p class="text-muted">None</p>
+  {% endif %}
+{% else %}
+  {% include 'extras/inc/result_pending.html' %}
+{% endif %}

+ 16 - 0
netbox/templates/extras/imageattachment_edit.html

@@ -0,0 +1,16 @@
+{% extends 'generic/object_edit.html' %}
+{% load helpers %}
+
+{% block form_fields %}
+  <div class="row mb-3">
+    <label class="col-sm-3 col-form-label text-lg-end required">
+      {{ obj.parent|meta:"verbose_name"|bettertitle }}
+    </label>
+    <div class="col-sm-9">
+      <div class="form-control-plaintext">
+        <a href="{{ obj.parent.get_absolute_url }}" class="">{{ obj.parent }}</a>
+      </div>
+    </div>
+  </div>
+  {{ block.super }}
+{% endblock form_fields %}

+ 6 - 0
netbox/templates/extras/inc/result_pending.html

@@ -0,0 +1,6 @@
+{# Indicates that a job result is still pending; used for HTMX requests #}
+<div class="spinner-border float-start me-1" id="spinner" role="status">
+  <span class="visually-hidden">Loading...</span>
+</div>
+<h3>Results pending...</h3>
+<small class="text-muted">Last updated {% now "H:i:s" %}</small>

+ 4 - 94
netbox/templates/extras/report_result.html

@@ -1,99 +1,9 @@
 {% extends 'extras/report.html' %}
-{% load helpers %}
-{% load static %}
-
-{% block head %}
-<script src="{% static 'jobs.js' %}?v{{ settings.VERSION }}"
-        onerror="window.location='{% url 'media_failure' %}?filename=jobs.js'"></script>
-{% endblock %}
 
 {% block content-wrapper %}
-<div class="row px-3">
-    <div class="col col-md-12">
-        <p>
-            Run: <strong>{{ result.created|annotated_date }}</strong>
-            {% if result.completed %}
-                Duration: <strong>{{ result.duration }}</strong>
-            {% else %}
-                <div class="spinner-border" role="status">
-                    <span class="visually-hidden">Loading...</span>
-                </div>
-            {% endif %}
-            <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
-        </p>
-        {% if result.completed %}
-            <div class="card">
-                <h5 class="card-header">
-                    Report Methods
-                </h5>
-                <div class="card-body">
-                    <table class="table table-hover">
-                        {% for method, data in result.data.items %}
-                            <tr>
-                                <td class="font-monospace"><a href="#{{ method }}">{{ method }}</a></td>
-                                <td class="text-end report-stats">
-                                    <span class="badge bg-success">{{ data.success }}</span>
-                                    <span class="badge bg-info">{{ data.info }}</span>
-                                    <span class="badge bg-warning">{{ data.warning }}</span>
-                                    <span class="badge bg-danger">{{ data.failure }}</span>
-                                </td>
-                            </tr>
-                        {% endfor %}
-                    </table>
-                </div>
-            </div>
-            <div class="card">
-                <h5 class="card-header">
-                    Report Results
-                </h5>
-                <div class="card-body">
-                    <table class="table table-hover report">
-                        <thead>
-                            <tr class="table-headings">
-                                <th>Time</th>
-                                <th>Level</th>
-                                <th>Object</th>
-                                <th>Message</th>
-                            </tr>
-                        </thead>
-                        <tbody>
-                            {% for method, data in result.data.items %}
-                                <tr>
-                                    <th colspan="4" style="font-family: monospace">
-                                        <a name="{{ method }}"></a>{{ method }}
-                                    </th>
-                                </tr>
-                                {% for time, level, obj, url, message in data.log %}
-                                    <tr class="{% if level == 'failure' %}danger{% elif level %}{{ level }}{% endif %}">
-                                        <td>{{ time }}</td>
-                                        <td>
-                                            <label class="badge bg-{% if level == 'failure' %}danger{% else %}{{ level }}{% endif %}">{{ level|title }}</label>
-                                        </td>
-                                        <td>
-                                            {% if obj and url %}
-                                                <a href="{{ url }}">{{ obj }}</a>
-                                            {% elif obj %}
-                                                {{ obj }}
-                                            {% else %}
-                                                <span class="muted">&mdash;</span>
-                                            {% endif %}
-                                        </td>
-                                        <td class="rendered-markdown">{{ message|render_markdown }}</td>
-                                    </tr>
-                                {% endfor %}
-                            {% endfor %}
-                        </tbody>
-                    </table>
-                </div>
-            </div>
-        {% else %}
-            <div class="well">Pending results</div>
-        {% endif %}
+  <div class="row px-3">
+    <div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:report_result' job_result_pk=result.pk %}" hx-trigger="every 3s"{% endif %}>
+      {% include 'extras/htmx/report_result.html' %}
     </div>
-</div>
-{% endblock %}
-
-{% block data %}
-<span data-job-url="{% url 'extras-api:jobresult-detail' pk=result.pk %}"></span>
-<span data-job-complete="{{ result.completed }}"></span>
+  </div>
 {% endblock %}

+ 26 - 95
netbox/templates/extras/script_result.html

@@ -1,117 +1,48 @@
 {% extends 'base/layout.html' %}
 {% load helpers %}
-{% load form_helpers %}
-{% load log_levels %}
-{% load static %}
-
-{% block head %}
-<script src="{% static 'jobs.js' %}?v{{ settings.VERSION }}"
-        onerror="window.location='{% url 'media_failure' %}?filename=jobs.js'"></script>
-{% endblock %}
 
 {% block title %}{{ script }}{% endblock %}
 
 {% block subtitle %}
   {{ script.Meta.description|render_markdown }}
-  <span id="pending-result-label">{% include 'extras/inc/job_label.html' with result=result %}</span>
 {% endblock %}
 
 {% block header %}
   <div class="row noprint">
-      <div class="col col-md-12">
-          <nav class="breadcrumb-container px-3" aria-label="breadcrumb">
-              <ol class="breadcrumb">
-                  <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
-                  <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
-                  <li class="breadcrumb-item"><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
-                  <li class="breadcrumb-item">{{ result.created|annotated_date }}</li>
-              </ol>
-          </nav>
-      </div>
+    <div class="col col-md-12">
+      <nav class="breadcrumb-container px-3" aria-label="breadcrumb">
+        <ol class="breadcrumb">
+          <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}">Scripts</a></li>
+          <li class="breadcrumb-item"><a href="{% url 'extras:script_list' %}#module.{{ script.module }}">{{ script.module|bettertitle }}</a></li>
+          <li class="breadcrumb-item"><a href="{% url 'extras:script' module=script.module name=class_name %}">{{ script }}</a></li>
+          <li class="breadcrumb-item">{{ result.created|annotated_date }}</li>
+        </ol>
+      </nav>
+    </div>
   </div>
   {{ block.super }}
 {% endblock header %}
 
 {% block content-wrapper %}
   <ul class="nav nav-tabs px-3" role="tablist">
-      <li class="nav-item" role="presentation">
-          <a href="#log" role="tab" data-bs-toggle="tab" class="nav-link active">Log</a>
-      </li>
-      <li class="nav-item" role="presentation">
-          <a href="#output" role="tab" data-bs-toggle="tab" class="nav-link">Output</a>
-      </li>
-      <li class="nav-item" role="presentation">
-          <a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
-      </li>
+    <li class="nav-item" role="presentation">
+      <a href="#log" role="tab" data-bs-toggle="tab" class="nav-link active">Log</a>
+    </li>
+    <li class="nav-item" role="presentation">
+      <a href="#source" role="tab" data-bs-toggle="tab" class="nav-link">Source</a>
+    </li>
   </ul>
   <div class="tab-content mb-3">
-      <p>
-          Run: <strong>{{ result.created|annotated_date }}</strong>
-          {% if result.completed %}
-              Duration: <strong>{{ result.duration }}</strong>
-          {% else %}
-              <div class="spinner-border" role="status">
-                  <span class="visually-hidden">Loading...</span>
-              </div>
-          {% endif %}
-      </p>
-      <div role="tabpanel" class="tab-pane active" id="log">
-          {% if result.completed %}
-              <div class="row">
-                  <div class="col col-md-12">
-                      <div class="card">
-                          <h5 class="card-header">
-                              Script Log
-                          </h5>
-                          <div class="card-body">
-                              <table class="table table-hover panel-body">
-                                  <tr>
-                                      <th>Line</th>
-                                      <th>Level</th>
-                                      <th>Message</th>
-                                  </tr>
-                                  {% for log in result.data.log %}
-                                      <tr>
-                                          <td>{{ forloop.counter }}</td>
-                                          <td>{% log_level log.status %}</td>
-                                          <td class="rendered-markdown">{{ log.message|render_markdown }}</td>
-                                      </tr>
-                                  {% empty %}
-                                      <tr>
-                                          <td colspan="3" class="text-center text-muted">
-                                              No log output
-                                          </td>
-                                      </tr>
-                                  {% endfor %}
-                              </table>
-                          </div>
-                          {% if execution_time %}
-                              <div class="card-footer text-end text-muted">
-                                  <small>Exec Time: {{ execution_time|floatformat:3 }}s</small>
-                              </div>
-                          {% endif %}
-                      </div>
-                  </div>
-              </div>
-          {% else %}
-              <div class="row">
-                  <div class="col col-md-12">
-                      <div class="well">Pending Results</div>
-                  </div>
-              </div>
-          {% endif %}
-      </div>
-      <div role="tabpanel" class="tab-pane" id="output">
-          <pre class="block">{{ result.data.output }}</pre>
-      </div>
-      <div role="tabpanel" class="tab-pane" id="source">
-          <p><code>{{ script.filename }}</code></p>
-          <pre class="block">{{ script.source }}</pre>
+    <div role="tabpanel" class="tab-pane active" id="log">
+      <div class="row">
+        <div class="col col-md-12"{% if not result.completed %} hx-get="{% url 'extras:script_result' job_result_pk=result.pk %}" hx-trigger="every 3s"{% endif %}>
+          {% include 'extras/htmx/script_result.html' %}
+        </div>
       </div>
+    </div>
+    <div role="tabpanel" class="tab-pane" id="source">
+      <p><code>{{ script.filename }}</code></p>
+      <pre class="block">{{ script.source }}</pre>
+    </div>
   </div>
 {% endblock content-wrapper %}
-
-{% block data %}
-<span data-job-url="{% url 'extras-api:jobresult-detail' pk=result.pk %}"></span>
-<span data-job-complete="{{ result.completed }}"></span>
-{% endblock %}

+ 5 - 35
netbox/templates/extras/webhook.html

@@ -17,13 +17,7 @@
           </tr>
           <tr>
             <th scope="row">Enabled</th>
-            <td>
-              {% if object.enabled %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.enabled %}</td>
           </tr>
         </table>
       </div>
@@ -36,33 +30,15 @@
         <table class="table table-hover attr-table">
           <tr>
             <th scope="row">Create</th>
-            <td>
-              {% if object.type_create %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.type_create %}</td>
           </tr>
           <tr>
             <th scope="row">Update</th>
-            <td>
-              {% if object.type_update %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.type_update %}</td>
           </tr>
           <tr>
             <th scope="row">Delete</th>
-            <td>
-              {% if object.type_delete %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.type_delete %}</td>
           </tr>
         </table>
       </div>
@@ -100,13 +76,7 @@
         <table class="table table-hover attr-table">
           <tr>
             <th scope="row">SSL Verification</th>
-            <td>
-              {% if object.ssl_verification %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.ssl_verification %}</td>
           </tr>
           <tr>
             <th scope="row">CA File Path</th>

+ 0 - 0
netbox/templates/utilities/confirmation_form.html → netbox/templates/generic/confirmation_form.html


+ 1 - 1
netbox/templates/generic/object_bulk_import.html

@@ -66,7 +66,7 @@
                                             </td>
                                             <td>
                                                 {% if field.required %}
-                                                    <i class="mdi mdi-check-bold text-success" title="Required"></i>
+                                                    {% checkmark True true="Required" %}
                                                 {% else %}
                                                     <span class="text-muted">&mdash;</span>
                                                 {% endif %}

+ 1 - 1
netbox/templates/generic/object_delete.html

@@ -1,4 +1,4 @@
-{% extends 'utilities/confirmation_form.html' %}
+{% extends 'generic/confirmation_form.html' %}
 {% load form_helpers %}
 
 {% block title %}Delete {{ obj_type }}?{% endblock %}

+ 1 - 2
netbox/templates/home.html

@@ -1,5 +1,4 @@
 {% extends 'base/layout.html' %}
-{% load get_status %}
 {% load helpers %}
 {% load render_table from django_tables2 %}
 
@@ -24,7 +23,7 @@
 {% block title %}Home{% endblock %}
 
 {% block content-wrapper %}
-  <div class="p-3">
+  <div class="px-3">
     {# General stats #}
     <div class="row masonry">
       {% for section, items, icon in stats %}

+ 1 - 1
netbox/templates/inc/panels/comments.html

@@ -4,7 +4,7 @@
   <h5 class="card-header">
     Comments
   </h5>
-  <div class="card-body rendered-markdown">
+  <div class="card-body">
     {% if object.comments %}
       {{ object.comments|render_markdown }}
     {% else %}

+ 2 - 2
netbox/templates/inc/panels/custom_fields.html

@@ -15,9 +15,9 @@
                                 {% if field.type == 'longtext' and value %}
                                     {{ value|render_markdown }}
                                 {% elif field.type == 'boolean' and value == True %}
-                                    <i class="mdi mdi-check-bold text-success" title="True"></i>
+                                    {% checkmark value true="True" %}
                                 {% elif field.type == 'boolean' and value == False %}
-                                    <i class="mdi mdi-close-thick text-danger" title="False"></i>
+                                    {% checkmark value false="False" %}
                                 {% elif field.type == 'url' and value %}
                                     <a href="{{ value }}">{{ value|truncatechars:70 }}</a>
                                 {% elif field.type == 'json' and value %}

+ 0 - 30
netbox/templates/inc/plugin_menu_items.html

@@ -1,30 +0,0 @@
-{% load helpers %}
-<li class="dropdown">
-    <a href="#" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">Plugins <span class="caret"></span></a>
-    <ul class="dropdown-menu">
-        {% for section_name, menu_items in registry.plugin_menu_items.items %}
-            <li class="dropdown-header">{{ section_name }}</li>
-            {% for menu_item in menu_items %}
-                {% if not menu_item.permissions or request.user|has_perms:menu_item.permissions %}
-                    <li>
-                        {% if menu_item.buttons %}
-                            <div class="buttons float-end">
-                                {% for button in menu_item.buttons %}
-                                    {% if not button.permissions or request.user|has_perms:button.permissions %}
-                                        <a href="{% url button.link %}" class="btn btn-sm btn-{{ button.color }}" title="{{ button.title }}"><i class="{{ button.icon_class }}"></i></a>
-                                    {% endif %}
-                                {% endfor %}
-                            </div>
-                        {% endif %}
-                        <a href="{% url menu_item.link %}">{{ menu_item.link_text }}</a>
-                    </li>
-                {% else %}
-                    <li class="disabled"><a href="#">{{ menu_item.link_text }}</a></li>
-                {% endif %}
-            {% endfor %}
-            {% if not forloop.last %}
-                <li class="divider"></li>
-            {% endif %}
-        {% endfor %}
-    </ul>
-</li>

+ 53 - 57
netbox/templates/inc/profile_button.html

@@ -1,60 +1,56 @@
 {% if request.user.is_authenticated %}
-<span class="dropdown profile-button">
-  <button
-    type="button"
-    aria-expanded="false"
-    data-bs-toggle="dropdown"
-    class="btn btn-outline-secondary dropdown-toggle w-100"
-  >
-    <i class="mdi mdi-account"></i>
-    <span id="navbar_user">{{ request.user|truncatechars:"30" }}</span>
-  </button>
-  <ul class="dropdown-menu dropdown-menu-end">
-    <li>
-      <button type="button" class="dropdown-item color-mode-toggle">
-        <i class="color-mode-icon mdi mdi-lightbulb"></i>&nbsp;
-        <span class="color-mode-text">Dark Mode</span>
-      </button>
-    </li>
-    <li>
-      {% if request.user.is_staff %}
-      <a class="dropdown-item" href="{% url 'admin:index' %}">
-        <i class="mdi mdi-cog"></i> Admin
-      </a>
-      {% endif %}
-    </li>
-    <li>
-      <a class="dropdown-item" href="{% url 'user:profile' %}">
-        <i class="mdi mdi-account"></i> Profile & Settings
-      </a>
-    </li>
-    <li><hr class="dropdown-divider" /></li>
-    <li>
-      <a class="dropdown-item text-danger" href="{% url 'logout' %}">
-        <i class="mdi mdi-logout-variant"></i> Log Out
-      </a>
-    </li>
-  </ul>
-</span>
+  <div class="dropdown profile-button">
+    <button type="button" aria-expanded="false" data-bs-toggle="dropdown" class="btn btn-outline-secondary dropdown-toggle w-100">
+      <i class="mdi mdi-account"></i>
+      <span id="navbar_user">{{ request.user|truncatechars:"30" }}</span>
+    </button>
+    <ul class="dropdown-menu dropdown-menu-end">
+      <li>
+        <button type="button" class="dropdown-item color-mode-toggle">
+          <i class="color-mode-icon mdi mdi-lightbulb"></i>
+          <span class="color-mode-text">Dark Mode</span>
+        </button>
+      </li>
+      <li>
+        {% if request.user.is_staff %}
+          <a class="dropdown-item" href="{% url 'admin:index' %}">
+            <i class="mdi mdi-cog"></i> Admin
+          </a>
+        {% endif %}
+      </li>
+      <li>
+        <a class="dropdown-item" href="{% url 'user:profile' %}">
+          <i class="mdi mdi-account"></i> Profile
+        </a>
+      </li>
+      <li>
+        <a class="dropdown-item" href="{% url 'user:preferences' %}">
+          <i class="mdi mdi-wrench"></i> Preferences
+        </a>
+      </li>
+      <li><hr class="dropdown-divider" /></li>
+      <li>
+        <a class="dropdown-item" href="{% url 'logout' %}">
+          <i class="mdi mdi-logout-variant"></i> Log Out
+        </a>
+      </li>
+    </ul>
+  </div>
 {% else %}
-<div class="btn-group">
-  <a
-    class="btn btn-primary ws-nowrap"
-    type="button"
-    href="{% url 'login' %}"
-  >
-    <i class="mdi mdi-login-variant"></i> Log In
-  </a>
-  <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">
-    <span class="visually-hidden">Toggle Dropdown</span>
-  </button>
-  <ul class="dropdown-menu dropdown-menu-end">
-    <li>
-      <button class="dropdown-item color-mode-toggle">
-        <i class="color-mode-icon mdi mdi-lightbulb"></i>&nbsp;
-        <span class="color-mode-text">Dark Mode</span>
-      </button>
-    </li>
-  </ul>
-</div>
+  <div class="btn-group">
+    <a class="btn btn-primary ws-nowrap" type="button" href="{% url 'login' %}">
+      <i class="mdi mdi-login-variant"></i> Log In
+    </a>
+    <button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown">
+      <span class="visually-hidden">Toggle Dropdown</span>
+    </button>
+    <ul class="dropdown-menu dropdown-menu-end">
+      <li>
+        <button class="dropdown-item color-mode-toggle">
+          <i class="color-mode-icon mdi mdi-lightbulb"></i>
+          <span class="color-mode-text">Dark Mode</span>
+        </button>
+      </li>
+    </ul>
+  </div>
 {% endif %}

+ 121 - 129
netbox/templates/ipam/ipaddress.html

@@ -13,143 +13,135 @@
 {% block content %}
 <div class="row">
 	<div class="col col-md-4">
-        <div class="card">
-            <h5 class="card-header">
-                IP Address
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">Family</th>
-                        <td>IPv{{ object.family }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">VRF</th>
-                        <td>
-                            {% if object.vrf %}
-                                <a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a>
-                            {% else %}
-                                <span>Global</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Tenant</th>
-                        <td>
-                            {% if object.tenant %}
-                                {% if object.tenant.group %}
-                                    <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
-                                {% endif %}
-                                <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Status</th>
-                        <td>
-                            <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Role</th>
-                        <td>
-                            {% if object.role %}
-                                <a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">DNS Name</th>
-                        <td>{{ object.dns_name|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Description</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">Assignment</th>
-                        <td>
-                          {% if object.assigned_object %}
-                            {% if object.assigned_object.parent_object %}
-                              <a href="{{ object.assigned_object.parent_object.get_absolute_url }}">{{ object.assigned_object.parent_object }}</a> /
-                            {% endif %}
-                            <a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}
+      <div class="card">
+          <h5 class="card-header">
+              IP Address
+          </h5>
+          <div class="card-body">
+              <table class="table table-hover attr-table">
+                  <tr>
+                      <th scope="row">Family</th>
+                      <td>IPv{{ object.family }}</td>
+                  </tr>
+                  <tr>
+                      <th scope="row">VRF</th>
+                      <td>
+                          {% if object.vrf %}
+                              <a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a>
                           {% else %}
-                            <span class="text-muted">&mdash;</span>
+                              <span>Global</span>
                           {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">NAT (inside)</th>
-                        <td>
-                            {% if object.nat_inside %}
-                                <a href="{{ object.nat_inside.get_absolute_url }}">{{ object.nat_inside }}</a>
-                                {% if object.nat_inside.assigned_object %}
-                                    (<a href="{{ object.nat_inside.assigned_object.parent_object.get_absolute_url }}">{{ object.nat_inside.assigned_object.parent_object }}</a>)
-                                {% endif %}
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">NAT (outside)</th>
-                        <td>
-                            {% if object.nat_outside %}
-                                <a href="{{ object.nat_outside.get_absolute_url }}">{{ object.nat_outside }}</a>
-                            {% else %}
-                                <span class="text-muted">None</span>
-                            {% endif %}
-                        </td>
-                    </tr>
-                </table>
-            </div>
-        </div>
-        {% include 'inc/panels/custom_fields.html' %}
-        
-        {% plugin_left_page object %}
-	</div>
-    
-	<div class="col col-md-8">
-        {% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
-        {% if duplicate_ips_table.rows %}
-            {# Custom version of panel_table.html #}
-            <div class="card border-danger">
-                <h5 class="card-header">
-                  <span class="text-danger">Duplicate IP Addresses</span>
-                    {% if more_duplicate_ips %}
-                      <div class="float-end">
-                        <a type="button" class="btn btn-primary btn-sm"
-                        {% if object.vrf %}
-                        href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id={{ object.vrf.pk }}"
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Tenant</th>
+                      <td>
+                          {% if object.tenant %}
+                              {% if object.tenant.group %}
+                                  <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
+                              {% endif %}
+                              <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Status</th>
+                      <td>
+                          <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Role</th>
+                      <td>
+                          {% if object.role %}
+                              <a href="{% url 'ipam:ipaddress_list' %}?role={{ object.role }}">{{ object.get_role_display }}</a>
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">DNS Name</th>
+                      <td>{{ object.dns_name|placeholder }}</td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Description</th>
+                      <td>{{ object.description|placeholder }}</td>
+                  </tr>
+                  <tr>
+                      <th scope="row">Assignment</th>
+                      <td>
+                        {% if object.assigned_object %}
+                          {% if object.assigned_object.parent_object %}
+                            <a href="{{ object.assigned_object.parent_object.get_absolute_url }}">{{ object.assigned_object.parent_object }}</a> /
+                          {% endif %}
+                          <a href="{{ object.assigned_object.get_absolute_url }}">{{ object.assigned_object }}
                         {% else %}
-                        href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id=null"
+                          <span class="text-muted">&mdash;</span>
                         {% endif %}
-                        >Show all</a>
-                      </div>
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">NAT (inside)</th>
+                      <td>
+                          {% if object.nat_inside %}
+                              <a href="{{ object.nat_inside.get_absolute_url }}">{{ object.nat_inside }}</a>
+                              {% if object.nat_inside.assigned_object %}
+                                  (<a href="{{ object.nat_inside.assigned_object.parent_object.get_absolute_url }}">{{ object.nat_inside.assigned_object.parent_object }}</a>)
+                              {% endif %}
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+                  <tr>
+                      <th scope="row">NAT (outside)</th>
+                      <td>
+                          {% if object.nat_outside %}
+                              <a href="{{ object.nat_outside.get_absolute_url }}">{{ object.nat_outside }}</a>
+                          {% else %}
+                              <span class="text-muted">None</span>
+                          {% endif %}
+                      </td>
+                  </tr>
+              </table>
+          </div>
+      </div>
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-8">
+    {% include 'inc/panel_table.html' with table=parent_prefixes_table heading='Parent Prefixes' %}
+    {% if duplicate_ips_table.rows %}
+        {# Custom version of panel_table.html #}
+        <div class="card border-danger">
+            <h5 class="card-header">
+              <span class="text-danger">Duplicate IP Addresses</span>
+                {% if more_duplicate_ips %}
+                  <div class="float-end">
+                    <a type="button" class="btn btn-primary btn-sm"
+                    {% if object.vrf %}
+                    href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id={{ object.vrf.pk }}"
+                    {% else %}
+                    href="{% url 'ipam:ipaddress_list' %}?address={{ object.address.ip }}&vrf_id=null"
                     {% endif %}
-                </h5>
-                <div class="card-body table-responsive">
-                  {% render_table duplicate_ips_table 'inc/table.html' %}
-                </div>
+                    >Show all</a>
+                  </div>
+                {% endif %}
+            </h5>
+            <div class="card-body table-responsive">
+              {% render_table duplicate_ips_table 'inc/table.html' %}
             </div>
-        {% endif %}
-        <div class="my-3">
-        {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
         </div>
-        {% plugin_right_page object %}
-	</div>
-</div>
-
-<div class="row my-3">
-    <div class="col col-md-4">
-        {% include 'inc/panels/tags.html' %}
+    {% endif %}
+    <div class="my-3">
+      {% include 'inc/panel_table.html' with table=related_ips_table heading='Related IP Addresses' %}
     </div>
-    
+    {% plugin_right_page object %}
+	</div>
 </div>
 
 <div class="row">

+ 146 - 119
netbox/templates/ipam/prefix.html

@@ -4,127 +4,154 @@
 
 {% block content %}
 <div class="row">
-    <div class="col col-md-6">
-        <div class="card">
-            <h5 class="card-header">
-              Prefix
-            </h5>
-            <div class="card-body">
-              <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row">Family</th>
-                    <td>IPv{{ object.family }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">VRF</th>
-                    <td>
-                        {% if object.vrf %}
-                            <a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a> ({{ object.vrf.rd }})
-                        {% else %}
-                            <span>Global</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">Tenant</th>
-                    <td>
-                        {% if object.tenant %}
-                            {% if object.tenant.group %}
-                                <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
-                            {% endif %}
-                            <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">Aggregate</th>
-                    <td>
-                        {% if aggregate %}
-                            <a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
-                        {% else %}
-                            <span class="text-warning">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">Site</th>
-                    <td>
-                        {% if object.site %}
-                            {% if object.site.region %}
-                                <a href="{{ object.site.region.get_absolute_url }}">{{ object.site.region }}</a> /
-                            {% endif %}
-                            <a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">VLAN</th>
-                    <td>
-                        {% if object.vlan %}
-                            {% if object.vlan.group %}
-                                <a href="{{ object.vlan.group.get_absolute_url }}">{{ object.vlan.group }}</a> /
-                            {% endif %}
-                            <a href="{% url 'ipam:vlan' pk=object.vlan.pk %}">{{ object.vlan }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">Status</th>
-                    <td>
-                      <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">Role</th>
-                    <td>
-                        {% if object.role %}
-                            <a href="{{ object.role.get_absolute_url }}">{{ object.role }}</a>
-                        {% else %}
-                            <span class="text-muted">None</span>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">Description</th>
-                    <td>{{ object.description|placeholder }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">Is a pool</th>
-                    <td>
-                        {% if object.is_pool %}
-                            <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-                        {% else %}
-                            <i class="mdi mdi-close-thick text-danger" title="No"></i>
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">Utilization</th>
-                    <td>
-                      {% if object.mark_utilized %}
-                        {% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
-                        <small>(Marked fully utilized)</small>
-                      {% else %}
-                        {% utilization_graph object.get_utilization %}
-                      {% endif %}
-                    </td>
-                </tr>
-              </table>
-            </div>
-        </div>
-        {% plugin_left_page object %}
+  <div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">Prefix</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Family</th>
+            <td>IPv{{ object.family }}</td>
+          </tr>
+          <tr>
+            <th scope="row">VRF</th>
+            <td>
+              {% if object.vrf %}
+                <a href="{% url 'ipam:vrf' pk=object.vrf.pk %}">{{ object.vrf }}</a> ({{ object.vrf.rd }})
+              {% else %}
+                <span>Global</span>
+              {% endif %}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Tenant</th>
+            <td>
+              {% if object.tenant %}
+                {% if object.tenant.group %}
+                  <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
+                {% endif %}
+                <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
+              {% else %}
+                <span class="text-muted">None</span>
+              {% endif %}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Aggregate</th>
+            <td>
+              {% if aggregate %}
+                <a href="{% url 'ipam:aggregate' pk=aggregate.pk %}">{{ aggregate.prefix }}</a> ({{ aggregate.rir }})
+              {% else %}
+                <span class="text-warning">None</span>
+              {% endif %}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Site</th>
+            <td>
+              {% if object.site %}
+                {% if object.site.region %}
+                  <a href="{{ object.site.region.get_absolute_url }}">{{ object.site.region }}</a> /
+                {% endif %}
+                <a href="{{ object.site.get_absolute_url }}">{{ object.site }}</a>
+              {% else %}
+                <span class="text-muted">None</span>
+              {% endif %}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">VLAN</th>
+            <td>
+              {% if object.vlan %}
+                {% if object.vlan.group %}
+                  <a href="{{ object.vlan.group.get_absolute_url }}">{{ object.vlan.group }}</a> /
+                {% endif %}
+                <a href="{% url 'ipam:vlan' pk=object.vlan.pk %}">{{ object.vlan }}</a>
+              {% else %}
+                <span class="text-muted">None</span>
+              {% endif %}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Status</th>
+            <td>
+              <span class="badge bg-{{ object.get_status_class }}">{{ object.get_status_display }}</span>
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Role</th>
+            <td>
+              {% if object.role %}
+                <a href="{{ object.role.get_absolute_url }}">{{ object.role }}</a>
+              {% else %}
+                <span class="text-muted">None</span>
+              {% endif %}
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Description</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Is a pool</th>
+            <td>{% checkmark object.is_pool %}</td>
+          </tr>
+        </table>
+      </div>
     </div>
-    <div class="col col-md-6">
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' %}
-        {% plugin_right_page object %}
+      {% plugin_left_page object %}
+  </div>
+  <div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">Addressing</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Utilization</th>
+            <td>
+              {% if object.mark_utilized %}
+                {% utilization_graph 100 warning_threshold=0 danger_threshold=0 %}
+                <small>(Marked fully utilized)</small>
+              {% else %}
+                {% utilization_graph object.get_utilization %}
+              {% endif %}
+            </td>
+          </tr>
+          {% with child_ip_count=object.get_child_ips.count %}
+            <tr>
+              <th scope="row">Child IPs</th>
+              <td>
+                <a href="{% url 'ipam:prefix_ipaddresses' pk=object.pk %}">{{ child_ip_count }}</a>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Available IPs</th>
+              <td>{{ object.get_available_ips|length }}</td>
+            </tr>
+          {% endwith %}
+          <tr>
+            <td>First available IP</td>
+            <td>
+              {% with first_available_ip=object.get_first_available_ip %}
+                {% if first_available_ip %}
+                  {% if perms.ipam.add_ipaddress %}
+                    <a href="{% url 'ipam:ipaddress_add' %}?address={{ first_available_ip }}">{{ first_available_ip }}</a>
+                  {% else %}
+                    {{ first_available_ip }}
+                  {% endif %}
+                {% else %}
+                  <span class="text-muted">None</span>
+                {% endif %}
+              {% endwith %}
+            </td>
+          </tr>
+        </table>
+      </div>
     </div>
+    {% include 'inc/panels/custom_fields.html' %}
+    {% include 'inc/panels/tags.html' %}
+    {% plugin_right_page object %}
+  </div>
 </div>
 <div class="row">
     <div class="col col-md-12">

+ 1 - 7
netbox/templates/ipam/rir.html

@@ -30,13 +30,7 @@
           </tr>
           <tr>
             <th scope="row">Private</th>
-            <td>
-              {% if object.is_private %}
-                <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-              {% else %}
-                <i class="mdi mdi-close-thick text-danger" title="No"></i>
-              {% endif %}
-            </td>
+            <td>{% checkmark object.is_private %}</td>
           </tr>
           <tr>
             <th scope="row">Aggregates</th>

+ 0 - 2
netbox/templates/ipam/service_edit.html

@@ -24,8 +24,6 @@
       </div>
     </div>
     <div class="tab-content p-0 border-0">
-    {{ form.initial.device }}
-    {{ form.initial.virtual_machine }}
       <div class="tab-pane {% if not form.initial.virtual_machine %}active{% endif %}" id="device" role="tabpanel" aria-labeled-by="device_tab">
         {% render_field form.device %}
       </div>

+ 1 - 7
netbox/templates/ipam/vrf.html

@@ -30,13 +30,7 @@
                   </tr>
                   <tr>
                       <th scope="row">Unique IP Space</th>
-                      <td>
-                          {% if object.enforce_unique %}
-                              <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-                          {% else %}
-                              <i class="mdi mdi-close-thick text-danger" title="No"></i>
-                          {% endif %}
-                      </td>
+                      <td>{% checkmark object.enforce_unique %}</td>
                   </tr>
                   <tr>
                       <th scope="row">Description</th>

+ 3 - 2
netbox/templates/users/api_tokens.html

@@ -5,7 +5,7 @@
 
 {% block content %}
     <div class="row">
-        <div class="col col-md-12">
+        <div class="col col-md-10 offset-md-1">
             {% for token in tokens %}
                 <div class="card{% if token.is_expired %} bg-danger{% endif %}">
                     <div class="card-header">
@@ -49,7 +49,8 @@
                     </div>
                 </div>
             {% empty %}
-                <p>You do not have any API tokens.</p>
+              <h6><i class="mdi mdi-information"></i> You do not have any API tokens.</h6>
+              <p>Tokens are used to authenticate REST and GraphQL API requests.</p>
             {% endfor %}
             <div class="text-end">
               <a href="{% url 'user:token_add' %}" class="btn btn-sm btn-primary my-3">

+ 2 - 14
netbox/templates/users/profile.html

@@ -35,23 +35,11 @@
             </tr>
             <tr>
               <th scope="row">Superuser</th>
-              <td>
-                {% if request.user.is_superuser %}
-                  <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-                {% else %}
-                  <i class="mdi mdi-close-thick text-danger" title="No"></i>
-                {% endif %}
-              </td>
+              <td>{% checkmark request.user.is_superuser %}</td>
             </tr>
             <tr>
               <th scope="row">Admin Access</th>
-              <td>
-                {% if request.user.is_staff %}
-                  <i class="mdi mdi-check-bold text-success" title="Yes"></i>
-                {% else %}
-                  <i class="mdi mdi-close-thick text-danger" title="No"></i>
-                {% endif %}
-              </td>
+              <td>{% checkmark request.user.is_staff %}</td>
             </tr>
           </table>
         </div>

+ 1 - 1
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -5,7 +5,7 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls_htmx.html' with table_modal="VMInterfaceTable_config" %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineVMInterfaceTable_config" %}
 
     <div class="card">
       <div class="card-body" id="object_list">

+ 4 - 8
netbox/tenancy/forms/filtersets.py

@@ -22,8 +22,7 @@ class TenantGroupFilterForm(CustomFieldModelFilterForm):
     parent_id = DynamicModelMultipleChoiceField(
         queryset=TenantGroup.objects.all(),
         required=False,
-        label=_('Parent group'),
-        fetch_trigger='open'
+        label=_('Parent group')
     )
     tag = TagFilterField(model)
 
@@ -38,8 +37,7 @@ class TenantFilterForm(CustomFieldModelFilterForm):
         queryset=TenantGroup.objects.all(),
         required=False,
         null_option='None',
-        label=_('Group'),
-        fetch_trigger='open'
+        label=_('Group')
     )
     tag = TagFilterField(model)
 
@@ -53,8 +51,7 @@ class ContactGroupFilterForm(CustomFieldModelFilterForm):
     parent_id = DynamicModelMultipleChoiceField(
         queryset=ContactGroup.objects.all(),
         required=False,
-        label=_('Parent group'),
-        fetch_trigger='open'
+        label=_('Parent group')
     )
     tag = TagFilterField(model)
 
@@ -74,7 +71,6 @@ class ContactFilterForm(CustomFieldModelFilterForm):
         queryset=ContactGroup.objects.all(),
         required=False,
         null_option='None',
-        label=_('Group'),
-        fetch_trigger='open'
+        label=_('Group')
     )
     tag = TagFilterField(model)

+ 2 - 4
netbox/tenancy/forms/forms.py

@@ -33,8 +33,7 @@ class TenancyFilterForm(forms.Form):
         queryset=TenantGroup.objects.all(),
         required=False,
         null_option='None',
-        label=_('Tenant group'),
-        fetch_trigger='open'
+        label=_('Tenant group')
     )
     tenant_id = DynamicModelMultipleChoiceField(
         queryset=Tenant.objects.all(),
@@ -43,6 +42,5 @@ class TenancyFilterForm(forms.Form):
         query_params={
             'group_id': '$tenant_group_id'
         },
-        label=_('Tenant'),
-        fetch_trigger='open'
+        label=_('Tenant')
     )

+ 1 - 0
netbox/utilities/constants.py

@@ -57,6 +57,7 @@ HTTP_REQUEST_META_SAFE_COPY = [
     'HTTP_HOST',
     'HTTP_REFERER',
     'HTTP_USER_AGENT',
+    'HTTP_X_FORWARDED_FOR',
     'QUERY_STRING',
     'REMOTE_ADDR',
     'REMOTE_HOST',

+ 2 - 2
netbox/utilities/forms/fields.py

@@ -399,8 +399,8 @@ class DynamicModelChoiceMixin:
     filter = django_filters.ModelChoiceFilter
     widget = widgets.APISelect
 
-    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None, fetch_trigger=None,
-                 empty_label=None, *args, **kwargs):
+    def __init__(self, query_params=None, initial_params=None, null_option=None, disabled_indicator=None,
+                 fetch_trigger=None, empty_label=None, *args, **kwargs):
         self.query_params = query_params or {}
         self.initial_params = initial_params or {}
         self.null_option = null_option

+ 42 - 10
netbox/utilities/tables.py

@@ -12,7 +12,7 @@ from django_tables2.data import TableQuerysetData
 from django_tables2.utils import Accessor
 
 from extras.choices import CustomFieldTypeChoices
-from extras.models import CustomField
+from extras.models import CustomField, CustomLink
 from .utils import content_type_identifier, content_type_name
 from .paginator import EnhancedPaginator, get_paginate_count
 
@@ -34,15 +34,18 @@ class BaseTable(tables.Table):
         }
 
     def __init__(self, *args, user=None, extra_columns=None, **kwargs):
+        if extra_columns is None:
+            extra_columns = []
+
         # Add custom field columns
         obj_type = ContentType.objects.get_for_model(self._meta.model)
         cf_columns = [
             (f'cf_{cf.name}', CustomFieldColumn(cf)) for cf in CustomField.objects.filter(content_types=obj_type)
         ]
-        if extra_columns is not None:
-            extra_columns.extend(cf_columns)
-        else:
-            extra_columns = cf_columns
+        cl_columns = [
+            (f'cl_{cl.name}', CustomLinkColumn(cl)) for cl in CustomLink.objects.filter(content_type=obj_type)
+        ]
+        extra_columns.extend([*cf_columns, *cl_columns])
 
         super().__init__(*args, extra_columns=extra_columns, **kwargs)
 
@@ -208,7 +211,6 @@ class ButtonsColumn(tables.TemplateColumn):
 
     :param model: Model class to use for calculating URL view names
     :param prepend_content: Additional template content to render in the column (optional)
-    :param return_url_extra: String to append to the return URL (e.g. for specifying a tab) (optional)
     """
     buttons = ('changelog', 'edit', 'delete')
     attrs = {'td': {'class': 'text-end text-nowrap noprint'}}
@@ -220,18 +222,18 @@ class ButtonsColumn(tables.TemplateColumn):
         </a>
     {{% endif %}}
     {{% if "edit" in buttons and perms.{app_label}.change_{model_name} %}}
-        <a href="{{% url '{app_label}:{model_name}_edit' pk=record.pk %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-sm btn-warning" title="Edit">
+        <a href="{{% url '{app_label}:{model_name}_edit' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-sm btn-warning" title="Edit">
             <i class="mdi mdi-pencil"></i>
         </a>
     {{% endif %}}
     {{% if "delete" in buttons and perms.{app_label}.delete_{model_name} %}}
-        <a href="{{% url '{app_label}:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}{{{{ return_url_extra }}}}" class="btn btn-sm btn-danger" title="Delete">
+        <a href="{{% url '{app_label}:{model_name}_delete' pk=record.pk %}}?return_url={{{{ request.path }}}}" class="btn btn-sm btn-danger" title="Delete">
             <i class="mdi mdi-trash-can-outline"></i>
         </a>
     {{% endif %}}
     """
 
-    def __init__(self, model, *args, buttons=None, prepend_template=None, return_url_extra='', **kwargs):
+    def __init__(self, model, *args, buttons=None, prepend_template=None, **kwargs):
         if prepend_template:
             prepend_template = prepend_template.replace('{', '{{')
             prepend_template = prepend_template.replace('}', '}}')
@@ -251,7 +253,6 @@ class ButtonsColumn(tables.TemplateColumn):
 
         self.extra_context.update({
             'buttons': buttons or self.buttons,
-            'return_url_extra': return_url_extra,
         })
 
     def header(self):
@@ -420,6 +421,37 @@ class CustomFieldColumn(tables.Column):
         return self.default
 
 
+class CustomLinkColumn(tables.Column):
+    """
+    Render a custom links as a table column.
+    """
+    def __init__(self, customlink, *args, **kwargs):
+        self.customlink = customlink
+        kwargs['accessor'] = Accessor('pk')
+        if 'verbose_name' not in kwargs:
+            kwargs['verbose_name'] = customlink.name
+
+        super().__init__(*args, **kwargs)
+
+    def render(self, record):
+        try:
+            rendered = self.customlink.render({'obj': record})
+            if rendered:
+                return mark_safe(f'<a href="{rendered["link"]}"{rendered["link_target"]}>{rendered["text"]}</a>')
+        except Exception as e:
+            return mark_safe(f'<span class="text-danger" title="{e}"><i class="mdi mdi-alert"></i> Error</span>')
+        return ''
+
+    def value(self, record):
+        try:
+            rendered = self.customlink.render({'obj': record})
+            if rendered:
+                return rendered['link']
+        except Exception:
+            pass
+        return None
+
+
 class MPTTColumn(tables.TemplateColumn):
     """
     Display a nested hierarchy for MPTT-enabled models.

+ 0 - 0
netbox/templates/utilities/render_custom_fields.html → netbox/utilities/templates/form_helpers/render_custom_fields.html


+ 0 - 0
netbox/templates/utilities/render_errors.html → netbox/utilities/templates/form_helpers/render_errors.html


+ 0 - 0
netbox/templates/utilities/render_field.html → netbox/utilities/templates/form_helpers/render_field.html


+ 0 - 0
netbox/templates/utilities/render_form.html → netbox/utilities/templates/form_helpers/render_form.html


+ 0 - 0
netbox/templates/utilities/templatetags/applied_filters.html → netbox/utilities/templates/helpers/applied_filters.html


Некоторые файлы не были показаны из-за большого количества измененных файлов