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

Merge pull request #8073 from netbox-community/8057-htmx-tables

Closes #8057: Dynamic object tables using HTMX
Jeremy Stretch 4 лет назад
Родитель
Сommit
57d3bfcfc9
50 измененных файлов с 649 добавлено и 457 удалено
  1. 17 0
      netbox/dcim/views.py
  2. 6 0
      netbox/ipam/models/ip.py
  3. 1 0
      netbox/ipam/urls.py
  4. 32 32
      netbox/ipam/views.py
  5. 30 2
      netbox/netbox/views/generic.py
  6. 0 0
      netbox/project-static/dist/netbox-dark.css
  7. 0 0
      netbox/project-static/dist/netbox-light.css
  8. 0 0
      netbox/project-static/dist/netbox-print.css
  9. 0 0
      netbox/project-static/dist/netbox.js
  10. 0 0
      netbox/project-static/dist/netbox.js.map
  11. 1 0
      netbox/project-static/package.json
  12. 0 2
      netbox/project-static/src/buttons/index.ts
  13. 0 14
      netbox/project-static/src/buttons/pagination.ts
  14. 1 0
      netbox/project-static/src/index.ts
  15. 2 104
      netbox/project-static/src/search.ts
  16. 0 12
      netbox/project-static/styles/netbox.scss
  17. 5 0
      netbox/project-static/yarn.lock
  18. 4 9
      netbox/templates/dcim/connections_list.html
  19. 7 4
      netbox/templates/dcim/device/consoleports.html
  20. 7 3
      netbox/templates/dcim/device/consoleserverports.html
  21. 7 3
      netbox/templates/dcim/device/devicebays.html
  22. 7 3
      netbox/templates/dcim/device/frontports.html
  23. 15 3
      netbox/templates/dcim/device/interfaces.html
  24. 7 3
      netbox/templates/dcim/device/inventory.html
  25. 7 3
      netbox/templates/dcim/device/poweroutlets.html
  26. 7 3
      netbox/templates/dcim/device/powerports.html
  27. 7 3
      netbox/templates/dcim/device/rearports.html
  28. 7 11
      netbox/templates/dcim/devicetype/component_templates.html
  29. 3 7
      netbox/templates/generic/object_list.html
  30. 5 0
      netbox/templates/htmx/table.html
  31. 40 39
      netbox/templates/inc/paginator.html
  32. 72 0
      netbox/templates/inc/paginator_htmx.html
  33. 8 3
      netbox/templates/inc/table_controls_htmx.html
  34. 49 0
      netbox/templates/inc/table_htmx.html
  35. 53 69
      netbox/templates/ipam/aggregate.html
  36. 23 0
      netbox/templates/ipam/aggregate/base.html
  37. 36 0
      netbox/templates/ipam/aggregate/prefixes.html
  38. 4 1
      netbox/templates/ipam/ipaddress_assign.html
  39. 26 4
      netbox/templates/ipam/iprange/ip_addresses.html
  40. 25 7
      netbox/templates/ipam/prefix/ip_addresses.html
  41. 25 7
      netbox/templates/ipam/prefix/ip_ranges.html
  42. 25 7
      netbox/templates/ipam/prefix/prefixes.html
  43. 12 4
      netbox/templates/ipam/vlan/interfaces.html
  44. 12 4
      netbox/templates/ipam/vlan/vminterfaces.html
  45. 0 62
      netbox/templates/utilities/obj_table.html
  46. 15 16
      netbox/templates/virtualization/cluster/devices.html
  47. 23 9
      netbox/templates/virtualization/cluster/virtual_machines.html
  48. 7 4
      netbox/templates/virtualization/virtualmachine/interfaces.html
  49. 5 0
      netbox/utilities/htmx.py
  50. 4 0
      netbox/virtualization/views.py

+ 17 - 0
netbox/dcim/views.py

@@ -797,41 +797,49 @@ class DeviceTypeView(generic.ObjectView):
 class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
     child_model = ConsolePortTemplate
     table = tables.ConsolePortTemplateTable
+    filterset = filtersets.ConsolePortTemplateFilterSet
 
 
 class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
     child_model = ConsoleServerPortTemplate
     table = tables.ConsoleServerPortTemplateTable
+    filterset = filtersets.ConsoleServerPortTemplateFilterSet
 
 
 class DeviceTypePowerPortsView(DeviceTypeComponentsView):
     child_model = PowerPortTemplate
     table = tables.PowerPortTemplateTable
+    filterset = filtersets.PowerPortTemplateFilterSet
 
 
 class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
     child_model = PowerOutletTemplate
     table = tables.PowerOutletTemplateTable
+    filterset = filtersets.PowerOutletTemplateFilterSet
 
 
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
     child_model = InterfaceTemplate
     table = tables.InterfaceTemplateTable
+    filterset = filtersets.InterfaceTemplateFilterSet
 
 
 class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
     child_model = FrontPortTemplate
     table = tables.FrontPortTemplateTable
+    filterset = filtersets.FrontPortTemplateFilterSet
 
 
 class DeviceTypeRearPortsView(DeviceTypeComponentsView):
     child_model = RearPortTemplate
     table = tables.RearPortTemplateTable
+    filterset = filtersets.RearPortTemplateFilterSet
 
 
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
     child_model = DeviceBayTemplate
     table = tables.DeviceBayTemplateTable
+    filterset = filtersets.DeviceBayTemplateFilterSet
 
 
 class DeviceTypeEditView(generic.ObjectEditView):
@@ -1328,30 +1336,35 @@ class DeviceView(generic.ObjectView):
 class DeviceConsolePortsView(DeviceComponentsView):
     child_model = ConsolePort
     table = tables.DeviceConsolePortTable
+    filterset = filtersets.ConsolePortFilterSet
     template_name = 'dcim/device/consoleports.html'
 
 
 class DeviceConsoleServerPortsView(DeviceComponentsView):
     child_model = ConsoleServerPort
     table = tables.DeviceConsoleServerPortTable
+    filterset = filtersets.ConsoleServerPortFilterSet
     template_name = 'dcim/device/consoleserverports.html'
 
 
 class DevicePowerPortsView(DeviceComponentsView):
     child_model = PowerPort
     table = tables.DevicePowerPortTable
+    filterset = filtersets.PowerPortFilterSet
     template_name = 'dcim/device/powerports.html'
 
 
 class DevicePowerOutletsView(DeviceComponentsView):
     child_model = PowerOutlet
     table = tables.DevicePowerOutletTable
