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

Closes #8057: Dynamic object tables using HTMX
This commit is contained in:
Jeremy Stretch 2021-12-15 09:16:41 -05:00 committed by GitHub
commit 57d3bfcfc9
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
50 changed files with 655 additions and 463 deletions

View File

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

View File

@ -195,6 +195,12 @@ class Aggregate(PrimaryModel):
return self.prefix.version return self.prefix.version
return None 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): def get_utilization(self):
""" """
Determine the prefix utilization of the aggregate and return it as a percentage. Determine the prefix utilization of the aggregate and return it as a percentage.

View File

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

View File

@ -1,21 +1,22 @@
from django.contrib.contenttypes.models import ContentType from django.contrib.contenttypes.models import ContentType
from django.db.models import Prefetch from django.db.models import Prefetch
from django.db.models.expressions import RawSQL from django.db.models.expressions import RawSQL
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from dcim.filtersets import InterfaceFilterSet
from dcim.models import Device, Interface, Site from dcim.models import Device, Interface, Site
from dcim.tables import SiteTable from dcim.tables import SiteTable
from netbox.views import generic from netbox.views import generic
from utilities.tables import paginate_table from utilities.tables import paginate_table
from utilities.utils import count_related from utilities.utils import count_related
from virtualization.filtersets import VMInterfaceFilterSet
from virtualization.models import VirtualMachine, VMInterface from virtualization.models import VirtualMachine, VMInterface
from . import filtersets, forms, tables from . import filtersets, forms, tables
from .constants import * from .constants import *
from .models import * from .models import *
from .models import ASN 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): class AggregateView(generic.ObjectView):
queryset = Aggregate.objects.all() 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_available = bool(request.GET.get('show_available', 'true') == 'true')
show_assigned = bool(request.GET.get('show_assigned', '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',)) return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
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'),
}
def get_extra_context(self, request, instance):
return { return {
'prefix_table': prefix_table,
'permissions': permissions,
'bulk_querystring': f'within={instance.prefix}', 'bulk_querystring': f'within={instance.prefix}',
'show_available': show_available, 'active_tab': 'prefixes',
'show_assigned': show_assigned, '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() queryset = Prefix.objects.all()
child_model = Prefix child_model = Prefix
table = tables.PrefixTable table = tables.PrefixTable
filterset = filtersets.PrefixFilterSet
template_name = 'ipam/prefix/prefixes.html' template_name = 'ipam/prefix/prefixes.html'
def get_children(self, request, parent): 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_available = bool(request.GET.get('show_available', 'true') == 'true')
show_assigned = bool(request.GET.get('show_assigned', '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): def get_extra_context(self, request, instance):
return { return {
@ -483,6 +478,7 @@ class PrefixIPRangesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
child_model = IPRange child_model = IPRange
table = tables.IPRangeTable table = tables.IPRangeTable
filterset = filtersets.IPRangeFilterSet
template_name = 'ipam/prefix/ip_ranges.html' template_name = 'ipam/prefix/ip_ranges.html'
def get_children(self, request, parent): def get_children(self, request, parent):
@ -499,6 +495,7 @@ class PrefixIPAddressesView(generic.ObjectChildrenView):
queryset = Prefix.objects.all() queryset = Prefix.objects.all()
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/prefix/ip_addresses.html' template_name = 'ipam/prefix/ip_addresses.html'
def get_children(self, request, parent): def get_children(self, request, parent):
@ -560,6 +557,7 @@ class IPRangeIPAddressesView(generic.ObjectChildrenView):
queryset = IPRange.objects.all() queryset = IPRange.objects.all()
child_model = IPAddress child_model = IPAddress
table = tables.IPAddressTable table = tables.IPAddressTable
filterset = filtersets.IPAddressFilterSet
template_name = 'ipam/iprange/ip_addresses.html' template_name = 'ipam/iprange/ip_addresses.html'
def get_children(self, request, parent): def get_children(self, request, parent):
@ -959,6 +957,7 @@ class VLANInterfacesView(generic.ObjectChildrenView):
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
child_model = Interface child_model = Interface
table = tables.VLANDevicesTable table = tables.VLANDevicesTable
filterset = InterfaceFilterSet
template_name = 'ipam/vlan/interfaces.html' template_name = 'ipam/vlan/interfaces.html'
def get_children(self, request, parent): def get_children(self, request, parent):
@ -974,6 +973,7 @@ class VLANVMInterfacesView(generic.ObjectChildrenView):
queryset = VLAN.objects.all() queryset = VLAN.objects.all()
child_model = VMInterface child_model = VMInterface
table = tables.VLANVirtualMachinesTable table = tables.VLANVirtualMachinesTable
filterset = VMInterfaceFilterSet
template_name = 'ipam/vlan/vminterfaces.html' template_name = 'ipam/vlan/vminterfaces.html'
def get_children(self, request, parent): def get_children(self, request, parent):

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@ -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);
}
}

View File

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

View File

@ -1,5 +1,4 @@
import debounce from 'just-debounce-it'; import { getElements, findFirstAdjacent, isTruthy } from './util';
import { getElements, getRowValues, findFirstAdjacent, isTruthy } from './util';
/** /**
* Change the display value and hidden input values of the search filter based on dropdown * 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 { export function initSearch(): void {
for (const func of [initSearchBar, initTableFilter, initInterfaceFilter]) { for (const func of [initSearchBar]) {
func(); func();
} }
} }

View File

@ -737,10 +737,6 @@ nav.breadcrumb-container {
} }
} }
div.paginator > form > div.input-group {
width: fit-content;
}
label.required { label.required {
font-weight: $font-weight-bold; 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 // Tabbed content
.nav-tabs { .nav-tabs {
.nav-link { .nav-link {

View File

@ -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" resolved "https://registry.yarnpkg.com/hosted-git-info/-/hosted-git-info-2.8.9.tgz#dffc0bf9a21c02209090f2aa69429e1414daf3f9"
integrity sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw== 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: ignore@^4.0.6:
version "4.0.6" version "4.0.6"
resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc" resolved "https://registry.yarnpkg.com/ignore/-/ignore-4.0.6.tgz#750e3db5862087b4737ebac8207ffd1ef27b25fc"

View File

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

View File

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

View File

@ -6,10 +6,14 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="DeviceConsoleServerPortTable_config" %}
<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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_consoleserverport %} {% if perms.dcim.change_consoleserverport %}

View File

@ -6,10 +6,14 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="DeviceDeviceBayTable_config" %}
<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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_devicebay %} {% if perms.dcim.change_devicebay %}

View File

@ -6,10 +6,14 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="DeviceFrontPortTable_config" %}
<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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_frontport %} {% if perms.dcim.change_frontport %}

View File

@ -9,7 +9,15 @@
<div class="row mb-3 justify-content-between"> <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="col col-12 col-lg-4 my-3 my-lg-0 d-flex noprint table-controls">
<div class="input-group input-group-sm"> <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> </div>
<div class="col col-md-3 mb-0 d-flex noprint table-controls"> <div class="col col-md-3 mb-0 d-flex noprint table-controls">
@ -34,9 +42,13 @@
</div> </div>
</div> </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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_interface %} {% if perms.dcim.change_interface %}

View File

@ -6,10 +6,14 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="DeviceInventoryItemTable_config" %}
<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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_inventoryitem %} {% if perms.dcim.change_inventoryitem %}

View File

@ -6,10 +6,14 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerOutletTable_config" %}
<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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_powerport %} {% if perms.dcim.change_powerport %}

View File

@ -6,10 +6,14 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DevicePowerPortTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="DevicePowerPortTable_config" %}
<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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_powerport %} {% if perms.dcim.change_powerport %}

View File

@ -6,10 +6,14 @@
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="DeviceRearPortTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="DeviceRearPortTable_config" %}
<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>
<div class="noprint bulk-buttons"> <div class="noprint bulk-buttons">
<div class="bulk-button-group"> <div class="bulk-button-group">
{% if perms.dcim.change_rearport %} {% if perms.dcim.change_rearport %}

View File

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

View File

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

View File

@ -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 %}

View File

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

View File

@ -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>

View File

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

View File

@ -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>

View File

@ -1,82 +1,66 @@
{% extends 'generic/object.html' %} {% extends 'ipam/aggregate/base.html' %}
{% load buttons %} {% load buttons %}
{% load helpers %} {% load helpers %}
{% load plugins %} {% 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 %} {% block content %}
<div class="row"> <div class="row">
<div class="col col-md-6"> <div class="col col-md-6">
<div class="card"> <div class="card">
<h5 class="card-header"> <h5 class="card-header">Aggregate</h5>
Aggregate <div class="card-body">
</h5> <table class="table table-hover attr-table">
<div class="card-body"> <tr>
<table class="table table-hover attr-table"> <td>Family</td>
<tr> <td>IPv{{ object.family }}</td>
<td>Family</td> </tr>
<td>IPv{{ object.family }}</td> <tr>
</tr> <td>RIR</td>
<tr> <td>
<td>RIR</td> <a href="{% url 'ipam:aggregate_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a>
<td> </td>
<a href="{% url 'ipam:aggregate_list' %}?rir={{ object.rir.slug }}">{{ object.rir }}</a> </tr>
</td> <tr>
</tr> <td>Utilization</td>
<tr> <td>
<td>Utilization</td> {% utilization_graph object.get_utilization %}
<td> </td>
{% utilization_graph object.get_utilization %} </tr>
</td> <tr>
</tr> <td>Tenant</td>
<tr> <td>
<td>Tenant</td> {% if object.tenant %}
<td> {% if prefix.object.group %}
{% if object.tenant %} <a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> /
{% if prefix.object.group %} {% endif %}
<a href="{{ object.tenant.group.get_absolute_url }}">{{ object.tenant.group }}</a> / <a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a>
{% endif %} {% else %}
<a href="{{ object.tenant.get_absolute_url }}">{{ object.tenant }}</a> <span class="text-muted">None</span>
{% else %} {% endif %}
<span class="text-muted">None</span> </td>
{% endif %} </tr>
</td> <tr>
</tr> <td>Date Added</td>
<tr> <td>{{ object.date_added|annotated_date|placeholder }}</td>
<td>Date Added</td> </tr>
<td>{{ object.date_added|annotated_date|placeholder }}</td> <tr>
</tr> <td>Description</td>
<tr> <td>{{ object.description|placeholder }}</td>
<td>Description</td> </tr>
<td>{{ object.description|placeholder }}</td> </table>
</tr>
</table>
</div>
</div> </div>
{% plugin_left_page object %} </div>
{% plugin_left_page object %}
</div> </div>
<div class="col col-md-6"> <div class="col col-md-6">
{% include 'inc/panels/custom_fields.html' %} {% include 'inc/panels/custom_fields.html' %}
{% include 'inc/panels/tags.html' %} {% include 'inc/panels/tags.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% plugin_full_width_page object %}
</div> </div>
</div>
<div class="row mb-3">
<div class="col col-md-12">
{% 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>
</div>
{% endblock %} {% endblock %}

View File

@ -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 %}

View File

@ -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 %}

View File

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

View File

@ -1,4 +1,5 @@
{% extends 'ipam/iprange/base.html' %} {% extends 'ipam/iprange/base.html' %}
{% load helpers %}
{% block extra_controls %} {% block extra_controls %}
{% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and object.first_available_ip %} {% if perms.ipam.add_ipaddress and active_tab == 'ip-addresses' and object.first_available_ip %}
@ -9,9 +10,30 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="row"> <form method="post">
<div class="col col-md-12"> {% csrf_token %}
{% include 'utilities/obj_table.html' with heading='IP Addresses' bulk_edit_url='ipam:ipaddress_bulk_edit' bulk_delete_url='ipam:ipaddress_bulk_delete' %} {% 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>
</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>
</form>
{% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,5 @@
{% extends 'ipam/prefix/base.html' %} {% extends 'ipam/prefix/base.html' %}
{% load helpers %} {% load helpers %}
{% load static %}
{% block extra_controls %} {% block extra_controls %}
{% if perms.ipam.add_ipaddress and first_available_ip %} {% if perms.ipam.add_ipaddress and first_available_ip %}
@ -11,11 +10,30 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="row"> <form method="post">
<div class="col col-md-12"> {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="IPAddressTable_config" %} {% include 'inc/table_controls_htmx.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' %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div> </div>
</div>
{% table_config_form table table_name="IPAddressTable" %} <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>
</form>
{% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -1,13 +1,31 @@
{% extends 'ipam/prefix/base.html' %} {% extends 'ipam/prefix/base.html' %}
{% load helpers %} {% load helpers %}
{% load static %}
{% block content %} {% block content %}
<div class="row"> <form method="post">
<div class="col col-md-12"> {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="IPRangeTable_config" %} {% include 'inc/table_controls_htmx.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 %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div> </div>
</div>
{% table_config_form table table_name="IPRangeTable" %} <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>
</form>
{% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -1,6 +1,5 @@
{% extends 'ipam/prefix/base.html' %} {% extends 'ipam/prefix/base.html' %}
{% load helpers %} {% load helpers %}
{% load static %}
{% block extra_controls %} {% block extra_controls %}
{% include 'ipam/inc/toggle_available.html' %} {% include 'ipam/inc/toggle_available.html' %}
@ -13,11 +12,30 @@
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="row"> <form method="post">
<div class="col col-md-12"> {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="PrefixTable_config" %} {% include 'inc/table_controls_htmx.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 %}
<div class="card">
<div class="card-body" id="object_list">
{% include 'htmx/table.html' %}
</div>
</div> </div>
</div>
{% table_config_form table table_name="PrefixTable" %} <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 %} {% endblock %}

View File

@ -1,9 +1,17 @@
{% extends 'ipam/vlan/base.html' %} {% extends 'ipam/vlan/base.html' %}
{% load helpers %}
{% block content %} {% block content %}
<div class="row"> <form method="post">
<div class="col col-md-12"> {% csrf_token %}
{% include 'utilities/obj_table.html' with heading='Device Interfaces' parent=vlan %} {% 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>
</div>
</form>
{% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -1,9 +1,17 @@
{% extends 'ipam/vlan/base.html' %} {% extends 'ipam/vlan/base.html' %}
{% load helpers %}
{% block content %} {% block content %}
<div class="row"> <form method="post">
<div class="col col-md-12"> {% csrf_token %}
{% include 'utilities/obj_table.html' with heading='Virtual Machine Interfaces' parent=vlan %} {% 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>
</div>
</form>
{% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -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 %}

View File

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

View File

@ -3,16 +3,30 @@
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% block content %} {% block content %}
<div class="row"> <form method="post">
<div class="col col-md-12"> {% csrf_token %}
{% include 'inc/table_controls_htmx.html' with table_modal="VirtualMachineTable_config" %}
<div class="card"> <div class="card">
<h5 class="card-header"> <div class="card-body" id="object_list">
Virtual Machines {% include 'htmx/table.html' %}
</h5>
<div class="card-body table-responsive">
{% render_table table 'inc/table.html' %}
</div> </div>
</div> </div>
</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>
</form>
{% table_config_form table %}
{% endblock %} {% endblock %}

View File

@ -1,15 +1,18 @@
{% extends 'virtualization/virtualmachine/base.html' %} {% extends 'virtualization/virtualmachine/base.html' %}
{% load render_table from django_tables2 %} {% load render_table from django_tables2 %}
{% load helpers %} {% load helpers %}
{% load static %}
{% block content %} {% block content %}
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
{% include 'inc/table_controls.html' with table_modal="VirtualMachineVMInterfaceTable_config" %} {% include 'inc/table_controls_htmx.html' with table_modal="VMInterfaceTable_config" %}
<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>
<div class="noprint"> <div class="noprint">
{% if perms.virtualization.change_vminterface %} {% 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"> <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
netbox/utilities/htmx.py Normal file
View File

@ -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

View File

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