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):
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'

View File

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

View File

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

View File

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

View File

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

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",
"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",

View File

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

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 'bootstrap';
import 'htmx.org';
import 'simplebar';
import './netbox';

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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 %}
<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">
<i class="mdi mdi-chevron-double-right"></i>
</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 %}
{% 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 %}
<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>
{% if page %}
<small class="text-end text-muted">
Showing {{ page.start_index }}-{{ page.end_index }} of {{ page.paginator.count }}
</small>
{% 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>
{% 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>

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="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>

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 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 class="col col-md-12">
{% plugin_full_width_page object %}
</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>
{% 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 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 %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

@ -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
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.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):