+    filterset = filtersets.PowerOutletFilterSet
     template_name = 'dcim/device/poweroutlets.html'
 
 
 class DeviceInterfacesView(DeviceComponentsView):
     child_model = Interface
     table = tables.DeviceInterfaceTable
+    filterset = filtersets.InterfaceFilterSet
     template_name = 'dcim/device/interfaces.html'
 
     def get_children(self, request, parent):
@@ -1364,24 +1377,28 @@ class DeviceInterfacesView(DeviceComponentsView):
 class DeviceFrontPortsView(DeviceComponentsView):
     child_model = FrontPort
     table = tables.DeviceFrontPortTable
+    filterset = filtersets.FrontPortFilterSet
     template_name = 'dcim/device/frontports.html'
 
 
 class DeviceRearPortsView(DeviceComponentsView):
     child_model = RearPort
     table = tables.DeviceRearPortTable
+    filterset = filtersets.RearPortFilterSet
     template_name = 'dcim/device/rearports.html'
 
 
 class DeviceDeviceBaysView(DeviceComponentsView):
     child_model = DeviceBay
     table = tables.DeviceDeviceBayTable
+    filterset = filtersets.DeviceBayFilterSet
     template_name = 'dcim/device/devicebays.html'
 
 
 class DeviceInventoryView(DeviceComponentsView):
     child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
+    filterset = filtersets.InventoryItemFilterSet
     template_name = 'dcim/device/inventory.html'
 
 

+ 6 - 0
netbox/ipam/models/ip.py

@@ -195,6 +195,12 @@ class Aggregate(PrimaryModel):
             return self.prefix.version
         return None
 
+    def get_child_prefixes(self):
+        """
+        Return all Prefixes within this Aggregate
+        """
+        return Prefix.objects.filter(prefix__net_contained=str(self.prefix))
+
     def get_utilization(self):
         """
         Determine the prefix utilization of the aggregate and return it as a percentage.

+ 1 - 0
netbox/ipam/urls.py

@@ -61,6 +61,7 @@ urlpatterns = [
     path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
     path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
+    path('aggregates/<int:pk>/prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'),
     path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
     path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
     path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),

+ 32 - 32
netbox/ipam/views.py

@@ -1,21 +1,22 @@
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Prefetch
 from django.db.models.expressions import RawSQL
-from django.http import Http404
 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.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 . import filtersets, forms, tables
 from .constants import *
 from .models import *
 from .models import ASN
-from .utils import add_available_ipaddresses, add_requested_prefixes, add_available_vlans
+from .utils import add_requested_prefixes, add_available_vlans
 
 
 #
@@ -274,39 +275,32 @@ class AggregateListView(generic.ObjectListView):
 class AggregateView(generic.ObjectView):
     queryset = Aggregate.objects.all()
 
-    def get_extra_context(self, request, instance):
-        # Find all child prefixes contained in this aggregate
-        prefix_list = Prefix.objects.restrict(request.user, 'view').filter(
-            prefix__net_contained_or_equal=str(instance.prefix)
-        ).prefetch_related(
-            'site', 'role'
-        ).order_by(
-            'prefix'
-        )
 
-        # Return List of requested Prefixes
+class AggregatePrefixesView(generic.ObjectChildrenView):
+    queryset = Aggregate.objects.all()
+    child_model = Prefix
+    table = tables.PrefixTable
+    filterset = filtersets.PrefixFilterSet
+    template_name = 'ipam/aggregate/prefixes.html'
+
+    def get_children(self, request, parent):
+        return Prefix.objects.restrict(request.user, 'view').filter(
+            prefix__net_contained_or_equal=str(parent.prefix)
+        ).prefetch_related('site', 'role', 'tenant', 'vlan')
+
+    def prep_table_data(self, request, queryset, parent):
+        # Determine whether to show assigned prefixes, available prefixes, or both
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
         show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
-        child_prefixes = add_requested_prefixes(instance.prefix, prefix_list, show_available, show_assigned)
 
-        prefix_table = tables.PrefixTable(child_prefixes, exclude=('utilization',))
-        if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
-            prefix_table.columns.show('pk')
-        paginate_table(prefix_table, request)
-
-        # Compile permissions list for rendering the object table
-        permissions = {
-            'add': request.user.has_perm('ipam.add_prefix'),
-            'change': request.user.has_perm('ipam.change_prefix'),
-            'delete': request.user.has_perm('ipam.delete_prefix'),
-        }
+        return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
 
+    def get_extra_context(self, request, instance):
         return {
-            'prefix_table': prefix_table,
-            'permissions': permissions,
             'bulk_querystring': f'within={instance.prefix}',
-            'show_available': show_available,
-            'show_assigned': show_assigned,
+            'active_tab': 'prefixes',
+            'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
+            'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
         }
 
 
@@ -457,17 +451,18 @@ class PrefixPrefixesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     child_model = Prefix
     table = tables.PrefixTable
+    filterset = filtersets.PrefixFilterSet
     template_name = 'ipam/prefix/prefixes.html'
 
     def get_children(self, request, parent):
-        child_prefixes = parent.get_child_prefixes().restrict(request.user, 'view')
+        return parent.get_child_prefixes().restrict(request.user, 'view')
 
-        # Add available prefixes if requested
+    def prep_table_data(self, request, queryset, parent):
+        # Determine whether to show assigned prefixes, available prefixes, or both
         show_available = bool(request.GET.get('show_available', 'true') == 'true')
         show_assigned = bool(request.GET.get('show_assigned', 'true') == 'true')
-        child_prefixes = add_requested_prefixes(parent.prefix, child_prefixes, show_available, show_assigned)
 
-        return child_prefixes
+        return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
 
     def get_extra_context(self, request, instance):
         return {
@@ -483,6 +478,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     child_model = IPRange
     table = tables.IPRangeTable
+    filterset = filtersets.IPRangeFilterSet
     template_name = 'ipam/prefix/ip_ranges.html'
 
     def get_children(self, request, parent):
@@ -499,6 +495,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
     queryset = Prefix.objects.all()
     child_model = IPAddress
     table = tables.IPAddressTable
+    filterset = filtersets.IPAddressFilterSet
     template_name = 'ipam/prefix/ip_addresses.html'
 
     def get_children(self, request, parent):
@@ -560,6 +557,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
     queryset = IPRange.objects.all()
     child_model = IPAddress
     table = tables.IPAddressTable
+    filterset = filtersets.IPAddressFilterSet
     template_name = 'ipam/iprange/ip_addresses.html'
 
     def get_children(self, request, parent):
@@ -959,6 +957,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
     queryset = VLAN.objects.all()
     child_model = Interface
     table = tables.VLANDevicesTable
+    filterset = InterfaceFilterSet
     template_name = 'ipam/vlan/interfaces.html'
 
     def get_children(self, request, parent):
@@ -974,6 +973,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
     queryset = VLAN.objects.all()
     child_model = VMInterface
     table = tables.VLANVirtualMachinesTable
+    filterset = VMInterfaceFilterSet
     template_name = 'ipam/vlan/vminterfaces.html'
 
     def get_children(self, request, parent):

+ 30 - 2
netbox/netbox/views/generic.py

@@ -23,6 +23,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.forms import (
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
 )
+from utilities.htmx import is_htmx
 from utilities.permissions import get_permission_for_model
 from utilities.tables import paginate_table
 from utilities.utils import normalize_querydict, prepare_cloned_fields
@@ -83,17 +84,28 @@ class ObjectChildrenView(ObjectView):
     queryset = None
     child_model = None
     table = None
+    filterset = None
     template_name = None
 
     def get_children(self, request, parent):
         """
