diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 93b3e4af7..61d93f286 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -17,7 +17,7 @@ body: What version of NetBox are you currently running? (If you don't have access to the most recent NetBox release, consider testing on our [demo instance](https://demo.netbox.dev/) before opening a bug report to see if your issue has already been addressed.) - placeholder: v3.0.0 + placeholder: v3.0.1 validations: required: true - type: dropdown diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml index 7a7bcc106..65f452f0b 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yaml +++ b/.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.0.0 + placeholder: v3.0.1 validations: required: true - type: dropdown diff --git a/docs/release-notes/version-3.0.md b/docs/release-notes/version-3.0.md index 93b8ae565..1587d4b43 100644 --- a/docs/release-notes/version-3.0.md +++ b/docs/release-notes/version-3.0.md @@ -1,5 +1,36 @@ # NetBox v3.0 +## v3.0.1 (2021-09-01) + +### Bug Fixes + +* [#7041](https://github.com/netbox-community/netbox/issues/7041) - Properly format JSON config object returned from a NAPALM device +* [#7070](https://github.com/netbox-community/netbox/issues/7070) - Fix exception when filtering by prefix max length in UI +* [#7071](https://github.com/netbox-community/netbox/issues/7071) - Fix exception when removing a primary IP from a device/VM +* [#7072](https://github.com/netbox-community/netbox/issues/7072) - Fix table configuration under prefix child object views +* [#7075](https://github.com/netbox-community/netbox/issues/7075) - Fix UI bug when a custom field has a space in the name +* [#7080](https://github.com/netbox-community/netbox/issues/7080) - Fix missing image previews +* [#7081](https://github.com/netbox-community/netbox/issues/7081) - Fix UI bug that did not properly request and handle paginated data +* [#7082](https://github.com/netbox-community/netbox/issues/7082) - Avoid exception when referencing invalid content type in table +* [#7083](https://github.com/netbox-community/netbox/issues/7083) - Correct labeling for VM memory attribute +* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix KeyError exception when editing access VLAN on an interface +* [#7084](https://github.com/netbox-community/netbox/issues/7084) - Fix issue where hidden VLAN form fields were incorrectly included in the form submission +* [#7089](https://github.com/netbox-community/netbox/issues/7089) - Fix filtering of change log by content type +* [#7090](https://github.com/netbox-community/netbox/issues/7090) - Allow decimal input on length field when bulk editing cables +* [#7091](https://github.com/netbox-community/netbox/issues/7091) - Ensure API requests from the UI are aware of `BASE_PATH` +* [#7092](https://github.com/netbox-community/netbox/issues/7092) - Fix missing bulk edit buttons on Prefix IP Addresses table +* [#7093](https://github.com/netbox-community/netbox/issues/7093) - Multi-select custom field filters should employ exact match +* [#7096](https://github.com/netbox-community/netbox/issues/7096) - Home links should honor `BASE_PATH` configuration +* [#7101](https://github.com/netbox-community/netbox/issues/7101) - Enforce `MAX_PAGE_SIZE` for table and REST API pagination +* [#7106](https://github.com/netbox-community/netbox/issues/7106) - Fix incorrect "Map It" button URL on a site's physical address field +* [#7107](https://github.com/netbox-community/netbox/issues/7107) - Fix missing search button and search results in IP address assignment "Assign IP" tab +* [#7109](https://github.com/netbox-community/netbox/issues/7109) - Ensure human readability of exceptions raised during REST API requests +* [#7113](https://github.com/netbox-community/netbox/issues/7113) - Show bulk edit/delete actions for prefix child objects +* [#7123](https://github.com/netbox-community/netbox/issues/7123) - Remove "Global" placeholder for null VRF field +* [#7124](https://github.com/netbox-community/netbox/issues/7124) - Fix duplicate static query param values in API Select + +--- + ## v3.0.0 (2021-08-30) !!! warning "Existing Deployments Must Upgrade from v2.11" diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 3ee225335..3d23cde5c 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -22,7 +22,7 @@ from netbox.api.authentication import IsAuthenticatedOrLoginNotRequired from netbox.api.exceptions import ServiceUnavailable from netbox.api.metadata import ContentTypeMetadata from utilities.api import get_serializer_for_model -from utilities.utils import count_related +from utilities.utils import count_related, decode_dict from virtualization.models import VirtualMachine from . import serializers from .exceptions import MissingFilterException @@ -498,7 +498,7 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet): response[method] = {'error': 'Only get_* NAPALM methods are supported'} continue try: - response[method] = getattr(d, method)() + response[method] = decode_dict(getattr(d, method)()) except NotImplementedError: response[method] = {'error': 'Method {} not implemented for NAPALM driver {}'.format(method, driver)} except Exception as e: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b69944cf6..367980ac4 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -129,7 +129,7 @@ class InterfaceCommonForm(forms.Form): super().clean() parent_field = 'device' if 'device' in self.cleaned_data else 'virtual_machine' - tagged_vlans = self.cleaned_data['tagged_vlans'] + tagged_vlans = self.cleaned_data.get('tagged_vlans') # Untagged interfaces cannot be assigned tagged VLANs if self.cleaned_data['mode'] == InterfaceModeChoices.MODE_ACCESS and tagged_vlans: @@ -142,7 +142,7 @@ class InterfaceCommonForm(forms.Form): self.cleaned_data['tagged_vlans'] = [] # Validate tagged VLANs; must be a global VLAN or in the same site - elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED: + elif self.cleaned_data['mode'] == InterfaceModeChoices.MODE_TAGGED and tagged_vlans: valid_sites = [None, self.cleaned_data[parent_field].site] invalid_vlans = [str(v) for v in tagged_vlans if v.site not in valid_sites] @@ -4586,8 +4586,8 @@ class CableBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldModelBulkE color = ColorField( required=False ) - length = forms.IntegerField( - min_value=1, + length = forms.DecimalField( + min_value=0, required=False ) length_unit = forms.ChoiceField( diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index aef2046fd..b37aaf40e 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -14,6 +14,7 @@ EXACT_FILTER_TYPES = ( CustomFieldTypeChoices.TYPE_DATE, CustomFieldTypeChoices.TYPE_INTEGER, CustomFieldTypeChoices.TYPE_SELECT, + CustomFieldTypeChoices.TYPE_MULTISELECT, ) @@ -35,7 +36,9 @@ class CustomFieldFilter(django_filters.Filter): self.field_name = f'custom_field_data__{self.field_name}' - if custom_field.type not in EXACT_FILTER_TYPES: + if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT: + self.lookup_expr = 'has_key' + elif custom_field.type not in EXACT_FILTER_TYPES: if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: self.lookup_expr = 'icontains' diff --git a/netbox/extras/filtersets.py b/netbox/extras/filtersets.py index 2a6ae088c..25fd32f0d 100644 --- a/netbox/extras/filtersets.py +++ b/netbox/extras/filtersets.py @@ -367,7 +367,19 @@ class JobResultFilterSet(BaseFilterSet): # class ContentTypeFilterSet(django_filters.FilterSet): + q = django_filters.CharFilter( + method='search', + label='Search', + ) class Meta: model = ContentType fields = ['id', 'app_label', 'model'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + return queryset.filter( + Q(app_label__icontains=value) | + Q(model__icontains=value) + ) diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index b1bf10be6..c2a2da3dc 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -681,7 +681,12 @@ class CustomFieldFilterTest(TestCase): cf.content_types.set([obj_type]) # Selection filtering - cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz']) + cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_SELECT, choices=['Foo', 'Bar', 'Baz']) + cf.save() + cf.content_types.set([obj_type]) + + # Multiselect filtering + cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C']) cf.save() cf.content_types.set([obj_type]) @@ -695,6 +700,7 @@ class CustomFieldFilterTest(TestCase): 'cf6': 'http://foo.example.com/', 'cf7': 'http://foo.example.com/', 'cf8': 'Foo', + 'cf9': ['A', 'B'], }), Site(name='Site 2', slug='site-2', custom_field_data={ 'cf1': 200, @@ -705,9 +711,9 @@ class CustomFieldFilterTest(TestCase): 'cf6': 'http://bar.example.com/', 'cf7': 'http://bar.example.com/', 'cf8': 'Bar', + 'cf9': ['AA', 'B'], }), - Site(name='Site 3', slug='site-3', custom_field_data={ - }), + Site(name='Site 3', slug='site-3'), ]) def test_filter_integer(self): @@ -730,3 +736,10 @@ class CustomFieldFilterTest(TestCase): def test_filter_select(self): self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0) + + def test_filter_multiselect(self): + self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0) diff --git a/netbox/ipam/filtersets.py b/netbox/ipam/filtersets.py index 9525d9bb3..37a9299dc 100644 --- a/netbox/ipam/filtersets.py +++ b/netbox/ipam/filtersets.py @@ -216,7 +216,7 @@ class PrefixFilterSet(PrimaryModelFilterSet, TenancyFilterSet): children = MultiValueNumberFilter( field_name='_children' ) - mask_length = django_filters.NumberFilter( + mask_length = MultiValueNumberFilter( field_name='prefix', lookup_expr='net_mask_length' ) diff --git a/netbox/ipam/forms.py b/netbox/ipam/forms.py index f4977c9c3..4d5b3ad73 100644 --- a/netbox/ipam/forms.py +++ b/netbox/ipam/forms.py @@ -491,11 +491,6 @@ class PrefixForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'status': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['vrf'].empty_label = 'Global' - class PrefixCSVForm(CustomFieldModelCSVForm): vrf = CSVModelChoiceField( @@ -658,11 +653,11 @@ class PrefixFilterForm(BootstrapMixin, TenancyFilterForm, CustomFieldModelFilter label=_('Address family'), widget=StaticSelect() ) - mask_length = forms.ChoiceField( + mask_length = forms.MultipleChoiceField( required=False, choices=PREFIX_MASK_LENGTH_CHOICES, label=_('Mask length'), - widget=StaticSelect() + widget=StaticSelectMultiple() ) vrf_id = DynamicModelMultipleChoiceField( queryset=VRF.objects.all(), @@ -760,11 +755,6 @@ class IPRangeForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'status': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - self.fields['vrf'].empty_label = 'Global' - class IPRangeCSVForm(CustomFieldModelCSVForm): vrf = CSVModelChoiceField( @@ -1026,8 +1016,6 @@ class IPAddressForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): super().__init__(*args, **kwargs) - self.fields['vrf'].empty_label = 'Global' - # Initialize primary_for_parent if IP address is already assigned if self.instance.pk and self.instance.assigned_object: parent = self.instance.assigned_object.parent_object @@ -1102,10 +1090,6 @@ class IPAddressBulkAddForm(BootstrapMixin, TenancyForm, CustomFieldModelForm): 'role': StaticSelect(), } - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.fields['vrf'].empty_label = 'Global' - class IPAddressCSVForm(CustomFieldModelCSVForm): vrf = CSVModelChoiceField( @@ -1256,8 +1240,7 @@ class IPAddressAssignForm(BootstrapMixin, forms.Form): vrf_id = DynamicModelChoiceField( queryset=VRF.objects.all(), required=False, - label='VRF', - empty_label='Global' + label='VRF' ) q = forms.CharField( required=False, diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py index 6c1b2d439..3e2e671ca 100644 --- a/netbox/ipam/models/ip.py +++ b/netbox/ipam/models/ip.py @@ -825,9 +825,9 @@ class IPAddress(PrimaryModel): if self.pk: for cls, attr in ((Device, 'device'), (VirtualMachine, 'virtual_machine')): parent = cls.objects.filter(Q(primary_ip4=self) | Q(primary_ip6=self)).first() - if parent and getattr(self.assigned_object, attr) != parent: + if parent and getattr(self.assigned_object, attr, None) != parent: # Check for a NAT relationship - if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr) != parent: + if not self.nat_inside or getattr(self.nat_inside.assigned_object, attr, None) != parent: raise ValidationError({ 'interface': f"IP address is primary for {cls._meta.model_name} {parent} but " f"not assigned to it!" diff --git a/netbox/ipam/tests/test_filtersets.py b/netbox/ipam/tests/test_filtersets.py index c82848aef..ff9dbfece 100644 --- a/netbox/ipam/tests/test_filtersets.py +++ b/netbox/ipam/tests/test_filtersets.py @@ -451,7 +451,7 @@ class PrefixTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_mask_length(self): - params = {'mask_length': '24'} + params = {'mask_length': ['24']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_vrf(self): diff --git a/netbox/ipam/views.py b/netbox/ipam/views.py index eed03885e..f84760418 100644 --- a/netbox/ipam/views.py +++ b/netbox/ipam/views.py @@ -403,13 +403,19 @@ class PrefixPrefixesView(generic.ObjectView): bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix) + # Compile permissions list for rendering the object table + permissions = { + 'change': request.user.has_perm('ipam.change_prefix'), + 'delete': request.user.has_perm('ipam.delete_prefix'), + } + return { - 'first_available_prefix': instance.get_first_available_prefix(), 'table': table, + 'permissions': permissions, 'bulk_querystring': bulk_querystring, 'active_tab': 'prefixes', + 'first_available_prefix': instance.get_first_available_prefix(), 'show_available': request.GET.get('show_available', 'true') == 'true', - 'table_config_form': TableConfigForm(table=table), } @@ -421,15 +427,22 @@ class PrefixIPRangesView(generic.ObjectView): # Find all IPRanges belonging to this Prefix ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf') - table = tables.IPRangeTable(ip_ranges) + table = tables.IPRangeTable(ip_ranges, user=request.user) if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'): table.columns.show('pk') paginate_table(table, request) bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix) + # Compile permissions list for rendering the object table + permissions = { + 'change': request.user.has_perm('ipam.change_iprange'), + 'delete': request.user.has_perm('ipam.delete_iprange'), + } + return { 'table': table, + 'permissions': permissions, 'bulk_querystring': bulk_querystring, 'active_tab': 'ip-ranges', } @@ -449,18 +462,25 @@ class PrefixIPAddressesView(generic.ObjectView): if request.GET.get('show_available', 'true') == 'true': ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool) - table = tables.IPAddressTable(ipaddresses) + table = tables.IPAddressTable(ipaddresses, user=request.user) if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'): table.columns.show('pk') paginate_table(table, request) bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix) + # Compile permissions list for rendering the object table + permissions = { + 'change': request.user.has_perm('ipam.change_ipaddress'), + 'delete': request.user.has_perm('ipam.delete_ipaddress'), + } + return { - 'first_available_ip': instance.get_first_available_ip(), 'table': table, + 'permissions': permissions, 'bulk_querystring': bulk_querystring, 'active_tab': 'ip-addresses', + 'first_available_ip': instance.get_first_available_ip(), 'show_available': request.GET.get('show_available', 'true') == 'true', } diff --git a/netbox/netbox/api/pagination.py b/netbox/netbox/api/pagination.py index d489ce951..77af755ce 100644 --- a/netbox/netbox/api/pagination.py +++ b/netbox/netbox/api/pagination.py @@ -34,23 +34,13 @@ class OptionalLimitOffsetPagination(LimitOffsetPagination): return list(queryset[self.offset:]) def get_limit(self, request): + limit = super().get_limit(request) - if self.limit_query_param: - try: - limit = int(request.query_params[self.limit_query_param]) - if limit < 0: - raise ValueError() - # Enforce maximum page size, if defined - if settings.MAX_PAGE_SIZE: - if limit == 0: - return settings.MAX_PAGE_SIZE - else: - return min(limit, settings.MAX_PAGE_SIZE) - return limit - except (KeyError, ValueError): - pass + # Enforce maximum page size + if settings.MAX_PAGE_SIZE: + limit = min(limit, settings.MAX_PAGE_SIZE) - return self.default_limit + return limit def get_next_link(self): diff --git a/netbox/netbox/middleware.py b/netbox/netbox/middleware.py index ef50edc4a..e0f376223 100644 --- a/netbox/netbox/middleware.py +++ b/netbox/netbox/middleware.py @@ -113,6 +113,10 @@ class ExceptionHandlingMiddleware(object): def process_exception(self, request, exception): + # Handle exceptions that occur from REST API requests + if is_api_request(request): + return rest_api_server_error(request) + # Don't catch exceptions when in debug mode if settings.DEBUG: return @@ -121,10 +125,6 @@ class ExceptionHandlingMiddleware(object): if isinstance(exception, Http404): return - # Handle exceptions that occur from REST API requests - if is_api_request(request): - return rest_api_server_error(request) - # Determine the type of exception. If it's a common issue, return a custom error page with instructions. custom_template = None if isinstance(exception, ProgrammingError): diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index b05097f3b..f70be12a0 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '3.0.0' +VERSION = '3.0.1' # Hostname HOSTNAME = platform.node() @@ -560,6 +560,10 @@ RQ_QUEUES = { # # Pagination +if MAX_PAGE_SIZE and PAGINATE_COUNT > MAX_PAGE_SIZE: + raise ImproperlyConfigured( + f"PAGINATE_COUNT ({PAGINATE_COUNT}) must be less than or equal to MAX_PAGE_SIZE ({MAX_PAGE_SIZE}), if set." + ) PER_PAGE_DEFAULTS = [ 25, 50, 100, 250, 500, 1000 ] diff --git a/netbox/netbox/views/generic.py b/netbox/netbox/views/generic.py index b00999b15..aafb2f3d8 100644 --- a/netbox/netbox/views/generic.py +++ b/netbox/netbox/views/generic.py @@ -181,7 +181,6 @@ class ObjectListView(ObjectPermissionRequiredMixin, View): 'table': table, 'permissions': permissions, 'action_buttons': self.action_buttons, - 'table_config_form': TableConfigForm(table=table), 'filter_form': self.filterset_form(request.GET, label_suffix='') if self.filterset_form else None, } context.update(self.extra_context()) diff --git a/netbox/project-static/dist/config.js b/netbox/project-static/dist/config.js index cd92937fb..cf1022589 100644 Binary files a/netbox/project-static/dist/config.js and b/netbox/project-static/dist/config.js differ diff --git a/netbox/project-static/dist/config.js.map b/netbox/project-static/dist/config.js.map index 5059d5e89..5f27e84a6 100644 Binary files a/netbox/project-static/dist/config.js.map and b/netbox/project-static/dist/config.js.map differ diff --git a/netbox/project-static/dist/jobs.js b/netbox/project-static/dist/jobs.js index b033042d1..3faf4b7ec 100644 Binary files a/netbox/project-static/dist/jobs.js and b/netbox/project-static/dist/jobs.js differ diff --git a/netbox/project-static/dist/jobs.js.map b/netbox/project-static/dist/jobs.js.map index f924ade99..e07a4157d 100644 Binary files a/netbox/project-static/dist/jobs.js.map and b/netbox/project-static/dist/jobs.js.map differ diff --git a/netbox/project-static/dist/lldp.js b/netbox/project-static/dist/lldp.js index 377a53bcf..86adb3db6 100644 Binary files a/netbox/project-static/dist/lldp.js and b/netbox/project-static/dist/lldp.js differ diff --git a/netbox/project-static/dist/lldp.js.map b/netbox/project-static/dist/lldp.js.map index 0c07910c6..028c35995 100644 Binary files a/netbox/project-static/dist/lldp.js.map and b/netbox/project-static/dist/lldp.js.map differ diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css index e319596b5..4caf6a4e6 100644 Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css index 06606cf83..f5db11538 100644 Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ diff --git a/netbox/project-static/dist/netbox-print.css b/netbox/project-static/dist/netbox-print.css index c13fad0fc..170e92bc4 100644 Binary files a/netbox/project-static/dist/netbox-print.css and b/netbox/project-static/dist/netbox-print.css differ diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js index a6c969773..c64df2e26 100644 Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map index 66a9d6d81..c9deea0b1 100644 Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ diff --git a/netbox/project-static/dist/status.js b/netbox/project-static/dist/status.js index 50714ec4e..2f3c2f762 100644 Binary files a/netbox/project-static/dist/status.js and b/netbox/project-static/dist/status.js differ diff --git a/netbox/project-static/dist/status.js.map b/netbox/project-static/dist/status.js.map index b8f51a48c..fc612cec8 100644 Binary files a/netbox/project-static/dist/status.js.map and b/netbox/project-static/dist/status.js.map differ diff --git a/netbox/project-static/src/bs.ts b/netbox/project-static/src/bs.ts index f31bbb9ef..e819b7cb1 100644 --- a/netbox/project-static/src/bs.ts +++ b/netbox/project-static/src/bs.ts @@ -1,6 +1,6 @@ -import { Collapse, Modal, Tab, Toast, Tooltip } from 'bootstrap'; +import { Collapse, Modal, Popover, Tab, Toast, Tooltip } from 'bootstrap'; import Masonry from 'masonry-layout'; -import { getElements } from './util'; +import { createElement, getElements } from './util'; type ToastLevel = 'danger' | 'warning' | 'success' | 'info'; @@ -8,6 +8,7 @@ type ToastLevel = 'danger' | 'warning' | 'success' | 'info'; // plugins). window.Collapse = Collapse; window.Modal = Modal; +window.Popover = Popover; window.Toast = Toast; window.Tooltip = Tooltip; @@ -156,13 +157,48 @@ function initSidebarAccordions(): void { } } +/** + * Initialize image preview popover, which shows a preview of an image from an image link with the + * `.image-preview` class. + */ +function initImagePreview(): void { + for (const element of getElements('a.image-preview')) { + // Generate a max-width that's a quarter of the screen's width (note - the actual element + // width will be slightly larger due to the popover body's padding). + const maxWidth = `${Math.round(window.innerWidth / 4)}px`; + + // Create an image element that uses the linked image as its `src`. + const image = createElement('img', { src: element.href }); + image.style.maxWidth = maxWidth; + + // Create a container for the image. + const content = createElement('div', null, null, [image]); + + // Initialize the Bootstrap Popper instance. + new Popover(element, { + // Attach this custom class to the popover so that it styling can be controlled via CSS. + customClass: 'image-preview-popover', + trigger: 'hover', + html: true, + content, + }); + } +} + /** * Enable any defined Bootstrap Tooltips. * * @see https://getbootstrap.com/docs/5.0/components/tooltips */ export function initBootstrap(): void { - for (const func of [initTooltips, initModals, initMasonry, initTabs, initSidebarAccordions]) { + for (const func of [ + initTooltips, + initModals, + initMasonry, + initTabs, + initImagePreview, + initSidebarAccordions, + ]) { func(); } } diff --git a/netbox/project-static/src/device/config.ts b/netbox/project-static/src/device/config.ts index cbe70952e..c9c19e8d3 100644 --- a/netbox/project-static/src/device/config.ts +++ b/netbox/project-static/src/device/config.ts @@ -13,18 +13,26 @@ function initConfig(): void { .then(data => { if (hasError(data)) { createToast('danger', 'Error Fetching Device Config', data.error).show(); + console.error(data.error); + return; + } else if (hasError>(data.get_config)) { + createToast('danger', 'Error Fetching Device Config', data.get_config.error).show(); + console.error(data.get_config.error); return; } else { - const configTypes = [ - 'running', - 'startup', - 'candidate', - ] as (keyof DeviceConfig['get_config'])[]; + const configTypes = ['running', 'startup', 'candidate'] as DeviceConfigType[]; for (const configType of configTypes) { const element = document.getElementById(`${configType}_config`); if (element !== null) { - element.innerHTML = data.get_config[configType]; + const config = data.get_config[configType]; + if (typeof config === 'string') { + // If the returned config is a string, set the element innerHTML as-is. + element.innerHTML = config; + } else { + // If the returned config is an object (dict), convert it to JSON. + element.innerHTML = JSON.stringify(data.get_config[configType], null, 2); + } } } } diff --git a/netbox/project-static/src/forms/vlanTags.ts b/netbox/project-static/src/forms/vlanTags.ts index 03ec73e60..4ad97c363 100644 --- a/netbox/project-static/src/forms/vlanTags.ts +++ b/netbox/project-static/src/forms/vlanTags.ts @@ -1,4 +1,4 @@ -import { all, getElement, resetSelect, toggleVisibility } from '../util'; +import { all, getElement, resetSelect, toggleVisibility as _toggleVisibility } from '../util'; /** * Get a select element's containing `.row` element. @@ -14,6 +14,38 @@ function fieldContainer(element: Nullable): Nullable>( + element: E, + action: 'show' | 'hide', +): void { + // Find the select element's containing element. + const parent = fieldContainer(element); + if (element !== null && parent !== null) { + // Toggle container visibility to visually remove it from the form. + _toggleVisibility(parent, action); + // Create a new event so that the APISelect instance properly handles the enable/disable + // action. + const event = new Event(`netbox.select.disabled.${element.name}`); + switch (action) { + case 'hide': + // Disable the native select element and dispatch the event APISelect is listening for. + element.disabled = true; + element.dispatchEvent(event); + break; + case 'show': + // Enable the native select element and dispatch the event APISelect is listening for. + element.disabled = false; + element.dispatchEvent(event); + } + } +} + /** * Toggle element visibility when the mode field does not have a value. */ @@ -29,7 +61,7 @@ function handleModeNone(): void { resetSelect(untaggedVlan); resetSelect(taggedVlans); for (const element of elements) { - toggleVisibility(fieldContainer(element), 'hide'); + toggleVisibility(element, 'hide'); } } } @@ -46,9 +78,9 @@ function handleModeAccess(): void { if (all(elements)) { const [taggedVlans, untaggedVlan, vlanGroup] = elements; resetSelect(taggedVlans); - toggleVisibility(fieldContainer(vlanGroup), 'show'); - toggleVisibility(fieldContainer(untaggedVlan), 'show'); - toggleVisibility(fieldContainer(taggedVlans), 'hide'); + toggleVisibility(vlanGroup, 'show'); + toggleVisibility(untaggedVlan, 'show'); + toggleVisibility(taggedVlans, 'hide'); } } @@ -63,9 +95,9 @@ function handleModeTagged(): void { ]; if (all(elements)) { const [taggedVlans, untaggedVlan, vlanGroup] = elements; - toggleVisibility(fieldContainer(taggedVlans), 'show'); - toggleVisibility(fieldContainer(vlanGroup), 'show'); - toggleVisibility(fieldContainer(untaggedVlan), 'show'); + toggleVisibility(taggedVlans, 'show'); + toggleVisibility(vlanGroup, 'show'); + toggleVisibility(untaggedVlan, 'show'); } } @@ -81,9 +113,9 @@ function handleModeTaggedAll(): void { if (all(elements)) { const [taggedVlans, untaggedVlan, vlanGroup] = elements; resetSelect(taggedVlans); - toggleVisibility(fieldContainer(vlanGroup), 'show'); - toggleVisibility(fieldContainer(untaggedVlan), 'show'); - toggleVisibility(fieldContainer(taggedVlans), 'hide'); + toggleVisibility(vlanGroup, 'show'); + toggleVisibility(untaggedVlan, 'show'); + toggleVisibility(taggedVlans, 'hide'); } } diff --git a/netbox/project-static/src/global.d.ts b/netbox/project-static/src/global.d.ts index f3dd7edd9..bad12c795 100644 --- a/netbox/project-static/src/global.d.ts +++ b/netbox/project-static/src/global.d.ts @@ -17,6 +17,11 @@ interface Window { */ Modal: typeof import('bootstrap').Modal; + /** + * Bootstrap Popover Instance. + */ + Popover: typeof import('bootstrap').Popover; + /** * Bootstrap Toast Instance. */ @@ -147,12 +152,15 @@ type LLDPNeighborDetail = { type DeviceConfig = { get_config: { - candidate: string; - running: string; - startup: string; + candidate: string | Record; + running: string | Record; + startup: string | Record; + error?: string; }; }; +type DeviceConfigType = Exclude; + type DeviceEnvironment = { cpu?: { [core: string]: { '%usage': number }; diff --git a/netbox/project-static/src/select/api/apiSelect.ts b/netbox/project-static/src/select/api/apiSelect.ts index 51648fc48..fe7c218a3 100644 --- a/netbox/project-static/src/select/api/apiSelect.ts +++ b/netbox/project-static/src/select/api/apiSelect.ts @@ -320,6 +320,7 @@ export class APISelect { this.slim.slim.multiSelected.container.setAttribute('disabled', ''); } } + this.slim.disable(); } /** @@ -335,6 +336,7 @@ export class APISelect { this.slim.slim.multiSelected.container.removeAttribute('disabled'); } } + this.slim.enable(); } /** @@ -357,6 +359,11 @@ export class APISelect { this.fetchOptions(this.more, 'merge'), ); + // When the base select element is disabled or enabled, properly disable/enable this instance. + this.base.addEventListener(`netbox.select.disabled.${this.name}`, event => + this.handleDisableEnable(event), + ); + // Create a unique iterator of all possible form fields which, when changed, should cause this // element to update its API query. // const dependencies = new Set([...this.filterParams.keys(), ...this.pathValues.keys()]); @@ -389,6 +396,19 @@ export class APISelect { } } + /** + * Get all options from the native select element that are already selected and do not contain + * placeholder values. + */ + private getPreselectedOptions(): HTMLOptionElement[] { + return Array.from(this.base.options) + .filter(option => option.selected) + .filter(option => { + if (option.value === '---------' || option.innerText === '---------') return false; + return true; + }); + } + /** * Process a valid API response and add results to this instance's options. * @@ -398,13 +418,19 @@ export class APISelect { data: APIAnswer, action: ApplyMethod = 'merge', ): Promise { - // Get all non-placeholder (empty) options' values. If any exist, it means we're editing an - // existing object. When we fetch options from the API later, we can set any of the options - // contained in this array to `selected`. - const selectOptions = Array.from(this.base.options) - .filter(option => option.selected) - .map(option => option.getAttribute('value')) - .filter(isTruthy); + // Get all already-selected options. + const preSelected = this.getPreselectedOptions(); + + // Get the values of all already-selected options. + const selectedValues = preSelected.map(option => option.getAttribute('value')).filter(isTruthy); + + // Build SlimSelect options from all already-selected options. + const preSelectedOptions = preSelected.map(option => ({ + value: option.value, + text: option.innerText, + selected: true, + disabled: false, + })) as Option[]; let options = [] as Option[]; @@ -441,12 +467,12 @@ export class APISelect { } // Set option to disabled if it is contained within the disabled array. - if (selectOptions.some(option => this.disabledOptions.includes(option))) { + if (selectedValues.some(option => this.disabledOptions.includes(option))) { disabled = true; } // Set pre-selected options. - if (selectOptions.includes(value)) { + if (selectedValues.includes(value)) { selected = true; // If an option is selected, it can't be disabled. Otherwise, it won't be submitted with // the rest of the form, resulting in that field's value being deleting from the object. @@ -469,7 +495,8 @@ export class APISelect { this.options = [...this.options, ...options]; break; case 'replace': - this.options = options; + this.options = [...preSelectedOptions, ...options]; + break; } if (hasMore(data)) { @@ -558,6 +585,23 @@ export class APISelect { Promise.all([this.loadData()]); } + /** + * Event handler to be dispatched when the base select element is disabled or enabled. When that + * occurs, run the instance's `disable()` or `enable()` methods to synchronize UI state with + * desired action. + * + * @param event Dispatched event matching pattern `netbox.select.disabled.` + */ + private handleDisableEnable(event: Event): void { + const target = event.target as HTMLSelectElement; + + if (target.disabled === true) { + this.disable(); + } else if (target.disabled === false) { + this.enable(); + } + } + /** * When the API returns an error, show it to the user and reset this element's available options. * @@ -715,7 +759,7 @@ export class APISelect { private getPlaceholder(): string { let placeholder = this.name; if (this.base.id) { - const label = document.querySelector(`label[for=${this.base.id}]`) as HTMLLabelElement; + const label = document.querySelector(`label[for="${this.base.id}"]`) as HTMLLabelElement; // Set the placeholder text to the label value, if it exists. if (label !== null) { placeholder = `Select ${label.innerText.trim()}`; diff --git a/netbox/project-static/src/select/static.ts b/netbox/project-static/src/select/static.ts index 550e5ba7d..c649537ba 100644 --- a/netbox/project-static/src/select/static.ts +++ b/netbox/project-static/src/select/static.ts @@ -4,7 +4,7 @@ import { getElements } from '../util'; export function initStaticSelect(): void { for (const select of getElements('.netbox-static-select')) { if (select !== null) { - const label = document.querySelector(`label[for=${select.id}]`) as HTMLLabelElement; + const label = document.querySelector(`label[for="${select.id}"]`) as HTMLLabelElement; let placeholder; if (label !== null) { diff --git a/netbox/project-static/src/util.ts b/netbox/project-static/src/util.ts index 7ff6ad869..9103a7b01 100644 --- a/netbox/project-static/src/util.ts +++ b/netbox/project-static/src/util.ts @@ -1,4 +1,5 @@ import Cookie from 'cookie'; +import queryString from 'query-string'; type Method = 'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'; type ReqData = URLSearchParams | Dict | undefined | unknown; @@ -11,14 +12,16 @@ type InferredProps< // Element name. T extends keyof HTMLElementTagNameMap, // Element type. - E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T] + E extends HTMLElementTagNameMap[T] = HTMLElementTagNameMap[T], > = Partial>; export function isApiError(data: Record): data is APIError { return 'error' in data && 'exception' in data; } -export function hasError(data: Record): data is ErrorBase { +export function hasError( + data: Record, +): data is E { return 'error' in data; } @@ -94,7 +97,7 @@ export function isElement(obj: Element | null | undefined): obj is Element { /** * Retrieve the CSRF token from cookie storage. */ -export function getCsrfToken(): string { +function getCsrfToken(): string { const { csrftoken: csrfToken } = Cookie.parse(document.cookie); if (typeof csrfToken === 'undefined') { throw new Error('Invalid or missing CSRF token'); @@ -102,8 +105,60 @@ export function getCsrfToken(): string { return csrfToken; } +/** + * Get the NetBox `settings.BASE_PATH` from the `` element's data attributes. + * + * @returns If there is no `BASE_PATH` specified, the return value will be `''`. + */ function getBasePath(): string { + const value = document.documentElement.getAttribute('data-netbox-base-path'); + if (value === null) { + return ''; + } + return value; +} + +/** + * Build a NetBox URL that includes `settings.BASE_PATH` and enforces leading and trailing slashes. + * + * @example + * ```js + * // With a BASE_PATH of 'netbox/' + * const url = buildUrl('/api/dcim/devices'); + * console.log(url); + * // => /netbox/api/dcim/devices/ + * ``` + * + * @param path Relative path _after_ (excluding) the `BASE_PATH`. + */ +function buildUrl(destination: string): string { + // Separate the path from any URL search params. + const [pathname, search] = destination.split(/(?=\?)/g); + + // If the `origin` exists in the API path (as in the case of paginated responses), remove it. + const origin = new RegExp(window.location.origin, 'g'); + const path = pathname.replaceAll(origin, ''); + + const basePath = getBasePath(); + + // Combine `BASE_PATH` with this request's path, removing _all_ slashes. + let combined = [...basePath.split('/'), ...path.split('/')].filter(p => p); + + if (combined[0] !== '/') { + // Ensure the URL has a leading slash. + combined = ['', ...combined]; + } + if (combined[combined.length - 1] !== '/') { + // Ensure the URL has a trailing slash. + combined = [...combined, '']; + } + const url = combined.join('/'); + // Construct an object from the URL search params so it can be re-serialized with the new URL. + const query = Object.fromEntries(new URLSearchParams(search).entries()); + return queryString.stringifyUrl({ url, query }); +} + export async function apiRequest( - url: string, + path: string, method: Method, data?: D, ): Promise> { @@ -115,6 +170,7 @@ export async function apiRequest( body = JSON.stringify(data); headers.set('content-type', 'application/json'); } + const url = buildUrl(path); const res = await fetch(url, { method, body, headers, credentials: 'same-origin' }); const contentType = res.headers.get('Content-Type'); @@ -367,8 +423,13 @@ export function createElement< // Element props. P extends InferredProps, // Child element type. - C extends HTMLElement = HTMLElement ->(tag: T, properties: P | null, classes: string[], children: C[] = []): HTMLElementTagNameMap[T] { + C extends HTMLElement = HTMLElement, +>( + tag: T, + properties: P | null, + classes: Nullable = null, + children: C[] = [], +): HTMLElementTagNameMap[T] { // Create the base element. const element = document.createElement(tag); @@ -384,7 +445,9 @@ export function createElement< } // Add each CSS class to the element's class list. - element.classList.add(...classes); + if (classes !== null && classes.length > 0) { + element.classList.add(...classes); + } for (const child of children) { // Add each child element to the base element. diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss index cc70dd2a0..b05cc07d4 100644 --- a/netbox/project-static/styles/netbox.scss +++ b/netbox/project-static/styles/netbox.scss @@ -956,6 +956,11 @@ div.card-overlay { } } +// Remove the max-width from image preview popovers as this is controlled on the image element. +.popover.image-preview-popover { + max-width: unset; +} + #django-messages { position: fixed; right: $spacer; diff --git a/netbox/templates/500.html b/netbox/templates/500.html index 427345e56..097699ffc 100644 --- a/netbox/templates/500.html +++ b/netbox/templates/500.html @@ -4,7 +4,7 @@ Server Error - + @@ -12,7 +12,7 @@
-
+
Server Error
@@ -32,7 +32,7 @@ Python version: {{ python_version }} NetBox version: {{ netbox_version }}

- If further assistance is required, please post to the NetBox mailing list. + If further assistance is required, please post to the NetBox discussion forum on GitHub.

Home Page diff --git a/netbox/templates/base/base.html b/netbox/templates/base/base.html index 0a1f846fd..ad2cefced 100644 --- a/netbox/templates/base/base.html +++ b/netbox/templates/base/base.html @@ -5,6 +5,7 @@ + NetBox Logo {# Icon Logo #} - + NetBox Logo diff --git a/netbox/templates/dcim/site.html b/netbox/templates/dcim/site.html index 17628cbde..8a348c308 100644 --- a/netbox/templates/dcim/site.html +++ b/netbox/templates/dcim/site.html @@ -109,8 +109,8 @@ {% if object.physical_address %} {{ object.physical_address|linebreaksbr }} @@ -129,7 +129,7 @@ {% if object.latitude and object.longitude %} {{ object.latitude }}, {{ object.longitude }} diff --git a/netbox/templates/ipam/ipaddress_assign.html b/netbox/templates/ipam/ipaddress_assign.html index 912056b9a..9a052d966 100644 --- a/netbox/templates/ipam/ipaddress_assign.html +++ b/netbox/templates/ipam/ipaddress_assign.html @@ -9,7 +9,7 @@ {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %} {% endblock %} -{% block content %} +{% block form %}
{% csrf_token %} {% for field in form.hidden_fields %} @@ -17,13 +17,10 @@ {% endfor %}
- {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %} -
-
Select IP Address
-
- {% render_field form.vrf_id %} - {% render_field form.q %} -
+
+
Select IP Address
+ {% render_field form.vrf_id %} + {% render_field form.q %}
@@ -42,4 +39,7 @@
{% endif %} -{% endblock %} +{% endblock form %} + +{% block buttons %} +{% endblock buttons%} diff --git a/netbox/templates/ipam/prefix/ip_addresses.html b/netbox/templates/ipam/prefix/ip_addresses.html index e8c9704bb..5aaacabe1 100644 --- a/netbox/templates/ipam/prefix/ip_addresses.html +++ b/netbox/templates/ipam/prefix/ip_addresses.html @@ -1,8 +1,10 @@ {% extends 'ipam/prefix/base.html' %} +{% load helpers %} +{% load static %} {% block extra_controls %} - {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} - + {% if perms.ipam.add_ipaddress and first_available_ip %} + Add IP Address {% endif %} @@ -11,7 +13,9 @@ {% block content %}
+ {% include 'inc/table_controls.html' with table_modal="IPAddressTable_config" %} {% include 'utilities/obj_table.html' with heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
+ {% table_config_form table table_name="IPAddressTable" %} {% endblock %} diff --git a/netbox/templates/ipam/prefix/ip_ranges.html b/netbox/templates/ipam/prefix/ip_ranges.html index 334cc90b0..e43014042 100644 --- a/netbox/templates/ipam/prefix/ip_ranges.html +++ b/netbox/templates/ipam/prefix/ip_ranges.html @@ -1,10 +1,13 @@ {% extends 'ipam/prefix/base.html' %} - +{% load helpers %} +{% load static %} {% block content %}
- {% include 'utilities/obj_table.html' with heading='Child IP Ranges' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %} + {% include 'inc/table_controls.html' with table_modal="IPRangeTable_config" %} + {% include 'utilities/obj_table.html' with heading='Child IP Ranges' bulk_edit_url='ipam:iprange_bulk_edit' bulk_delete_url='ipam:iprange_bulk_delete' parent=prefix %}
+ {% table_config_form table table_name="IPRangeTable" %} {% endblock %} diff --git a/netbox/templates/ipam/prefix/prefixes.html b/netbox/templates/ipam/prefix/prefixes.html index 998b7b70b..e6c109a39 100644 --- a/netbox/templates/ipam/prefix/prefixes.html +++ b/netbox/templates/ipam/prefix/prefixes.html @@ -2,20 +2,11 @@ {% load helpers %} {% load static %} -{% block buttons %} +{% block extra_controls %} {% include 'ipam/inc/toggle_available.html' %} - {% if request.user.is_authenticated and table_config_form %} - - {% endif %} - {% if perms.ipam.add_prefix and active_tab == 'prefixes' and first_available_prefix %} - - Add Child Prefix - - {% endif %} - {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and first_available_ip %} - - - Add an IP Address + {% if perms.ipam.add_prefix and first_available_prefix %} + + Add Prefix {% endif %} {{ block.super }} @@ -24,12 +15,9 @@ {% block content %}
+ {% include 'inc/table_controls.html' with table_modal="PrefixDetailTable_config" %} {% include 'utilities/obj_table.html' with heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' parent=prefix %}
- {% table_config_form prefix_table table_name="PrefixDetailTable" %} -{% endblock %} - -{% block javascript %} - + {% table_config_form table table_name="PrefixDetailTable" %} {% endblock %} diff --git a/netbox/templates/media_failure.html b/netbox/templates/media_failure.html index 10b01291d..e3b7ef309 100644 --- a/netbox/templates/media_failure.html +++ b/netbox/templates/media_failure.html @@ -42,7 +42,7 @@ The file {{ filename }} exists in the static root directory and is readable by the HTTP process. -

Click here to attempt loading NetBox again.

+

Click here to attempt loading NetBox again.

diff --git a/netbox/templates/rest_framework/api.html b/netbox/templates/rest_framework/api.html index 36bf7a554..7ece5905c 100644 --- a/netbox/templates/rest_framework/api.html +++ b/netbox/templates/rest_framework/api.html @@ -9,5 +9,5 @@ {% block title %}{% if name %}{{ name }} | {% endif %}NetBox REST API{% endblock %} {% block branding %} - NetBox + NetBox {% endblock branding %} diff --git a/netbox/templates/utilities/obj_table.html b/netbox/templates/utilities/obj_table.html index ad14de48d..fdcc59fa8 100644 --- a/netbox/templates/utilities/obj_table.html +++ b/netbox/templates/utilities/obj_table.html @@ -1,40 +1,44 @@ {% load helpers %} + {% if permissions.change or permissions.delete %} {% csrf_token %} + {% if table.paginator.num_pages > 1 %}
-
-
- {% if bulk_edit_url and permissions.change %} - - {% endif %} - {% if bulk_delete_url and permissions.delete %} - - {% endif %} -
-
- - -
+
+ {% if bulk_edit_url and permissions.change %} + + {% endif %} + {% if bulk_delete_url and permissions.delete %} + + {% endif %} +
+
+ +
{% endif %} + {% include table_template|default:'inc/responsive_table.html' %} +
{% block extra_actions %}{% endblock %} + {% if bulk_edit_url and permissions.change %} {% endif %} + {% if bulk_delete_url and permissions.delete %}
{% else %} + {% include table_template|default:'inc/responsive_table.html' %} + {% endif %} - {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} -
+ +{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %} diff --git a/netbox/templates/utilities/templatetags/table_config_form.html b/netbox/templates/utilities/templatetags/table_config_form.html index 3c44642d4..5e17497e9 100644 --- a/netbox/templates/utilities/templatetags/table_config_form.html +++ b/netbox/templates/utilities/templatetags/table_config_form.html @@ -7,11 +7,11 @@
-
+