-        Return a QuerySet or iterable of child objects.
+        Return a QuerySet of child objects.
 
         request: The current request
         parent: The parent object
         """
         raise NotImplementedError(f'{self.__class__.__name__} must implement get_children()')
 
+    def prep_table_data(self, request, queryset, parent):
+        """
+        Provides a hook for subclassed views to modify data before initializing the table.
+
+        :param request: The current request
+        :param queryset: The filtered queryset of child objects
+        :param parent: The parent object
+        """
+        return queryset
+
     def get(self, request, *args, **kwargs):
         """
         GET handler for rendering child objects.
@@ -101,17 +113,27 @@ class ObjectChildrenView(ObjectView):
         instance = get_object_or_404(self.queryset, **kwargs)
         child_objects = self.get_children(request, instance)
 
+        if self.filterset:
+            child_objects = self.filterset(request.GET, child_objects).qs
+
         permissions = {}
         for action in ('change', 'delete'):
             perm_name = get_permission_for_model(self.child_model, action)
             permissions[action] = request.user.has_perm(perm_name)
 
-        table = self.table(child_objects, user=request.user)
+        table = self.table(self.prep_table_data(request, child_objects, instance), user=request.user)
         # Determine whether to display bulk action checkboxes
         if 'pk' in table.base_columns and (permissions['change'] or permissions['delete']):
             table.columns.show('pk')
         paginate_table(table, request)
 
+        # If this is an HTMX request, return only the rendered table HTML
+        if is_htmx(request):
+            return render(request, 'htmx/table.html', {
+                'object': instance,
+                'table': table,
+            })
+
         return render(request, self.get_template_name(), {
             'object': instance,
             'table': table,
@@ -233,6 +255,12 @@ class ObjectListView(ObjectPermissionRequiredMixin, View):
         table = self.get_table(request, permissions)
         paginate_table(table, request)
 
+        # If this is an HTMX request, return only the rendered table HTML
+        if is_htmx(request):
+            return render(request, 'htmx/table.html', {
+                'table': table,
+            })
+
         context = {
             'content_type': content_type,
             'table': table,

Разница между файлами не показана из-за своего большого размера
+ 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


+ 1 - 0
netbox/project-static/package.json

@@ -30,6 +30,7 @@
     "cookie": "^0.4.1",
     "dayjs": "^1.10.4",
     "flatpickr": "4.6.3",
+    "htmx.org": "^1.6.1",
     "just-debounce-it": "^1.4.0",
     "masonry-layout": "^4.2.2",
     "query-string": "^6.14.1",

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

@@ -1,7 +1,6 @@
 import { initConnectionToggle } from './connectionToggle';
 import { initDepthToggle } from './depthToggle';
 import { initMoveButtons } from './moveOptions';
-import { initPerPage } from './pagination';
 import { initPreferenceUpdate } from './preferences';
 import { initReslug } from './reslug';
 import { initSelectAll } from './selectAll';
@@ -13,7 +12,6 @@ export function initButtons(): void {
     initReslug,
     initSelectAll,
     initPreferenceUpdate,
-    initPerPage,
     initMoveButtons,
   ]) {
     func();

+ 0 - 14
netbox/project-static/src/buttons/pagination.ts

@@ -1,14 +0,0 @@
-import { getElements } from '../util';
-
-function handlePerPageSelect(event: Event): void {
-  const select = event.currentTarget as HTMLSelectElement;
-  if (select.form !== null) {
-    select.form.submit();
-  }
-}
-
-export function initPerPage(): void {
-  for (const element of getElements<HTMLSelectElement>('select.per-page')) {
-    element.addEventListener('change', handlePerPageSelect);
-  }
-}

+ 1 - 0
netbox/project-static/src/index.ts

@@ -1,4 +1,5 @@
 import '@popperjs/core';
 import 'bootstrap';
+import 'htmx.org';
 import 'simplebar';
 import './netbox';

+ 2 - 104
netbox/project-static/src/search.ts

@@ -1,5 +1,4 @@
-import debounce from 'just-debounce-it';
-import { getElements, getRowValues, findFirstAdjacent, isTruthy } from './util';
+import { getElements, findFirstAdjacent, isTruthy } from './util';
 
 /**
  * Change the display value and hidden input values of the search filter based on dropdown
@@ -41,109 +40,8 @@ function initSearchBar(): void {
   }
 }
 
-/**
- * Initialize Interface Table Filter Elements.
- */
-function initInterfaceFilter(): void {
-  for (const input of getElements<HTMLInputElement>('input.interface-filter')) {
-    const table = findFirstAdjacent<HTMLTableElement>(input, 'table');
-    const rows = Array.from(
-      table?.querySelectorAll<HTMLTableRowElement>('tbody > tr') ?? [],
-    ).filter(r => r !== null);
-    /**
-     * Filter on-page table by input text.
-     */
-    function handleInput(event: Event): void {
-      const target = event.target as HTMLInputElement;
-      // Create a regex pattern from the input search text to match against.
-      const filter = new RegExp(target.value.toLowerCase().trim());
-
-      // Each row represents an interface and its attributes.
-      for (const row of rows) {
-        // Find the row's checkbox and deselect it, so that it is not accidentally included in form
-        // submissions.
-        const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]');
-        if (checkBox !== null) {
-          checkBox.checked = false;
-        }
-
-        // The data-name attribute's value contains the interface name.
-        const name = row.getAttribute('data-name');
-
-        if (typeof name === 'string') {
-          if (filter.test(name.toLowerCase().trim())) {
-            // If this row matches the search pattern, but is already hidden, unhide it.
-            if (row.classList.contains('d-none')) {
-              row.classList.remove('d-none');
-            }
-          } else {
-            // If this row doesn't match the search pattern, hide it.
-            row.classList.add('d-none');
-          }
-        }
-      }
-    }
-    input.addEventListener('keyup', debounce(handleInput, 300));
-  }
-}
-
-function initTableFilter(): void {
-  for (const input of getElements<HTMLInputElement>('input.object-filter')) {
-    // Find the first adjacent table element.
-    const table = findFirstAdjacent<HTMLTableElement>(input, 'table');
-
-    // Build a valid array of <tr/> elements that are children of the adjacent table.
-    const rows = Array.from(
-      table?.querySelectorAll<HTMLTableRowElement>('tbody > tr') ?? [],
-    ).filter(r => r !== null);
-
-    /**
-     * Filter table rows by matched input text.
-     * @param event
-     */
-    function handleInput(event: Event): void {
-      const target = event.target as HTMLInputElement;
-
-      // Create a regex pattern from the input search text to match against.
-      const filter = new RegExp(target.value.toLowerCase().trim());
-
-      // List of which rows which match the query
-      const matchedRows: Array<HTMLTableRowElement> = [];
-
-      for (const row of rows) {
-        // Find the row's checkbox and deselect it, so that it is not accidentally included in form
-        // submissions.
-        const checkBox = row.querySelector<HTMLInputElement>('input[type="checkbox"][name="pk"]');
-        if (checkBox !== null) {
-          checkBox.checked = false;
-        }
-
-        // Iterate through each row's cell values
-        for (const value of getRowValues(row)) {
-          if (filter.test(value.toLowerCase())) {
-            // If this row matches the search pattern, add it to the list.
-            matchedRows.push(row);
-            break;
-          }
-        }
-      }
-
-      // Iterate the rows again to set visibility.
-      // This results in a single reflow instead of one for each row.
-      for (const row of rows) {
-        if (matchedRows.indexOf(row) >= 0) {
-          row.classList.remove('d-none');
-        } else {
-          row.classList.add('d-none');
-        }
-      }
-    }
-    input.addEventListener('keyup', debounce(handleInput, 300));
-  }
-}
-
 export function initSearch(): void {
-  for (const func of [initSearchBar, initTableFilter, initInterfaceFilter]) {
+  for (const func of [initSearchBar]) {
     func();
   }
 }

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

@@ -737,10 +737,6 @@ nav.breadcrumb-container {
   }
 }
 
-div.paginator > form > div.input-group {
-  width: fit-content;
-}
-
 label.required {
   font-weight: $font-weight-bold;
 
@@ -900,14 +896,6 @@ div.card-overlay {
   }
 }
 
-// Right-align the paginator element.
-.paginator {
-  display: flex;
-  flex-direction: column;
-  align-items: flex-end;
-  padding: $spacer 0;
-}
-
 // Tabbed content
 .nav-tabs {
   .nav-link {

+ 5 - 0
netbox/project-static/yarn.lock

@@ -1688,6 +1688,11 @@ hosted-git-info@^2.1.4, hosted-git-info@^2.8.9:
   resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
   integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==
 
+htmx.org@^1.6.1:
+  version "1.6.1"
+  resolved "https://registry.yarnpkg.com/htmx.org/-/htmx.org-1.6.1.tgz#6f0d59a93fa61cbaa15316c134a2f179045a5778"
+  integrity sha512-i+1k5ee2eFWaZbomjckyrDjUpa3FMDZWufatUSBmmsjXVksn89nsXvr1KLGIdAajiz+ZSL7TE4U/QaZVd2U2sA==
+
 ignore@^4.0.6:
   version "4.0.6"
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"

+ 4 - 9
netbox/templates/dcim/connections_list.html

@@ -8,19 +8,14 @@
 {% block content-wrapper %}
   <div class="tab-content">
 
-    {# Conncetions list #}
+    {# Connections list #}
     <div class="tab-pane show active" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
-      {% include 'inc/table_controls.html' %}
-
+      {% include 'inc/table_controls_htmx.html' %}
       <div class="card">
-        <div class="card-body">
-          <div class="table-responsive">
-            {% render_table table 'inc/table.html' %}
-          </div>
+        <div class="card-body" id="object_list">
+          {% include 'htmx/table.html' %}
         </div>
       </div>
-
-      {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
     </div>
 
     {# Filter form #}

+ 7 - 4
netbox/templates/dcim/device/consoleports.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceConsolePortTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsolePortTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_consoleport %}
@@ -38,6 +42,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

+ 7 - 3
netbox/templates/dcim/device/consoleserverports.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_consoleserverport %}

+ 7 - 3
netbox/templates/dcim/device/devicebays.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_devicebay %}

+ 7 - 3
netbox/templates/dcim/device/frontports.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_frontport %}

+ 15 - 3
netbox/templates/dcim/device/interfaces.html

@@ -9,7 +9,15 @@
     <div class="row mb-3 justify-content-between">
       <div class="col col-12 col-lg-4 my-3 my-lg-0 d-flex noprint table-controls">
         <div class="input-group input-group-sm">
-          <input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
+          <input
+              type="text"
+              name="q"
+              class="form-control"
+              placeholder="Quick search"
+              hx-get="{{ request.full_path }}"
+              hx-target="#object_list"
+              hx-trigger="keyup changed delay:500ms"
+          />
         </div>
       </div>
       <div class="col col-md-3 mb-0 d-flex noprint table-controls">
@@ -34,9 +42,13 @@
         </div>
       </div>
     </div>
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
         {% if perms.dcim.change_interface %}

+ 7 - 3
netbox/templates/dcim/device/inventory.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_inventoryitem %}

+ 7 - 3
netbox/templates/dcim/device/poweroutlets.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_powerport %}

+ 7 - 3
netbox/templates/dcim/device/powerports.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DevicePowerPortTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_powerport %}

+ 7 - 3
netbox/templates/dcim/device/rearports.html

@@ -6,10 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceRearPortTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint bulk-buttons">
         <div class="bulk-button-group">
             {% if perms.dcim.change_rearport %}

+ 7 - 11
netbox/templates/dcim/devicetype/component_templates.html

@@ -7,11 +7,9 @@
     <form method="post">
         {% csrf_token %}
         <div class="card">
-            <h5 class="card-header">
-                {{ title }}
-            </h5>
-            <div class="card-body table-responsive">
-                {% render_table table 'inc/table.html' %}
+            <h5 class="card-header">{{ title }}</h5>
+            <div class="card-body" id="object_list">
+              {% include 'htmx/table.html' %}
             </div>
             <div class="card-footer noprint">
                 {% if table.rows %}
@@ -37,12 +35,10 @@
     </form>
   {% else %}
     <div class="card">
-        <h5 class="card-header">
-            {{ title }}
-        </h5>
-        <div class="card-body table-responsive">
-            {% render_table table 'inc/table.html' %}
-        </div>
+      <h5 class="card-header">{{ title }}</h5>
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
   {% endif %}
 {% endblock content %}

+ 3 - 7
netbox/templates/generic/object_list.html

@@ -87,7 +87,7 @@
       {% endif %}
 
       {# Object table controls #}
-      {% include 'inc/table_controls.html' with table_modal="ObjectTable_config" %}
+      {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
 
       <form method="post" class="form form-horizontal">
         {% csrf_token %}
@@ -95,10 +95,8 @@
 
         {# Object table #}
         <div class="card">
-          <div class="card-body">
-            <div class="table-responsive">
-              {% render_table table 'inc/table.html' %}
-            </div>
+          <div class="card-body" id="object_list">
+            {% include 'htmx/table.html' %}
           </div>
         </div>
 
@@ -125,8 +123,6 @@
 
       </form>
 
-      {# Paginator #}
-      {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
     </div>
 
     {# Filter form #}

+ 5 - 0
netbox/templates/htmx/table.html

@@ -0,0 +1,5 @@
+{# Render an HTMX-enabled table with paginator #}
+{% load render_table from django_tables2 %}
+
+{% render_table table 'inc/table_htmx.html' %}
+{% include 'inc/paginator_htmx.html' with paginator=table.paginator page=table.page %}

+ 40 - 39
netbox/templates/inc/paginator.html

@@ -1,51 +1,52 @@
 {% load helpers %}
 
-<div class="paginator float-end text-end">
+<div class="row">
+  <div class="col col-md-6 mb-0">
+    {# Page number carousel #}
     {% if paginator.num_pages > 1 %}
-    <div class="btn-group btn-group-sm mb-3" role="group" aria-label="Pages">    
-    {% if page.has_previous %}
-        <a href="{% querystring request page=page.previous_page_number %}" class="btn btn-outline-secondary">
+      <div class="btn-group btn-group-sm mb-3" role="group" aria-label="Pages">
+        {% if page.has_previous %}
+          <a href="{% querystring request page=page.previous_page_number %}" class="btn btn-outline-secondary">
             <i class="mdi mdi-chevron-double-left"></i>
-        </a>
-    {% endif %}
-    {% for p in page.smart_pages %}
-        {% if p %}
-        <a href="{% querystring request page=p %}" class="btn btn-outline-secondary{% if page.number == p %} active{% endif %}">
-            {{ p }}
-        </a>
-        {% else %}
-        <button type="button" class="btn btn-outline-secondary" disabled>
-            <span>&hellip;</span>
-        </button>
+          </a>
         {% endif %}
-    {% endfor %}
-    {% if page.has_next %}
-        <a href="{% querystring request page=page.next_page_number %}" class="btn btn-outline-secondary">
+        {% for p in page.smart_pages %}
+          {% if p %}
+            <a href="{% querystring request page=p %}" class="btn btn-outline-secondary{% if page.number == p %} active{% endif %}">
+              {{ p }}
+            </a>
+          {% else %}
+            <button type="button" class="btn btn-outline-secondary" disabled>
+              <span>&hellip;</span>
+            </button>
+          {% endif %}
+        {% endfor %}
+        {% if page.has_next %}
+          <a href="{% querystring request page=page.next_page_number %}" class="btn btn-outline-secondary">
             <i class="mdi mdi-chevron-double-right"></i>
-        </a>
-    {% endif %}
-    </div>
+          </a>
+        {% endif %}
+      </div>
     {% endif %}
-    <form method="get" class="mb-2">
-        {% for k, v_list in request.GET.lists %}
-            {% if k != 'per_page' %}
-                {% for v in v_list %}
-                    <input type="hidden" name="{{ k }}" value="{{ v }}" />
-                {% endfor %}
-            {% endif %}
+  </div>
+  <div class="col col-md-6 mb-0 text-end">
+    {# Per-page count selector #}
+    <div class="dropdown dropup">
+      <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
+        Per Page
+      </button>
+      <ul class="dropdown-menu">
+        {% for n in page.paginator.get_page_lengths %}
+          <li>
+            <a href="{% querystring request per_page=n %}" class="dropdown-item">{{ n }}</a>
+          </li>
         {% endfor %}
-        <div class="input-group input-group-sm">
-            <select name="per_page" class="form-select per-page">
-            {% for n in page.paginator.get_page_lengths %}
-                <option value="{{ n }}"{% if page.paginator.per_page == n %} selected="selected"{% endif %}>{{ n }}</option>
-            {% endfor %}
-            </select>
-            <label class="input-group-text" for="per_page">Per Page</label>
-        </div>
-    </form>
+      </ul>
+    </div>
     {% if page %}
-    <small class="text-end text-muted">
+      <small class="text-end text-muted">
         Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }}
-    </small>
+      </small>
     {% endif %}
+  </div>
 </div>

+ 72 - 0
netbox/templates/inc/paginator_htmx.html

@@ -0,0 +1,72 @@
+{% load helpers %}
+
+<div class="row">
+  <div class="col col-md-6 mb-0">
+    {# Page number carousel #}
+    {% if paginator.num_pages > 1 %}
+      <div class="btn-group btn-group-sm" role="group" aria-label="Pages">
+        {% if page.has_previous %}
+          <a href="#"
+             hx-get="{% querystring request page=page.previous_page_number %}"
+             hx-target="#object_list"
+             hx-push-url="true"
+             class="btn btn-outline-secondary"
+          >
+            <i class="mdi mdi-chevron-double-left"></i>
+          </a>
+        {% endif %}
+        {% for p in page.smart_pages %}
+          {% if p %}
+            <a href="#"
+               hx-get="{% querystring request page=p %}"
+               hx-target="#object_list"
+               hx-push-url="true"
+               class="btn btn-outline-secondary{% if page.number == p %} active{% endif %}"
+            >
+              {{ p }}
+            </a>
+          {% else %}
+            <button type="button" class="btn btn-outline-secondary" disabled>
+              <span>&hellip;</span>
+            </button>
+          {% endif %}
+        {% endfor %}
+        {% if page.has_next %}
+          <a href="#"
+             hx-get="{% querystring request page=page.next_page_number %}"
+             hx-target="#object_list"
+             hx-push-url="true"
+             class="btn btn-outline-secondary"
+          >
+            <i class="mdi mdi-chevron-double-right"></i>
+          </a>
+        {% endif %}
+      </div>
+    {% endif %}
+  </div>
+  <div class="col col-md-6 mb-0 text-end">
+    {# Per-page count selector #}
+    <div class="dropdown dropup">
+      <button class="btn btn-sm btn-outline-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown">
+        Per Page
+      </button>
+      <ul class="dropdown-menu">
+        {% for n in page.paginator.get_page_lengths %}
+          <li>
+            <a href="#"
+               hx-get="{% querystring request per_page=n %}"
+               hx-target="#object_list"
+               hx-push-url="true"
+               class="dropdown-item"
+            >{{ n }}</a>
+          </li>
+        {% endfor %}
+      </ul>
+    </div>
+    {% if page %}
+      <small class="text-end text-muted">
+        Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }}
+      </small>
+    {% endif %}
+  </div>
+</div>

+ 8 - 3
netbox/templates/inc/table_controls.html → netbox/templates/inc/table_controls_htmx.html

@@ -1,11 +1,16 @@
+{% load helpers %}
+
 <div class="row mb-3 justify-content-between">
   <div class="table-controls noprint col col-12 col-md-8 col-lg-4">
     <div class="input-group input-group-sm">
       <input
         type="text"
-        class="form-control object-filter"
-        placeholder="Quick find"
-        title="Find in the results below (regular expressions supported)"
+        name="q"
+        class="form-control"
+        placeholder="Quick search"
+        hx-get="{{ request.full_path }}"
+        hx-target="#object_list"
+        hx-trigger="keyup changed delay:500ms"
       />
     </div>
   </div>

+ 49 - 0
netbox/templates/inc/table_htmx.html

@@ -0,0 +1,49 @@
+{% load django_tables2 %}
+
+<div class="table-responsive">
+  <table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
+    {% if table.show_header %}
+      <thead>
+        <tr>
+          {% for column in table.columns %}
+            {% if column.orderable %}
+              <th {{ column.attrs.th.as_html }}>
+                <a href="#"
+                   hx-get="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}"
+                   hx-target="#object_list"
+                   hx-push-url="true"
+                >{{ column.header }}</a>
+              </th>
+            {% else %}
+              <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
+            {% endif %}
+          {% endfor %}
+        </tr>
+      </thead>
+    {% endif %}
+    <tbody>
+      {% for row in table.page.object_list|default:table.rows %}
+        <tr {{ row.attrs.as_html }}>
+          {% for column, cell in row.items %}
+            <td {{ column.attrs.td.as_html }}>{{ cell }}</td>
+          {% endfor %}
+        </tr>
+      {% empty %}
+        {% if table.empty_text %}
+          <tr>
+            <td colspan="{{ table.columns|length }}" class="text-center text-muted">&mdash; {{ table.empty_text }} &mdash;</td>
+          </tr>
+        {% endif %}
+      {% endfor %}
+    </tbody>
+    {% if table.has_footer %}
+      <tfoot>
+        <tr>
+          {% for column in table.columns %}
+            <td>{{ column.footer }}</td>
+          {% endfor %}
+        </tr>
+      </tfoot>
+    {% endif %}
+  </table>
+</div>

+ 53 - 69
netbox/templates/ipam/aggregate.html

@@ -1,82 +1,66 @@
-{% extends 'generic/object.html' %}
+{% extends 'ipam/aggregate/base.html' %}
 {% load buttons %}
 {% load helpers %}
 {% load plugins %}
 
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
-{% endblock %}
-
-{% block extra_controls %}
-  {% include 'ipam/inc/toggle_available.html' %}
-{% endblock %}
-
 {% block content %}
-<div class="row">
-	<div class="col col-md-6">
-        <div class="card">
-            <h5 class="card-header">
-                Aggregate
-            </h5>
-            <div class="card-body">
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <td>Family</td>
-                        <td>IPv{{ object.family }}</td>
-                    </tr>
-                    <tr>
-                        <td>RIR</td>
-                        <td>
-                            <a href="{% url 'ipam:aggregate_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
-                        </td>
-                    </tr>
-                    <tr>
-                        <td>Utilization</td>
-                        <td>
-                            {% utilization_graph object.get_utilization %}
-                        </td>
-                    </tr>
-                    <tr>
-                        <td>Tenant</td>
-                        <td>
-                            {% if object.tenant %}
-                                {% if prefix.object.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>
-                        <td>Date Added</td>
-                        <td>{{ object.date_added|annotated_date|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <td>Description</td>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Aggregate</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <td>Family</td>
+              <td>IPv{{ object.family }}</td>
+            </tr>
+            <tr>
+              <td>RIR</td>
+              <td>
+                <a href="{% url 'ipam:aggregate_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
+              </td>
+            </tr>
+            <tr>
+              <td>Utilization</td>
+              <td>
+                {% utilization_graph object.get_utilization %}
+              </td>
+            </tr>
+            <tr>
+              <td>Tenant</td>
+              <td>
+                {% if object.tenant %}
+                  {% if prefix.object.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>
+              <td>Date Added</td>
+              <td>{{ object.date_added|annotated_date|placeholder }}</td>
+            </tr>
+            <tr>
+              <td>Description</td>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+          </table>
         </div>
-        {% plugin_left_page object %}
+      </div>
+      {% plugin_left_page object %}
     </div>
     <div class="col col-md-6">
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' %}
-        {% plugin_right_page object %}
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% plugin_right_page object %}
     </div>
-</div>
-<div class="row mb-3">
+  </div>
+  <div class="row mb-3">
     <div class="col col-md-12">
-        {% plugin_full_width_page object %}
+      {% plugin_full_width_page object %}
     </div>
-</div>
-<div class="row mb-3">
-  <div class="col col-md-12">
-    {% include 'utilities/obj_table.html' with table=prefix_table heading='Child Prefixes' bulk_edit_url='ipam:prefix_bulk_edit' bulk_delete_url='ipam:prefix_bulk_delete' %}
   </div>
-</div>
 {% endblock %}

+ 23 - 0
netbox/templates/ipam/aggregate/base.html

@@ -0,0 +1,23 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load helpers %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item"><a href="{% url 'ipam:aggregate_list' %}?rir_id={{ object.rir.pk }}">{{ object.rir }}</a></li>
+{% endblock %}
+
+{% block tab_items %}
+  <li role="presentation" class="nav-item">
+    <a class="nav-link{% if not active_tab %} active{% endif %}" href="{{ object.get_absolute_url }}">
+      Aggregate
+    </a>
+  </li>
+  {% if perms.ipam.view_prefix %}
+    <li role="presentation" class="nav-item">
+      <a class="nav-link{% if active_tab == 'prefixes' %} active{% endif %}" href="{% url 'ipam:aggregate_prefixes' pk=object.pk %}">
+        Prefixes {% badge object.get_child_prefixes.count %}
+      </a>
+    </li>
+  {% endif %}
+{% endblock %}

+ 36 - 0
netbox/templates/ipam/aggregate/prefixes.html

@@ -0,0 +1,36 @@
+{% extends 'ipam/aggregate/base.html' %}
+{% load helpers %}
+
+{% block extra_controls %}
+  {% include 'ipam/inc/toggle_available.html' %}
+  {{ block.super }}
+{% endblock %}
+
+{% block content %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if perms.ipam.change_prefix %}
+          <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
+            <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
+          </button>
+        {% endif %}
+        {% if perms.ipam.delete_prefix %}
+          <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+          </button>
+        {% endif %}
+      </div>
+    </div>
+  </form>
+  {% table_config_form table %}
+{% endblock %}

+ 4 - 1
netbox/templates/ipam/ipaddress_assign.html

@@ -2,6 +2,7 @@
 {% load static %}
 {% load form_helpers %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 
 {% block title %}Assign an IP Address{% endblock title %}
 
@@ -35,7 +36,9 @@
         <div class="row mb-3">
             <div class="col col-md-12">
                 <h3>Search Results</h3>
-                {% include 'utilities/obj_table.html' %}
+                <div class="table-responsive">
+                  {% render_table table 'inc/table.html' %}
+                </div>
             </div>
         </div>
     {% endif %}

+ 26 - 4
netbox/templates/ipam/iprange/ip_addresses.html

@@ -1,4 +1,5 @@
 {% extends 'ipam/iprange/base.html' %}
+{% load helpers %}
 
 {% block extra_controls %}
   {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and object.first_available_ip %}
@@ -9,9 +10,30 @@
 {% endblock %}
 
 {% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      {% include 'utilities/obj_table.html' with heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if perms.ipam.change_ipaddress %}
+          <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
+            <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
+          </button>
+        {% endif %}
+        {% if perms.ipam.delete_ipaddress %}
+          <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:iprange_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+          </button>
+        {% endif %}
+      </div>
     </div>
-  </div>
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 25 - 7
netbox/templates/ipam/prefix/ip_addresses.html

@@ -1,6 +1,5 @@
 {% extends 'ipam/prefix/base.html' %}
 {% load helpers %}
-{% load static %}
 
 {% block extra_controls %}
   {% if perms.ipam.add_ipaddress and first_available_ip %}
@@ -11,11 +10,30 @@
 {% endblock %}
 
 {% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      {% 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' %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="IPAddressTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if perms.ipam.change_ipaddress %}
+          <button type="submit" name="_edit" formaction="{% url 'ipam:ipaddress_bulk_edit' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-warning btn-sm">
+            <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
+          </button>
+        {% endif %}
+        {% if perms.ipam.delete_ipaddress %}
+          <button type="submit" name="_delete" formaction="{% url 'ipam:ipaddress_bulk_delete' %}?return_url={% url 'ipam:prefix_ipaddresses' pk=object.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+          </button>
+        {% endif %}
+      </div>
     </div>
-  </div>
-  {% table_config_form table table_name="IPAddressTable" %}
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 25 - 7
netbox/templates/ipam/prefix/ip_ranges.html

@@ -1,13 +1,31 @@
 {% extends 'ipam/prefix/base.html' %}
 {% load helpers %}
-{% load static %}
 
 {% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      {% 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 %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="IPRangeTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if perms.ipam.change_iprange %}
+          <button type="submit" name="_edit" formaction="{% url 'ipam:iprange_bulk_edit' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-warning btn-sm">
+            <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
+          </button>
+        {% endif %}
+        {% if perms.ipam.delete_iprange %}
+          <button type="submit" name="_delete" formaction="{% url 'ipam:iprange_bulk_delete' %}?return_url={% url 'ipam:prefix_ipranges' pk=object.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+          </button>
+        {% endif %}
+      </div>
     </div>
-  </div>
-  {% table_config_form table table_name="IPRangeTable" %}
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 25 - 7
netbox/templates/ipam/prefix/prefixes.html

@@ -1,6 +1,5 @@
 {% extends 'ipam/prefix/base.html' %}
 {% load helpers %}
-{% load static %}
 
 {% block extra_controls %}
   {% include 'ipam/inc/toggle_available.html' %}
@@ -13,11 +12,30 @@
 {% endblock %}
 
 {% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      {% include 'inc/table_controls.html' with table_modal="PrefixTable_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 %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="PrefixTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if perms.ipam.change_prefix %}
+          <button type="submit" name="_edit" formaction="{% url 'ipam:prefix_bulk_edit' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-warning btn-sm">
+            <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
+          </button>
+        {% endif %}
+        {% if perms.ipam.delete_prefix %}
+          <button type="submit" name="_delete" formaction="{% url 'ipam:prefix_bulk_delete' %}?return_url={% url 'ipam:prefix_prefixes' pk=object.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+          </button>
+        {% endif %}
+      </div>
     </div>
-  </div>
-  {% table_config_form table table_name="PrefixTable" %}
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 12 - 4
netbox/templates/ipam/vlan/interfaces.html

@@ -1,9 +1,17 @@
 {% extends 'ipam/vlan/base.html' %}
+{% load helpers %}
 
 {% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      {% include 'utilities/obj_table.html' with heading='Device Interfaces' parent=vlan %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="VLANDevicesTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
-  </div>
+
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 12 - 4
netbox/templates/ipam/vlan/vminterfaces.html

@@ -1,9 +1,17 @@
 {% extends 'ipam/vlan/base.html' %}
+{% load helpers %}
 
 {% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      {% include 'utilities/obj_table.html' with heading='Virtual Machine Interfaces' parent=vlan %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="VLANVirtualMachinesTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
-  </div>
+
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 0 - 62
netbox/templates/utilities/obj_table.html

@@ -1,62 +0,0 @@
-{% load helpers %}
-{% load render_table from django_tables2 %}
-
-{% if permissions.change or permissions.delete %}
-    <form method="post" class="form form-horizontal">
-        {% csrf_token %}
-        <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
-
-        {% if table.paginator.num_pages > 1 %}
-            <div id="select-all-box" class="d-none card noprint">
-              <div class="card-body">
-                <div class="float-end">
-                    {% if bulk_edit_url and permissions.change %}
-                        <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm" disabled="disabled">
-                            <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit All
-                        </button>
-                    {% endif %}
-                    {% if bulk_delete_url and permissions.delete %}
-                        <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if bulk_querystring %}?{{ bulk_querystring }}{% elif request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm" disabled="disabled">
-                            <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Delete All
-                        </button>
-                    {% endif %}
-                </div>
-                <div class="form-check">
-                    <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
-                    <label for="select-all" class="form-check-label">
-                    Select <strong>all {{ table.objects_count }} {{ table.data.verbose_name_plural }}</strong> matching query
-                    </label>
-                </div>
-              </div>
-            </div>
-        {% endif %}
-
-        <div class="table-responsive">
-          {% render_table table 'inc/table.html' %}
-        </div>
-
-        <div class="float-start noprint">
-            {% block extra_actions %}{% endblock %}
-
-            {% if bulk_edit_url and permissions.change %}
-                <button type="submit" name="_edit" formaction="{% url bulk_edit_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-warning btn-sm">
-                    <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit Selected
-                </button>
-            {% endif %}
-
-            {% if bulk_delete_url and permissions.delete %}
-                <button type="submit" name="_delete" formaction="{% url bulk_delete_url %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="btn btn-danger btn-sm">
-                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete Selected
-                </button>
-            {% endif %}
-        </div>
-    </form>
-{% else %}
-
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
-    </div>
-
-{% endif %}
-
-{% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}

+ 15 - 16
netbox/templates/virtualization/cluster/devices.html

@@ -3,26 +3,25 @@
 {% load render_table from django_tables2 %}
 
 {% block content %}
-<div class="row">
-  <div class="col col-md-12">
+  <form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceTable_config" %}
+
     <div class="card">
-      <h5 class="card-header">
-        Host Devices
-      </h5>
-      <form action="{% url 'virtualization:cluster_remove_devices' pk=object.pk %}" method="post">
-      {% csrf_token %}
-      <div class="card-body table-responsive">
-        {% render_table table 'inc/table.html' %}
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
       </div>
-      {% if perms.virtualization.change_cluster %}
-        <div class="card-footer noprint">
+    </div>
+
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if perms.virtualization.change_cluster %}
           <button type="submit" name="_remove" class="btn btn-danger btn-sm">
             <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> Remove Devices
           </button>
-        </div>
-      {% endif %}
-      </form>
+        {% endif %}
+      </div>
     </div>
-  </div>
-</div>
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 23 - 9
netbox/templates/virtualization/cluster/virtual_machines.html

@@ -3,16 +3,30 @@
 {% load render_table from django_tables2 %}
 
 {% block content %}
-<div class="row">
-  <div class="col col-md-12">
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
+
     <div class="card">
-      <h5 class="card-header">
-        Virtual Machines
-      </h5>
-      <div class="card-body table-responsive">
-        {% render_table table 'inc/table.html' %}
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+
+    <div class="noprint bulk-buttons">
+      <div class="bulk-button-group">
+        {% if perms.virtualization.change_virtualmachine %}
+          <button type="submit" name="_edit" formaction="{% url 'virtualization:virtualmachine_bulk_edit' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-warning btn-sm">
+            <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
+          </button>
+        {% endif %}
+        {% if perms.virtualization.delete_virtualmachine %}
+          <button type="submit" name="_delete" formaction="{% url 'virtualization:virtualmachine_bulk_delete' %}?return_url={% url 'virtualization:cluster_virtualmachines' pk=object.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+          </button>
+        {% endif %}
       </div>
     </div>
-  </div>
-</div>
+  </form>
+  {% table_config_form table %}
 {% endblock %}

+ 7 - 4
netbox/templates/virtualization/virtualmachine/interfaces.html

@@ -1,15 +1,18 @@
 {% extends 'virtualization/virtualmachine/base.html' %}
 {% load render_table from django_tables2 %}
 {% load helpers %}
-{% load static %}
 
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="VirtualMachineVMInterfaceTable_config" %}
-    <div class="table-responsive">
-      {% render_table table 'inc/table.html' %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="VMInterfaceTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
     </div>
+
     <div class="noprint">
         {% if perms.virtualization.change_vminterface %}
             <button type="submit" name="_rename" formaction="{% url 'virtualization:vminterface_bulk_rename' %}?return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}" class="btn btn-warning btn-sm">

+ 5 - 0
netbox/utilities/htmx.py

@@ -0,0 +1,5 @@
+def is_htmx(request):
+    """
+    Returns True if the request was made by HTMX; False otherwise.
+    """
+    return 'Hx-Request' in request.headers

+ 4 - 0
netbox/virtualization/views.py

@@ -4,6 +4,7 @@ from django.db.models import Prefetch
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 
+from dcim.filtersets import DeviceFilterSet
 from dcim.models import Device
 from dcim.tables import DeviceTable
 from extras.views import ObjectConfigContextView
@@ -165,6 +166,7 @@ class ClusterVirtualMachinesView(generic.ObjectChildrenView):
     queryset = Cluster.objects.all()
     child_model = VirtualMachine
     table = tables.VirtualMachineTable
+    filterset = filtersets.VirtualMachineFilterSet
     template_name = 'virtualization/cluster/virtual_machines.html'
 
     def get_children(self, request, parent):
@@ -180,6 +182,7 @@ class ClusterDevicesView(generic.ObjectChildrenView):
     queryset = Cluster.objects.all()
     child_model = Device
     table = DeviceTable
+    filterset = DeviceFilterSet
     template_name = 'virtualization/cluster/devices.html'
 
     def get_children(self, request, parent):
@@ -345,6 +348,7 @@ class VirtualMachineInterfacesView(generic.ObjectChildrenView):
     queryset = VirtualMachine.objects.all()
     child_model = VMInterface
     table = tables.VMInterfaceTable
+    filterset = filtersets.VMInterfaceFilterSet
     template_name = 'virtualization/virtualmachine/interfaces.html'
 
     def get_children(self, request, parent):

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