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

Merge pull request #8123 from netbox-community/develop

Release v3.1.2
Jeremy Stretch 4 лет назад
Родитель
Сommit
b15ecf7649
100 измененных файлов с 1180 добавлено и 952 удалено
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 6 8
      docs/development/adding-models.md
  4. 7 8
      docs/development/extending-models.md
  5. 19 8
      docs/development/getting-started.md
  6. 8 5
      docs/development/index.md
  7. 3 2
      docs/development/models.md
  8. 3 3
      docs/development/style-guide.md
  9. 28 0
      docs/release-notes/version-3.1.md
  10. 8 4
      netbox/dcim/svg.py
  11. 0 2
      netbox/dcim/urls.py
  12. 42 46
      netbox/dcim/views.py
  13. 1 11
      netbox/extras/api/serializers.py
  14. 1 1
      netbox/extras/management/commands/nbshell.py
  15. 3 5
      netbox/extras/querysets.py
  16. 27 22
      netbox/ipam/forms/models.py
  17. 4 6
      netbox/ipam/models/fhrp.py
  18. 6 0
      netbox/ipam/models/ip.py
  19. 1 12
      netbox/ipam/tests/test_views.py
  20. 2 0
      netbox/ipam/urls.py
  21. 23 9
      netbox/ipam/utils.py
  22. 76 145
      netbox/ipam/views.py
  23. 1 1
      netbox/netbox/models.py
  24. 1 1
      netbox/netbox/navigation_menu.py
  25. 1 1
      netbox/netbox/settings.py
  26. 82 3
      netbox/netbox/views/generic.py
  27. 0 0
      netbox/project-static/dist/netbox-dark.css
  28. 0 0
      netbox/project-static/dist/netbox-light.css
  29. 0 0
      netbox/project-static/dist/netbox-print.css
  30. 0 0
      netbox/project-static/dist/netbox.js
  31. 0 0
      netbox/project-static/dist/netbox.js.map
  32. BIN
      netbox/project-static/img/netbox_touch-icon-180.png
  33. 1 0
      netbox/project-static/package.json
  34. 0 2
      netbox/project-static/src/buttons/index.ts
  35. 0 14
      netbox/project-static/src/buttons/pagination.ts
  36. 1 0
      netbox/project-static/src/index.ts
  37. 2 104
      netbox/project-static/src/search.ts
  38. 0 12
      netbox/project-static/styles/netbox.scss
  39. 5 0
      netbox/project-static/yarn.lock
  40. 1 0
      netbox/templates/base/base.html
  41. 2 2
      netbox/templates/base/layout.html
  42. 15 15
      netbox/templates/circuits/circuittype.html
  43. 20 22
      netbox/templates/circuits/provider.html
  44. 11 16
      netbox/templates/circuits/providernetwork.html
  45. 4 9
      netbox/templates/dcim/connections_list.html
  46. 8 1
      netbox/templates/dcim/consoleport.html
  47. 8 1
      netbox/templates/dcim/consoleserverport.html
  48. 25 58
      netbox/templates/dcim/device.html
  49. 8 3
      netbox/templates/dcim/device/consoleports.html
  50. 8 3
      netbox/templates/dcim/device/consoleserverports.html
  51. 8 3
      netbox/templates/dcim/device/devicebays.html
  52. 8 3
      netbox/templates/dcim/device/frontports.html
  53. 16 3
      netbox/templates/dcim/device/interfaces.html
  54. 8 3
      netbox/templates/dcim/device/inventory.html
  55. 3 3
      netbox/templates/dcim/device/lldp_neighbors.html
  56. 8 3
      netbox/templates/dcim/device/poweroutlets.html
  57. 8 3
      netbox/templates/dcim/device/powerports.html
  58. 8 3
      netbox/templates/dcim/device/rearports.html
  59. 0 9
      netbox/templates/dcim/device_component.html
  60. 8 1
      netbox/templates/dcim/devicebay.html
  61. 13 13
      netbox/templates/dcim/devicerole.html
  62. 7 11
      netbox/templates/dcim/devicetype/component_templates.html
  63. 8 1
      netbox/templates/dcim/frontport.html
  64. 9 2
      netbox/templates/dcim/interface.html
  65. 8 1
      netbox/templates/dcim/inventoryitem.html
  66. 15 15
      netbox/templates/dcim/location.html
  67. 13 13
      netbox/templates/dcim/manufacturer.html
  68. 15 15
      netbox/templates/dcim/platform.html
  69. 8 1
      netbox/templates/dcim/poweroutlet.html
  70. 1 1
      netbox/templates/dcim/powerpanel.html
  71. 8 1
      netbox/templates/dcim/powerport.html
  72. 13 13
      netbox/templates/dcim/rackrole.html
  73. 8 1
      netbox/templates/dcim/rearport.html
  74. 18 18
      netbox/templates/dcim/region.html
  75. 15 15
      netbox/templates/dcim/sitegroup.html
  76. 11 3
      netbox/templates/extras/object_changelog.html
  77. 6 2
      netbox/templates/extras/object_journal.html
  78. 11 4
      netbox/templates/extras/tag.html
  79. 66 33
      netbox/templates/generic/object_bulk_add_component.html
  80. 4 1
      netbox/templates/generic/object_bulk_delete.html
  81. 15 7
      netbox/templates/generic/object_bulk_edit.html
  82. 4 1
      netbox/templates/generic/object_bulk_remove.html
  83. 3 7
      netbox/templates/generic/object_list.html
  84. 5 0
      netbox/templates/htmx/table.html
  85. 41 40
      netbox/templates/inc/paginator.html
  86. 72 0
      netbox/templates/inc/paginator_htmx.html
  87. 4 4
      netbox/templates/inc/panel_table.html
  88. 37 39
      netbox/templates/inc/table.html
  89. 8 3
      netbox/templates/inc/table_controls_htmx.html
  90. 49 0
      netbox/templates/inc/table_htmx.html
  91. 53 69
      netbox/templates/ipam/aggregate.html
  92. 23 0
      netbox/templates/ipam/aggregate/base.html
  93. 36 0
      netbox/templates/ipam/aggregate/prefixes.html
  94. 4 3
      netbox/templates/ipam/asn.html
  95. 2 2
      netbox/templates/ipam/fhrpgroup.html
  96. 0 0
      netbox/templates/ipam/inc/ipaddress_edit_header.html
  97. 8 5
      netbox/templates/ipam/inc/toggle_available.html
  98. 4 4
      netbox/templates/ipam/ipaddress.html
  99. 5 2
      netbox/templates/ipam/ipaddress_assign.html
  100. 1 1
      netbox/templates/ipam/ipaddress_bulk_add.html

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

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

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

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

+ 6 - 8
docs/development/adding-models.md

@@ -6,9 +6,9 @@ Models within each app are stored in either `models.py` or within a submodule un
 
 Each model should define, at a minimum:
 
+* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
 * A `__str__()` method returning a user-friendly string representation of the instance
 * A `get_absolute_url()` method returning an instance's direct URL (using `reverse()`)
-* A `Meta` class specifying a deterministic ordering (if ordered by fields other than the primary ID)
 
 ## 2. Define field choices
 
@@ -16,9 +16,9 @@ If the model has one or more fields with static choices, define those choices in
 
 ## 3. Generate database migrations
 
-Once your model definition is complete, generate database migrations by running `manage.py -n $NAME --no-header`. Always specify a short unique name when generating migrations.
+Once your model definition is complete, generate database migrations by running `manage.py makemigrations -n $NAME --no-header`. Always specify a short unique name when generating migrations.
 
-!!! info
+!!! info "Configuration Required"
     Set `DEVELOPER = True` in your NetBox configuration to enable the creation of new migrations.
 
 ## 4. Add all standard views
@@ -41,9 +41,7 @@ Add the relevant URL path for each view created in the previous step to `urls.py
 
 Each model should have a corresponding FilterSet class defined. This is used to filter UI and API queries. Subclass the appropriate class from `netbox.filtersets` that matches the model's parent class.
 
-Every model FilterSet should define a `q` filter to support general search queries.
-
-## 7. Create the table
+## 7. Create the table class
 
 Create a table class for the model in `tables.py` by subclassing `utilities.tables.BaseTable`. Under the table's `Meta` class, be sure to list both the fields and default columns.
 
@@ -53,7 +51,7 @@ Create the HTML template for the object view. (The other views each typically em
 
 ## 9. Add the model to the navigation menu
 
-For NetBox releases prior to v3.0, add the relevant link(s) to the navigation menu template. For later releases, add the relevant items in `netbox/netbox/navigation_menu.py`.
+Add the relevant navigation menu items in `netbox/netbox/navigation_menu.py`.
 
 ## 10. REST API components
 
@@ -64,7 +62,7 @@ Create the following for each model:
 * API view in `api/views.py`
 * Endpoint route in `api/urls.py`
 
-## 11. GraphQL API components (v3.0+)
+## 11. GraphQL API components
 
 Create a Graphene object type for the model in `graphql/types.py` by subclassing the appropriate class from `netbox.graphql.types`.
 

+ 7 - 8
docs/development/extending-models.md

@@ -4,16 +4,16 @@ Below is a list of tasks to consider when adding a new field to a core model.
 
 ## 1. Generate and run database migrations
 
-Django migrations are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
+[Django migrations](https://docs.djangoproject.com/en/stable/topics/migrations/) are used to express changes to the database schema. In most cases, Django can generate these automatically, however very complex changes may require manual intervention. Always remember to specify a short but descriptive name when generating a new migration.
 
 ```
 ./manage.py makemigrations <app> -n <name>
 ./manage.py migrate
 ```
 
-Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in the same migration. You can merge a new migration with an existing one by combining their `operations` lists.
+Where possible, try to merge related changes into a single migration. For example, if three new fields are being added to different models within an app, these can be expressed in a single migration. You can merge a newly generated migration with an existing one by combining their `operations` lists.
 
-!!! note
+!!! warning "Do not alter existing migrations"
     Migrations can only be merged within a release. Once a new release has been published, its migrations cannot be altered (other than for the purpose of correcting a bug).
 
 ## 2. Add validation logic to `clean()`
@@ -24,7 +24,6 @@ If the new field introduces additional validation requirements (beyond what's in
 class Foo(models.Model):
 
     def clean(self):
-
         super().clean()
 
         # Custom validation goes here
@@ -40,9 +39,9 @@ If you're adding a relational field (e.g. `ForeignKey`) and intend to include th
 
 Extend the model's API serializer in `<app>.api.serializers` to include the new field. In most cases, it will not be necessary to also extend the nested serializer, which produces a minimal representation of the model.
 
-## 5. Add field to forms
+## 5. Add fields to forms
 
-Extend any forms to include the new field as appropriate. Common forms include:
+Extend any forms to include the new field(s) as appropriate. These are found under the `forms/` directory within each app. Common forms include:
 
 * **Credit/edit** - Manipulating a single object
 * **Bulk edit** - Performing a change on many objects at once
@@ -51,11 +50,11 @@ Extend any forms to include the new field as appropriate. Common forms include:
 
 ## 6. Extend object filter set
 
-If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to reference it in the FilterSet's `search()` method.
+If the new field should be filterable, add it to the `FilterSet` for the model. If the field should be searchable, remember to query it in the FilterSet's `search()` method.
 
 ## 7. Add column to object table
 
-If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column.
+If the new field will be included in the object list view, add a column to the model's table. For simple fields, adding the field name to `Meta.fields` will be sufficient. More complex fields may require declaring a custom column. Also add the field name to `default_columns` if the column should be present in the table by default.
 
 ## 8. Update the UI templates
 

+ 19 - 8
docs/development/getting-started.md

@@ -35,6 +35,8 @@ The NetBox project utilizes three persistent git branches to track work:
 
 Typically, you'll base pull requests off of the `develop` branch, or off of `feature` if you're working on a new major release. **Never** merge pull requests into the `master` branch, which receives merged only from the `develop` branch.
 
+For example, assume that the current NetBox release is v3.1.1. Work applied to the `develop` branch will appear in v3.1.2, and work done under the `feature` branch will be included in the next minor release (v3.2.0).
+
 ### Enable Pre-Commit Hooks
 
 NetBox ships with a [git pre-commit hook](https://githooks.com/) script that automatically checks for style compliance and missing database migrations prior to committing changes. This helps avoid erroneous commits that result in CI test failures. You are encouraged to enable it by creating a link to `scripts/git-hooks/pre-commit`:
@@ -46,7 +48,7 @@ $ ln -s ../../scripts/git-hooks/pre-commit
 
 ### Create a Python Virtual Environment
 
-A [virtual environment](https://docs.python.org/3/tutorial/venv.html) is like a container for a set of Python packages. They allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
+A [virtual environment](https://docs.python.org/3/tutorial/venv.html) (or "venv" for short) is like a container for a set of Python packages. These allow you to build environments suited to specific projects without interfering with system packages or other projects. When installed per the documentation, NetBox uses a virtual environment in production.
 
 Create a virtual environment using the `venv` Python module:
 
@@ -57,8 +59,8 @@ $ python3 -m venv ~/.venv/netbox
 
 This will create a directory named `.venv/netbox/` in your home directory, which houses a virtual copy of the Python executable and its related libraries and tooling. When running NetBox for development, it will be run using the Python binary at `~/.venv/netbox/bin/python`.
 
-!!! info
-    Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created wherever you please.
+!!! info "Where to Create Your Virtual Environments"
+    Keeping virtual environments in `~/.venv/` is a common convention but entirely optional: Virtual environments can be created almost wherever you please.
 
 Once created, activate the virtual environment:
 
@@ -94,7 +96,7 @@ Within the `netbox/netbox/` directory, copy `configuration.example.py` to `confi
 
 ### Start the Development Server
 
-Django provides a lightweight, auto-updating HTTP/WSGI server for development use. NetBox extends this slightly to automatically import models and other utilities. Run the NetBox development server with the `nbshell` management command:
+Django provides a lightweight, auto-updating HTTP/WSGI server for development use. It is started with the `runserver` management command:
 
 ```no-highlight
 $ python netbox/manage.py runserver
@@ -109,9 +111,12 @@ Quit the server with CONTROL-C.
 
 This ensures that your development environment is now complete and operational. Any changes you make to the code base will be automatically adapted by the development server.
 
+!!! info "IDE Integration"
+    Some IDEs, such as PyCharm, will integrate with Django's development server and allow you to run it directly within the IDE. This is strongly encouraged as it makes for a much more convenient development environment.
+
 ## Running Tests
 
-Throughout the course of development, it's a good idea to occasionally run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command:
+Prior to committing any substantial changes to the code base, be sure to run NetBox's test suite to catch any potential errors. Tests are run using the `test` management command. Remember to ensure the Python virtual environment is active before running this command.
 
 ```no-highlight
 $ python netbox/manage.py test
@@ -123,9 +128,15 @@ In cases where you haven't made any changes to the database (which is most of th
 $ python netbox/manage.py test --keepdb
 ```
 
+You can also limit the command to running only a specific subset of tests. For example, to run only IPAM and DCIM view tests:
+
+```no-highlight
+$ python netbox/manage.py test dcim.tests.test_views ipam.tests.test_views
+```
+
 ## Submitting Pull Requests
 
-Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to reference it.
+Once you're happy with your work and have verified that all tests pass, commit your changes and push it upstream to your fork. Always provide descriptive (but not excessively verbose) commit messages. When working on a specific issue, be sure to prefix your commit message with the word "Fixes" or "Closes" and the issue number (with a hash mark). This tells GitHub to automatically close the referenced issue once the commit has been merged.
 
 ```no-highlight
 $ git commit -m "Closes #1234: Add IPv5 support"
@@ -136,5 +147,5 @@ Once your fork has the new commit, submit a [pull request](https://github.com/ne
 
 Once submitted, a maintainer will review your pull request and either merge it or request changes. If changes are needed, you can make them via new commits to your fork: The pull request will update automatically.
 
-!!! note
-    Remember, pull requests are entertained only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted.
+!!! note "Remember to Open an Issue First"
+    Remember, pull requests are permitted only for **accepted** issues. If an issue you want to work on hasn't been approved by a maintainer yet, it's best to avoid risking your time and effort on a change that might not be accepted. (The one exception to this is trivial changes to the documentation or other non-critical resources.)

+ 8 - 5
docs/development/index.md

@@ -1,25 +1,25 @@
 # NetBox Development
 
-NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox.
+NetBox is maintained as a [GitHub project](https://github.com/netbox-community/netbox) under the Apache 2 license. Users are encouraged to submit GitHub issues for feature requests and bug reports, however we are very selective about pull requests. Each pull request must be preceded by an **approved** issue. Please see the `CONTRIBUTING` guide for more direction on contributing to NetBox.
 
 ## Communication
 
 There are several official forums for communication among the developers and community members:
 
-* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in an issue.
+* [GitHub issues](https://github.com/netbox-community/netbox/issues) - All feature requests, bug reports, and other substantial changes to the code base **must** be documented in a GitHub issue.
 * [GitHub Discussions](https://github.com/netbox-community/netbox/discussions) - The preferred forum for general discussion and support issues. Ideal for shaping a feature request prior to submitting an issue.
 * [#netbox on NetDev Community Slack](https://netdev.chat/) - Good for quick chats. Avoid any discussion that might need to be referenced later on, as the chat history is not retained long.
 * [Google Group](https://groups.google.com/g/netbox-discuss) - Legacy mailing list; slowly being phased out in favor of GitHub discussions.
 
 ## Governance
 
-NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions (in other words, avoid scope creep).
+NetBox follows the [benevolent dictator](http://oss-watch.ac.uk/resources/benevolentdictatorgovernancemodel) model of governance, with [Jeremy Stretch](https://github.com/jeremystretch) ultimately responsible for all changes to the code base. While community contributions are welcomed and encouraged, the lead maintainer's primary role is to ensure the project's long-term maintainability and continued focus on its primary functions.
 
 ## Project Structure
 
-All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base.
+All development of the current NetBox release occurs in the `develop` branch; releases are packaged from the `master` branch. The `master` branch should _always_ represent the current stable release in its entirety, such that installing NetBox by either downloading a packaged release or cloning the `master` branch provides the same code base. Only pull requests representing new releases should be merged into `master`.
 
-NetBox components are arranged into functional subsections called _apps_ (a carryover from Django vernacular). Each app holds the models, views, and templates relevant to a particular function:
+NetBox components are arranged into Django apps. Each app holds the models, views, and other resources relevant to a particular function:
 
 * `circuits`: Communications circuits and providers (not to be confused with power circuits)
 * `dcim`: Datacenter infrastructure management (sites, racks, and devices)
@@ -29,3 +29,6 @@ NetBox components are arranged into functional subsections called _apps_ (a carr
 * `users`: Authentication and user preferences
 * `utilities`: Resources which are not user-facing (extendable classes, etc.)
 * `virtualization`: Virtual machines and clusters
+* `wireless`: Wireless links and LANs
+
+All core functionality is stored within the `netbox/` subdirectory. HTML templates are stored in a common `templates/` directory, with model- and view-specific templates arranged by app. Documentation is kept in the `docs/` root directory.

+ 3 - 2
docs/development/models.md

@@ -17,12 +17,12 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
 * Nesting - These models can be nested recursively to create a hierarchy
 
 | Type               | Change Logging   | Webhooks         | Custom Fields    | Export Templates | Tags             | Journaling       | Nesting          |
-| ------------------ | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- | ---------------- |
+| ------------------ | ---------------- | ---------------- |------------------| ---------------- | ---------------- | ---------------- | ---------------- |
 | Primary            | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |
 | Organizational     | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |
 | Nested Group       | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  | :material-check: |
 | Component          | :material-check: | :material-check: | :material-check: | :material-check: | :material-check: |                  |                  |
-| Component Template | :material-check: | :material-check: | :material-check: |                  |                  |                  |                  |
+| Component Template | :material-check: | :material-check: |                  |                  |                  |                  |                  |
 
 ## Models Index
 
@@ -44,6 +44,7 @@ The Django [content types](https://docs.djangoproject.com/en/stable/ref/contrib/
 * [ipam.ASN](../models/ipam/asn.md)
 * [ipam.FHRPGroup](../models/ipam/fhrpgroup.md)
 * [ipam.IPAddress](../models/ipam/ipaddress.md)
+* [ipam.IPRange](../models/ipam/iprange.md)
 * [ipam.Prefix](../models/ipam/prefix.md)
 * [ipam.RouteTarget](../models/ipam/routetarget.md)
 * [ipam.Service](../models/ipam/service.md)

+ 3 - 3
docs/development/style-guide.md

@@ -1,6 +1,6 @@
 # Style Guide
 
-NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh`.
+NetBox generally follows the [Django style guide](https://docs.djangoproject.com/en/stable/internals/contributing/writing-code/coding-style/), which is itself based on [PEP 8](https://www.python.org/dev/peps/pep-0008/). [Pycodestyle](https://github.com/pycqa/pycodestyle) is used to validate code formatting, ignoring certain violations. See `scripts/cibuild.sh` for details.
 
 ## PEP 8 Exceptions
 
@@ -30,7 +30,7 @@ pycodestyle --ignore=W504,E501 netbox/
 
 ## Introducing New Dependencies
 
-The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and attacks.
+The introduction of a new dependency is best avoided unless it is absolutely necessary. For small features, it's generally preferable to replicate functionality within the NetBox code base rather than to introduce reliance on an external project. This reduces both the burden of tracking new releases and our exposure to outside bugs and supply chain attacks.
 
 If there's a strong case for introducing a new dependency, it must meet the following criteria:
 
@@ -43,7 +43,7 @@ When adding a new dependency, a short description of the package and the URL of
 
 ## General Guidance
 
-* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and open a bug so that the entire code base can be evaluated at a later point.
+* When in doubt, remain consistent: It is better to be consistently incorrect than inconsistently correct. If you notice in the course of unrelated work a pattern that should be corrected, continue to follow the pattern for now and submit a separate bug report so that the entire code base can be evaluated at a later point.
 
 * Prioritize readability over concision. Python is a very flexible language that typically offers several options for expressing a given piece of logic, but some may be more friendly to the reader than others. (List comprehensions are particularly vulnerable to over-optimization.) Always remain considerate of the future reader who may need to interpret your code without the benefit of the context within which you are writing it.
 

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

@@ -1,5 +1,33 @@
 # NetBox v3.1
 
+## v3.1.2 (2021-12-20)
+
+### Enhancements
+
+* [#7661](https://github.com/netbox-community/netbox/issues/7661) - Remove forced styling of custom banners
+* [#7665](https://github.com/netbox-community/netbox/issues/7665) - Add toggle to show only available child prefixes
+* [#7999](https://github.com/netbox-community/netbox/issues/7999) - Add 6 GHz and 60 GHz wireless channels
+* [#8057](https://github.com/netbox-community/netbox/issues/8057) - Dynamic object tables using HTMX
+* [#8080](https://github.com/netbox-community/netbox/issues/8080) - Link to NAT IPs for device/VM primary IPs
+* [#8081](https://github.com/netbox-community/netbox/issues/8081) - Allow creating services directly from navigation menu
+* [#8083](https://github.com/netbox-community/netbox/issues/8083) - Removed "related devices" panel from device view
+* [#8108](https://github.com/netbox-community/netbox/issues/8108) - Improve breadcrumb links for device/VM components
+
+### Bug Fixes
+
+* [#7674](https://github.com/netbox-community/netbox/issues/7674) - Fix inadvertent application of device type context to virtual machines
+* [#8074](https://github.com/netbox-community/netbox/issues/8074) - Ordering VMs by name should reference naturalized value
+* [#8077](https://github.com/netbox-community/netbox/issues/8077) - Fix exception when attaching image to location, circuit, or power panel
+* [#8078](https://github.com/netbox-community/netbox/issues/8078) - Add missing wireless models to `lsmodels()` in `nbshell`
+* [#8079](https://github.com/netbox-community/netbox/issues/8079) - Fix validation of LLDP neighbors when connected device has an asset tag
+* [#8088](https://github.com/netbox-community/netbox/issues/8088) - Improve legibility of text in labels with light-colored backgrounds
+* [#8092](https://github.com/netbox-community/netbox/issues/8092) - Rack elevations should not include device asset tags
+* [#8096](https://github.com/netbox-community/netbox/issues/8096) - Fix DataError during change logging of objects with very long string representations
+* [#8101](https://github.com/netbox-community/netbox/issues/8101) - Preserve return URL when using "create and add another" button
+* [#8102](https://github.com/netbox-community/netbox/issues/8102) - Raise validation error when attempting to assign an IP address to multiple objects
+
+---
+
 ## v3.1.1 (2021-12-13)
 
 ### Enhancements

+ 8 - 4
netbox/dcim/svg.py

@@ -18,6 +18,10 @@ __all__ = (
 )
 
 
+def get_device_name(device):
+    return device.name or str(device.device_type)
+
+
 class RackElevationSVG:
     """
     Use this class to render a rack elevation as an SVG image.
@@ -85,7 +89,7 @@ class RackElevationSVG:
         return drawing
 
     def _draw_device_front(self, drawing, device, start, end, text):
-        name = str(device)
+        name = get_device_name(device)
         if device.devicebay_count:
             name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
 
@@ -120,7 +124,7 @@ class RackElevationSVG:
         rect = drawing.rect(start, end, class_="slot blocked")
         rect.set_desc(self._get_device_description(device))
         drawing.add(rect)
-        drawing.add(drawing.text(str(device), insert=text))
+        drawing.add(drawing.text(get_device_name(device), insert=text))
 
         # Embed rear device type image if one exists
         if self.include_images and device.device_type.rear_image:
@@ -132,9 +136,9 @@ class RackElevationSVG:
             )
             image.fit(scale='slice')
             drawing.add(image)
-            drawing.add(drawing.text(str(device), insert=text, stroke='black',
+            drawing.add(drawing.text(get_device_name(device), insert=text, stroke='black',
                         stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
-            drawing.add(drawing.text(str(device), insert=text, fill='white', class_='device-image-label'))
+            drawing.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
 
     @staticmethod
     def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):

+ 0 - 2
netbox/dcim/urls.py

@@ -1,7 +1,6 @@
 from django.urls import path
 
 from extras.views import ObjectChangeLogView, ObjectJournalView
-from ipam.views import ServiceEditView
 from utilities.views import SlugRedirectView
 from . import views
 from .models import *
@@ -233,7 +232,6 @@ urlpatterns = [
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
-    path('devices/<int:device>/services/assign/', ServiceEditView.as_view(), name='device_service_assign'),
 
     # Console ports
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),

+ 42 - 46
netbox/dcim/views.py

@@ -36,26 +36,15 @@ from .models import (
 )
 
 
-class DeviceComponentsView(generic.ObjectView):
+class DeviceComponentsView(generic.ObjectChildrenView):
     queryset = Device.objects.all()
-    model = None
-    table = None
 
-    def get_components(self, request, instance):
-        return self.model.objects.restrict(request.user, 'view').filter(device=instance)
+    def get_children(self, request, parent):
+        return self.child_model.objects.restrict(request.user, 'view').filter(device=parent)
 
     def get_extra_context(self, request, instance):
-        components = self.get_components(request, instance)
-        table = self.table(data=components, user=request.user)
-        change_perm = f'{self.model._meta.app_label}.change_{self.model._meta.model_name}'
-        delete_perm = f'{self.model._meta.app_label}.delete_{self.model._meta.model_name}'
-        if request.user.has_perm(change_perm) or request.user.has_perm(delete_perm):
-            table.columns.show('pk')
-        paginate_table(table, request)
-
         return {
-            'table': table,
-            'active_tab': f"{self.model._meta.verbose_name_plural.replace(' ', '-')}",
+            'active_tab': f"{self.child_model._meta.verbose_name_plural.replace(' ', '-')}",
         }
 
 
@@ -63,8 +52,8 @@ class DeviceTypeComponentsView(DeviceComponentsView):
     queryset = DeviceType.objects.all()
     template_name = 'dcim/devicetype/component_templates.html'
 
-    def get_components(self, request, instance):
-        return self.model.objects.restrict(request.user, 'view').filter(device_type=instance)
+    def get_children(self, request, parent):
+        return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
 
 
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
@@ -806,43 +795,51 @@ class DeviceTypeView(generic.ObjectView):
 
 
 class DeviceTypeConsolePortsView(DeviceTypeComponentsView):
-    model = ConsolePortTemplate
+    child_model = ConsolePortTemplate
     table = tables.ConsolePortTemplateTable
+    filterset = filtersets.ConsolePortTemplateFilterSet
 
 
 class DeviceTypeConsoleServerPortsView(DeviceTypeComponentsView):
-    model = ConsoleServerPortTemplate
+    child_model = ConsoleServerPortTemplate
     table = tables.ConsoleServerPortTemplateTable
+    filterset = filtersets.ConsoleServerPortTemplateFilterSet
 
 
 class DeviceTypePowerPortsView(DeviceTypeComponentsView):
-    model = PowerPortTemplate
+    child_model = PowerPortTemplate
     table = tables.PowerPortTemplateTable
+    filterset = filtersets.PowerPortTemplateFilterSet
 
 
 class DeviceTypePowerOutletsView(DeviceTypeComponentsView):
-    model = PowerOutletTemplate
+    child_model = PowerOutletTemplate
     table = tables.PowerOutletTemplateTable
+    filterset = filtersets.PowerOutletTemplateFilterSet
 
 
 class DeviceTypeInterfacesView(DeviceTypeComponentsView):
-    model = InterfaceTemplate
+    child_model = InterfaceTemplate
     table = tables.InterfaceTemplateTable
+    filterset = filtersets.InterfaceTemplateFilterSet
 
 
 class DeviceTypeFrontPortsView(DeviceTypeComponentsView):
-    model = FrontPortTemplate
+    child_model = FrontPortTemplate
     table = tables.FrontPortTemplateTable
+    filterset = filtersets.FrontPortTemplateFilterSet
 
 
 class DeviceTypeRearPortsView(DeviceTypeComponentsView):
-    model = RearPortTemplate
+    child_model = RearPortTemplate
     table = tables.RearPortTemplateTable
+    filterset = filtersets.RearPortTemplateFilterSet
 
 
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
-    model = DeviceBayTemplate
+    child_model = DeviceBayTemplate
     table = tables.DeviceBayTemplateTable
+    filterset = filtersets.DeviceBayTemplateFilterSet
 
 
 class DeviceTypeEditView(generic.ObjectEditView):
@@ -1319,80 +1316,79 @@ class DeviceView(generic.ObjectView):
         # Services
         services = Service.objects.restrict(request.user, 'view').filter(device=instance)
 
-        # Find up to ten devices in the same site with the same functional role for quick reference.
-        related_devices = Device.objects.restrict(request.user, 'view').filter(
-            site=instance.site, device_role=instance.device_role
-        ).exclude(
-            pk=instance.pk
-        ).prefetch_related(
-            'rack', 'device_type__manufacturer'
-        )[:10]
-
         return {
             'services': services,
             'vc_members': vc_members,
-            'related_devices': related_devices,
             'active_tab': 'device',
         }
 
 
 class DeviceConsolePortsView(DeviceComponentsView):
-    model = ConsolePort
+    child_model = ConsolePort
     table = tables.DeviceConsolePortTable
+    filterset = filtersets.ConsolePortFilterSet
     template_name = 'dcim/device/consoleports.html'
 
 
 class DeviceConsoleServerPortsView(DeviceComponentsView):
-    model = ConsoleServerPort
+    child_model = ConsoleServerPort
     table = tables.DeviceConsoleServerPortTable
+    filterset = filtersets.ConsoleServerPortFilterSet
     template_name = 'dcim/device/consoleserverports.html'
 
 
 class DevicePowerPortsView(DeviceComponentsView):
-    model = PowerPort
+    child_model = PowerPort
     table = tables.DevicePowerPortTable
+    filterset = filtersets.PowerPortFilterSet
     template_name = 'dcim/device/powerports.html'
 
 
 class DevicePowerOutletsView(DeviceComponentsView):
-    model = PowerOutlet
+    child_model = PowerOutlet
     table = tables.DevicePowerOutletTable
+    filterset = filtersets.PowerOutletFilterSet
     template_name = 'dcim/device/poweroutlets.html'
 
 
 class DeviceInterfacesView(DeviceComponentsView):
-    model = Interface
+    child_model = Interface
     table = tables.DeviceInterfaceTable
+    filterset = filtersets.InterfaceFilterSet
     template_name = 'dcim/device/interfaces.html'
 
-    def get_components(self, request, instance):
-        return instance.vc_interfaces().restrict(request.user, 'view').prefetch_related(
+    def get_children(self, request, parent):
+        return parent.vc_interfaces().restrict(request.user, 'view').prefetch_related(
             Prefetch('ip_addresses', queryset=IPAddress.objects.restrict(request.user)),
             Prefetch('member_interfaces', queryset=Interface.objects.restrict(request.user))
         )
 
 
 class DeviceFrontPortsView(DeviceComponentsView):
-    model = FrontPort
+    child_model = FrontPort
     table = tables.DeviceFrontPortTable
+    filterset = filtersets.FrontPortFilterSet
     template_name = 'dcim/device/frontports.html'
 
 
 class DeviceRearPortsView(DeviceComponentsView):
-    model = RearPort
+    child_model = RearPort
     table = tables.DeviceRearPortTable
+    filterset = filtersets.RearPortFilterSet
     template_name = 'dcim/device/rearports.html'
 
 
 class DeviceDeviceBaysView(DeviceComponentsView):
-    model = DeviceBay
+    child_model = DeviceBay
     table = tables.DeviceDeviceBayTable
+    filterset = filtersets.DeviceBayFilterSet
     template_name = 'dcim/device/devicebays.html'
 
 
 class DeviceInventoryView(DeviceComponentsView):
-    model = InventoryItem
+    child_model = InventoryItem
     table = tables.DeviceInventoryItemTable
+    filterset = filtersets.InventoryItemFilterSet
     template_name = 'dcim/device/inventory.html'
 
 

+ 1 - 11
netbox/extras/api/serializers.py

@@ -170,17 +170,7 @@ class ImageAttachmentSerializer(ValidatedModelSerializer):
 
     @swagger_serializer_method(serializer_or_field=serializers.DictField)
     def get_parent(self, obj):
-
-        # Static mapping of models to their nested serializers
-        if isinstance(obj.parent, Device):
-            serializer = NestedDeviceSerializer
-        elif isinstance(obj.parent, Rack):
-            serializer = NestedRackSerializer
-        elif isinstance(obj.parent, Site):
-            serializer = NestedSiteSerializer
-        else:
-            raise Exception("Unexpected type of parent object for ImageAttachment")
-
+        serializer = get_serializer_for_model(obj.parent, prefix='Nested')
         return serializer(obj.parent, context={'request': self.context['request']}).data
 
 

+ 1 - 1
netbox/extras/management/commands/nbshell.py

@@ -9,7 +9,7 @@ from django.contrib.auth.models import User
 from django.contrib.contenttypes.models import ContentType
 from django.core.management.base import BaseCommand
 
-APPS = ['circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization']
+APPS = ('circuits', 'dcim', 'extras', 'ipam', 'tenancy', 'users', 'virtualization', 'wireless')
 
 BANNER_TEXT = """### NetBox interactive shell ({node})
 ### Python {python} | Django {django} | NetBox {netbox}

+ 3 - 5
netbox/extras/querysets.py

@@ -22,7 +22,7 @@ class ConfigContextQuerySet(RestrictedQuerySet):
         # Device type assignment is relevant only for Devices
         device_type = getattr(obj, 'device_type', None)
 
-        # Cluster assignment is relevant only for VirtualMachines
+        # Get assigned Cluster and ClusterGroup, if any
         cluster = getattr(obj, 'cluster', None)
         cluster_group = getattr(cluster, 'group', None)
 
@@ -67,11 +67,8 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
     Includes a method which appends an annotation of aggregated config context JSON data objects. This is
     implemented as a subquery which performs all the joins necessary to filter relevant config context objects.
     This offers a substantial performance gain over ConfigContextQuerySet.get_for_object() when dealing with
-    multiple objects.
-
-    This allows the annotation to be entirely optional.
+    multiple objects. This allows the annotation to be entirely optional.
     """
-
     def annotate_config_context_data(self):
         """
         Attach the subquery annotation to the base queryset
@@ -123,6 +120,7 @@ class ConfigContextModelQuerySet(RestrictedQuerySet):
         elif self.model._meta.model_name == 'virtualmachine':
             base_query.add((Q(roles=OuterRef('role')) | Q(roles=None)), Q.AND)
             base_query.add((Q(sites=OuterRef('cluster__site')) | Q(sites=None)), Q.AND)
+            base_query.add(Q(device_types=None), Q.AND)
             region_field = 'cluster__site__region'
             sitegroup_field = 'cluster__site__group'
 

+ 27 - 22
netbox/ipam/forms/models.py

@@ -462,12 +462,15 @@ class IPAddressForm(TenancyForm, CustomFieldModelForm):
         super().clean()
 
         # Handle object assignment
-        if self.cleaned_data['interface']:
-            self.instance.assigned_object = self.cleaned_data['interface']
-        elif self.cleaned_data['vminterface']:
-            self.instance.assigned_object = self.cleaned_data['vminterface']
-        elif self.cleaned_data['fhrpgroup']:
-            self.instance.assigned_object = self.cleaned_data['fhrpgroup']
+        selected_objects = [
+            field for field in ('interface', 'vminterface', 'fhrpgroup') if self.cleaned_data[field]
+        ]
+        if len(selected_objects) > 1:
+            raise forms.ValidationError({
+                selected_objects[1]: "An IP address can only be assigned to a single object."
+            })
+        elif selected_objects:
+            self.instance.assigned_object = self.cleaned_data[selected_objects[0]]
 
         # Primary IP assignment is only available if an interface has been assigned.
         interface = self.cleaned_data.get('interface') or self.cleaned_data.get('vminterface')
@@ -809,6 +812,14 @@ class VLANForm(TenancyForm, CustomFieldModelForm):
 
 
 class ServiceForm(CustomFieldModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False
+    )
+    virtual_machine = DynamicModelChoiceField(
+        queryset=VirtualMachine.objects.all(),
+        required=False
+    )
     ports = NumericArrayField(
         base_field=forms.IntegerField(
             min_value=SERVICE_PORT_MIN,
@@ -816,6 +827,15 @@ class ServiceForm(CustomFieldModelForm):
         ),
         help_text="Comma-separated list of one or more port numbers. A range may be specified using a hyphen."
     )
+    ipaddresses = DynamicModelMultipleChoiceField(
+        queryset=IPAddress.objects.all(),
+        required=False,
+        label='IP Addresses',
+        query_params={
+            'device_id': '$device',
+            'virtual_machine_id': '$virtual_machine',
+        }
+    )
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         required=False
@@ -824,7 +844,7 @@ class ServiceForm(CustomFieldModelForm):
     class Meta:
         model = Service
         fields = [
-            'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
+            'device', 'virtual_machine', 'name', 'protocol', 'ports', 'ipaddresses', 'description', 'tags',
         ]
         help_texts = {
             'ipaddresses': "IP address assignment is optional. If no IPs are selected, the service is assumed to be "
@@ -834,18 +854,3 @@ class ServiceForm(CustomFieldModelForm):
             'protocol': StaticSelect(),
             'ipaddresses': StaticSelectMultiple(),
         }
-
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit IP address choices to those assigned to interfaces of the parent device/VM
-        if self.instance.device:
-            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                interface__in=self.instance.device.vc_interfaces().values_list('id', flat=True)
-            )
-        elif self.instance.virtual_machine:
-            self.fields['ipaddresses'].queryset = IPAddress.objects.filter(
-                vminterface__in=self.instance.virtual_machine.interfaces.values_list('id', flat=True)
-            )
-        else:
-            self.fields['ipaddresses'].choices = []

+ 4 - 6
netbox/ipam/models/fhrp.py

@@ -58,13 +58,11 @@ class FHRPGroup(PrimaryModel):
     def __str__(self):
         name = f'{self.get_protocol_display()}: {self.group_id}'
 
-        # Append the list of assigned IP addresses to serve as an additional identifier
+        # Append the first assigned IP addresses (if any) to serve as an additional identifier
         if self.pk:
-            ip_addresses = [
-                str(ip.address) for ip in self.ip_addresses.all()
-            ]
-            if ip_addresses:
-                return f"{name} ({', '.join(ip_addresses)})"
+            ip_address = self.ip_addresses.first()
+            if ip_address:
+                return f"{name} ({ip_address})"
 
         return name
 

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

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

+ 1 - 12
netbox/ipam/tests/test_views.py

@@ -562,18 +562,7 @@ class VLANTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         }
 
 
-# TODO: Update base class to PrimaryObjectViewTestCase
-# Blocked by absence of standard creation view
-class ServiceTestCase(
-    ViewTestCases.GetObjectViewTestCase,
-    ViewTestCases.GetObjectChangelogViewTestCase,
-    ViewTestCases.EditObjectViewTestCase,
-    ViewTestCases.DeleteObjectViewTestCase,
-    ViewTestCases.ListObjectsViewTestCase,
-    ViewTestCases.BulkImportObjectsViewTestCase,
-    ViewTestCases.BulkEditObjectsViewTestCase,
-    ViewTestCases.BulkDeleteObjectsViewTestCase
-):
+class ServiceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     model = Service
 
     @classmethod

+ 2 - 0
netbox/ipam/urls.py

@@ -61,6 +61,7 @@ urlpatterns = [
     path('aggregates/edit/', views.AggregateBulkEditView.as_view(), name='aggregate_bulk_edit'),
     path('aggregates/delete/', views.AggregateBulkDeleteView.as_view(), name='aggregate_bulk_delete'),
     path('aggregates/<int:pk>/', views.AggregateView.as_view(), name='aggregate'),
+    path('aggregates/<int:pk>/prefixes/', views.AggregatePrefixesView.as_view(), name='aggregate_prefixes'),
     path('aggregates/<int:pk>/edit/', views.AggregateEditView.as_view(), name='aggregate_edit'),
     path('aggregates/<int:pk>/delete/', views.AggregateDeleteView.as_view(), name='aggregate_delete'),
     path('aggregates/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='aggregate_changelog', kwargs={'model': Aggregate}),
@@ -163,6 +164,7 @@ urlpatterns = [
 
     # Services
     path('services/', views.ServiceListView.as_view(), name='service_list'),
+    path('services/add/', views.ServiceEditView.as_view(), name='service_add'),
     path('services/import/', views.ServiceBulkImportView.as_view(), name='service_import'),
     path('services/edit/', views.ServiceBulkEditView.as_view(), name='service_bulk_edit'),
     path('services/delete/', views.ServiceBulkDeleteView.as_view(), name='service_bulk_delete'),

+ 23 - 9
netbox/ipam/utils.py

@@ -4,20 +4,34 @@ from .constants import *
 from .models import Prefix, VLAN
 
 
-def add_available_prefixes(parent, prefix_list):
+def add_requested_prefixes(parent, prefix_list, show_available=True, show_assigned=True):
     """
-    Create fake Prefix objects for all unallocated space within a prefix.
+    Return a list of requested prefixes using show_available, show_assigned filters. If available prefixes are
+    requested, create fake Prefix objects for all unallocated space within a prefix.
+
+    :param parent: Parent Prefix instance
+    :param prefix_list: Child prefixes list
+    :param show_available: Include available prefixes.
+    :param show_assigned: Show assigned prefixes.
     """
+    child_prefixes = []
+
+    # Add available prefixes to the table if requested
+    if prefix_list and show_available:
+
+        # Find all unallocated space, add fake Prefix objects to child_prefixes.
+        available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
+        available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
+        child_prefixes = child_prefixes + available_prefixes
 
-    # Find all unallocated space
-    available_prefixes = netaddr.IPSet(parent) ^ netaddr.IPSet([p.prefix for p in prefix_list])
-    available_prefixes = [Prefix(prefix=p, status=None) for p in available_prefixes.iter_cidrs()]
+    # Add assigned prefixes to the table if requested
+    if prefix_list and show_assigned:
+        child_prefixes = child_prefixes + list(prefix_list)
 
-    # Concatenate and sort complete list of children
-    prefix_list = list(prefix_list) + available_prefixes
-    prefix_list.sort(key=lambda p: p.prefix)
+    # Sort child prefixes after additions
+    child_prefixes.sort(key=lambda p: p.prefix)
 
-    return prefix_list
+    return child_prefixes
 
 
 def add_available_ipaddresses(prefix, ipaddress_list, is_pool=False):

+ 76 - 145
netbox/ipam/views.py

@@ -1,21 +1,22 @@
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Prefetch
 from django.db.models.expressions import RawSQL
-from django.http import Http404
 from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 
+from dcim.filtersets import InterfaceFilterSet
 from dcim.models import Device, Interface, Site
 from dcim.tables import SiteTable
 from netbox.views import generic
 from utilities.tables import paginate_table
 from utilities.utils import count_related
+from virtualization.filtersets import VMInterfaceFilterSet
 from virtualization.models import VirtualMachine, VMInterface
 from . import filtersets, forms, tables
 from .constants import *
 from .models import *
 from .models import ASN
-from .utils import add_available_ipaddresses, add_available_prefixes, add_available_vlans
+from .utils import add_requested_prefixes, add_available_vlans
 
 
 #
@@ -274,37 +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 by this aggregate
-        child_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
-            prefix__net_contained_or_equal=str(instance.prefix)
-        ).prefetch_related(
-            'site', 'role'
-        ).order_by(
-            'prefix'
-        )
 
-        # Add available prefixes to the table if requested
-        if request.GET.get('show_available', 'true') == 'true':
-            child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
+class AggregatePrefixesView(generic.ObjectChildrenView):
+    queryset = Aggregate.objects.all()
+    child_model = Prefix
+    table = tables.PrefixTable
+    filterset = filtersets.PrefixFilterSet
+    template_name = 'ipam/aggregate/prefixes.html'
 
-        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)
+    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')
 
-        # 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 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')
 
+        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': request.GET.get('show_available', 'true') == 'true',
+            'active_tab': 'prefixes',
+            'show_available': bool(request.GET.get('show_available', 'true') == 'true'),
+            'show_assigned': bool(request.GET.get('show_assigned', 'true') == 'true'),
         }
 
 
@@ -451,104 +447,66 @@ class PrefixView(generic.ObjectView):
         }
 
 
-class PrefixPrefixesView(generic.ObjectView):
+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_extra_context(self, request, instance):
-        # Child prefixes table
-        child_prefixes = instance.get_child_prefixes().restrict(request.user, 'view').prefetch_related(
-            'site', 'vlan', 'role',
-        )
-
-        # Add available prefixes to the table if requested
-        if child_prefixes and request.GET.get('show_available', 'true') == 'true':
-            child_prefixes = add_available_prefixes(instance.prefix, child_prefixes)
-
-        table = tables.PrefixTable(child_prefixes, user=request.user, exclude=('utilization',))
-        if request.user.has_perm('ipam.change_prefix') or request.user.has_perm('ipam.delete_prefix'):
-            table.columns.show('pk')
-        paginate_table(table, request)
+    def get_children(self, request, parent):
+        return parent.get_child_prefixes().restrict(request.user, 'view')
 
-        bulk_querystring = 'vrf_id={}&within={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
+    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')
 
-        # Compile permissions list for rendering the object table
-        permissions = {
-            'change': request.user.has_perm('ipam.change_prefix'),
-            'delete': request.user.has_perm('ipam.delete_prefix'),
-        }
+        return add_requested_prefixes(parent.prefix, queryset, show_available, show_assigned)
 
+    def get_extra_context(self, request, instance):
         return {
-            'table': table,
-            'permissions': permissions,
-            'bulk_querystring': bulk_querystring,
+            'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&within={instance.prefix}",
             'active_tab': 'prefixes',
             'first_available_prefix': instance.get_first_available_prefix(),
-            'show_available': 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'),
         }
 
 
-class PrefixIPRangesView(generic.ObjectView):
+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_extra_context(self, request, instance):
-        # Find all IPRanges belonging to this Prefix
-        ip_ranges = instance.get_child_ranges().restrict(request.user, 'view').prefetch_related('vrf')
-
-        table = tables.IPRangeTable(ip_ranges, user=request.user)
-        if request.user.has_perm('ipam.change_iprange') or request.user.has_perm('ipam.delete_iprange'):
-            table.columns.show('pk')
-        paginate_table(table, request)
-
-        bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
-
-        # Compile permissions list for rendering the object table
-        permissions = {
-            'change': request.user.has_perm('ipam.change_iprange'),
-            'delete': request.user.has_perm('ipam.delete_iprange'),
-        }
+    def get_children(self, request, parent):
+        return parent.get_child_ranges().restrict(request.user, 'view')
 
+    def get_extra_context(self, request, instance):
         return {
-            'table': table,
-            'permissions': permissions,
-            'bulk_querystring': bulk_querystring,
+            'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
             'active_tab': 'ip-ranges',
+            'first_available_ip': instance.get_first_available_ip(),
         }
 
 
-class PrefixIPAddressesView(generic.ObjectView):
+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_extra_context(self, request, instance):
-        # Find all IPAddresses belonging to this Prefix
-        ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
-
-        # Add available IP addresses to the table if requested
-        if request.GET.get('show_available', 'true') == 'true':
-            ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
-
-        table = tables.IPAddressTable(ipaddresses, user=request.user)
-        if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
-            table.columns.show('pk')
-        paginate_table(table, request)
-
-        bulk_querystring = 'vrf_id={}&parent={}'.format(instance.vrf.pk if instance.vrf else '0', instance.prefix)
-
-        # Compile permissions list for rendering the object table
-        permissions = {
-            'change': request.user.has_perm('ipam.change_ipaddress'),
-            'delete': request.user.has_perm('ipam.delete_ipaddress'),
-        }
+    def get_children(self, request, parent):
+        return parent.get_child_ips().restrict(request.user, 'view')
 
+    def get_extra_context(self, request, instance):
         return {
-            'table': table,
-            'permissions': permissions,
-            'bulk_querystring': bulk_querystring,
+            'bulk_querystring': f"vrf_id={instance.vrf.pk if instance.vrf else '0'}&parent={instance.prefix}",
             'active_tab': 'ip-addresses',
             'first_available_ip': instance.get_first_available_ip(),
-            'show_available': request.GET.get('show_available', 'true') == 'true',
         }
 
 
@@ -596,35 +554,19 @@ class IPRangeView(generic.ObjectView):
     queryset = IPRange.objects.all()
 
 
-class IPRangeIPAddressesView(generic.ObjectView):
+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_extra_context(self, request, instance):
-        # Find all IPAddresses within this range
-        ipaddresses = instance.get_child_ips().restrict(request.user, 'view').prefetch_related('vrf')
-
-        # Add available IP addresses to the table if requested
-        # if request.GET.get('show_available', 'true') == 'true':
-        #     ipaddresses = add_available_ipaddresses(instance.prefix, ipaddresses, instance.is_pool)
-
-        ip_table = tables.IPAddressTable(ipaddresses)
-        if request.user.has_perm('ipam.change_ipaddress') or request.user.has_perm('ipam.delete_ipaddress'):
-            ip_table.columns.show('pk')
-        paginate_table(ip_table, request)
-
-        # Compile permissions list for rendering the object table
-        permissions = {
-            'add': request.user.has_perm('ipam.add_ipaddress'),
-            'change': request.user.has_perm('ipam.change_ipaddress'),
-            'delete': request.user.has_perm('ipam.delete_ipaddress'),
-        }
+    def get_children(self, request, parent):
+        return parent.get_child_ips().restrict(request.user, 'view')
 
+    def get_extra_context(self, request, instance):
         return {
-            'ip_table': ip_table,
-            'permissions': permissions,
             'active_tab': 'ip-addresses',
-            'show_available': request.GET.get('show_available', 'true') == 'true',
         }
 
 
@@ -1012,32 +954,34 @@ class VLANView(generic.ObjectView):
         }
 
 
-class VLANInterfacesView(generic.ObjectView):
+class VLANInterfacesView(generic.ObjectChildrenView):
     queryset = VLAN.objects.all()
+    child_model = Interface
+    table = tables.VLANDevicesTable
+    filterset = InterfaceFilterSet
     template_name = 'ipam/vlan/interfaces.html'
 
-    def get_extra_context(self, request, instance):
-        interfaces = instance.get_interfaces().prefetch_related('device')
-        members_table = tables.VLANDevicesTable(interfaces)
-        paginate_table(members_table, request)
+    def get_children(self, request, parent):
+        return parent.get_interfaces().restrict(request.user, 'view')
 
+    def get_extra_context(self, request, instance):
         return {
-            'members_table': members_table,
             'active_tab': 'interfaces',
         }
 
 
-class VLANVMInterfacesView(generic.ObjectView):
+class VLANVMInterfacesView(generic.ObjectChildrenView):
     queryset = VLAN.objects.all()
+    child_model = VMInterface
+    table = tables.VLANVirtualMachinesTable
+    filterset = VMInterfaceFilterSet
     template_name = 'ipam/vlan/vminterfaces.html'
 
-    def get_extra_context(self, request, instance):
-        interfaces = instance.get_vminterfaces().prefetch_related('virtual_machine')
-        members_table = tables.VLANVirtualMachinesTable(interfaces)
-        paginate_table(members_table, request)
+    def get_children(self, request, parent):
+        return parent.get_vminterfaces().restrict(request.user, 'view')
 
+    def get_extra_context(self, request, instance):
         return {
-            'members_table': members_table,
             'active_tab': 'vminterfaces',
         }
 
@@ -1092,19 +1036,6 @@ class ServiceEditView(generic.ObjectEditView):
     model_form = forms.ServiceForm
     template_name = 'ipam/service_edit.html'
 
-    def alter_obj(self, obj, request, url_args, url_kwargs):
-        if 'device' in url_kwargs:
-            obj.device = get_object_or_404(
-                Device.objects.restrict(request.user),
-                pk=url_kwargs['device']
-            )
-        elif 'virtualmachine' in url_kwargs:
-            obj.virtual_machine = get_object_or_404(
-                VirtualMachine.objects.restrict(request.user),
-                pk=url_kwargs['virtualmachine']
-            )
-        return obj
-
 
 class ServiceBulkImportView(generic.BulkImportView):
     queryset = Service.objects.all()

+ 1 - 1
netbox/netbox/models.py

@@ -62,7 +62,7 @@ class ChangeLoggingMixin(models.Model):
         objectchange = ObjectChange(
             changed_object=self,
             related_object=related_object,
-            object_repr=str(self),
+            object_repr=str(self)[:200],
             action=action
         )
         if hasattr(self, '_prechange_snapshot'):

+ 1 - 1
netbox/netbox/navigation_menu.py

@@ -260,7 +260,7 @@ IPAM_MENU = Menu(
             label='Other',
             items=(
                 get_model_item('ipam', 'fhrpgroup', 'FHRP Groups'),
-                get_model_item('ipam', 'service', 'Services', actions=['import']),
+                get_model_item('ipam', 'service', 'Services'),
             ),
         ),
     ),

+ 1 - 1
netbox/netbox/settings.py

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

+ 82 - 3
netbox/netbox/views/generic.py

@@ -23,6 +23,7 @@ from utilities.exceptions import AbortTransaction, PermissionsViolation
 from utilities.forms import (
     BootstrapMixin, BulkRenameForm, ConfirmationForm, CSVDataField, CSVFileField, ImportForm, restrict_form_fields,
 )
+from utilities.htmx import is_htmx
 from utilities.permissions import get_permission_for_model
 from utilities.tables import paginate_table
 from utilities.utils import normalize_querydict, prepare_cloned_fields
@@ -72,6 +73,75 @@ class ObjectView(ObjectPermissionRequiredMixin, View):
         })
 
 
+class ObjectChildrenView(ObjectView):
+    """
+    Display a table of child objects associated with the parent object.
+
+    queryset: The base queryset for retrieving the *parent* object
+    table: Table class used to render child objects list
+    template_name: Name of the template to use
+    """
+    queryset = None
+    child_model = None
+    table = None
+    filterset = None
+    template_name = None
+
+    def get_children(self, request, parent):
+        """
+        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.
+        """
+        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(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,
+            'permissions': permissions,
+            **self.get_extra_context(request, instance),
+        })
+
+
 class ObjectListView(ObjectPermissionRequiredMixin, View):
     """
     List a series of objects.
@@ -185,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,
@@ -295,8 +371,11 @@ class ObjectEditView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
                     redirect_url = request.path
 
                     # If the object has clone_fields, pre-populate a new instance of the form
-                    if hasattr(obj, 'clone_fields'):
-                        redirect_url += f"?{prepare_cloned_fields(obj)}"
+                    params = prepare_cloned_fields(obj)
+                    if 'return_url' in request.GET:
+                        params['return_url'] = request.GET.get('return_url')
+                    if params:
+                        redirect_url += f"?{params.urlencode()}"
 
                     return redirect(redirect_url)
 
@@ -1229,7 +1308,7 @@ class BulkComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin,
         if not selected_objects:
             messages.warning(request, "No {} were selected.".format(self.parent_model._meta.verbose_name_plural))
             return redirect(self.get_return_url(request))
-        table = self.table(selected_objects)
+        table = self.table(selected_objects, orderable=False)
 
         if '_create' in request.POST:
             form = self.form(request.POST)

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


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


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


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


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


BIN
netbox/project-static/img/netbox_touch-icon-180.png


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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

@@ -124,6 +124,7 @@
       onerror="window.location='{% url 'media_failure' %}?filename=netbox-print.css'"
     />
     <link rel="icon" type="image/png" href="{% static 'netbox.ico' %}" />
+    <link rel="apple-touch-icon" type="image/png" href="{% static 'netbox_touch-icon-180.png' %}" />
 
     {# Javascript #}
     <script

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

@@ -59,7 +59,7 @@
         </nav>
 
         {% if config.BANNER_TOP %}
-          <div class="alert alert-info text-center mx-3" role="alert">
+          <div class="text-center mx-3">
             {{ config.BANNER_TOP|safe }}
           </div>
         {% endif %}
@@ -99,7 +99,7 @@
         </div>
 
         {% if config.BANNER_BOTTOM %}
-          <div class="alert alert-info text-center mx-3" role="alert">
+          <div class="text-center mx-3">
             {{ config.BANNER_BOTTOM|safe }}
           </div>
         {% endif %}

+ 15 - 15
netbox/templates/circuits/circuittype.html

@@ -1,6 +1,15 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block extra_controls %}
+  {% if perms.circuits.add_circuit %}
+    <a href="{% url 'circuits:circuit_add' %}?type={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Circuit
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
 
 {% block content %}
 <div class="row mb-3">
@@ -39,22 +48,13 @@
 <div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Circuits
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=circuits_table %}
-      </div>
-      {% if perms.circuits.add_circuit %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'circuits:circuit_add' %}?type={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Circuit
-          </a>
-        </div>
-      {% endif %}
+      <h5 class="card-header">Circuits</h5>
+      <div class="card-body table-responsive">
+        {% render_table circuits_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
       </div>
-      {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
-      {% plugin_full_width_page object %}
+    </div>
+    {% plugin_full_width_page object %}
   </div>
 </div>
 {% endblock %}

+ 20 - 22
netbox/templates/circuits/provider.html

@@ -2,9 +2,18 @@
 {% load static %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block extra_controls %}
+  {% if perms.circuits.add_circuit %}
+    <a href="{% url 'circuits:circuit_add' %}?provider={{ object.pk }}" class="btn btn-sm btn-primary">
+      <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add circuit
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
 
 {% block content %}
-<div class="row">
+<div class="row mb-3">
 	  <div class="col col-md-6">
         <div class="card">
             <h5 class="card-header">
@@ -56,28 +65,17 @@
         {% include 'inc/panels/contacts.html' %}
         {% plugin_right_page object %}
     </div>
-    <div class="col col-md-12">
-        <div class="card">
-            <h5 class="card-header">
-                Circuits
-            </h5>
-            <div class="card-body">
-            {% include 'inc/table.html' with table=circuits_table %}
-            </div>
-            {% if perms.circuits.add_circuit %}
-            <div class="card-footer text-end noprint">
-                <a href="{% url 'circuits:circuit_add' %}?provider={{ object.pk }}" class="btn btn-sm btn-primary">
-                    <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add circuit
-                </a>
-            </div>
-            {% endif %}
-        </div>
-        {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
-    </div>
 </div>
-<div class="row">
-    <div class="col col-md-12">
-        {% plugin_full_width_page object %}
+<div class="row mb-3">
+  <div class="col col-md-12">
+    <div class="card">
+      <h5 class="card-header">Circuits</h5>
+      <div class="card-body table-responsive">
+        {% render_table circuits_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
+      </div>
     </div>
+    {% plugin_full_width_page object %}
+  </div>
 </div>
 {% endblock %}

+ 11 - 16
netbox/templates/circuits/providernetwork.html

@@ -2,6 +2,7 @@
 {% load static %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -9,7 +10,7 @@
 {% endblock %}
 
 {% block content %}
-<div class="row">
+<div class="row mb-3">
 	  <div class="col col-md-6">
         <div class="card">
             <h5 class="card-header">
@@ -43,22 +44,16 @@
         {% plugin_right_page object %}
     </div>
 </div>
-<div class="row">
-    <div class="col col-md-12">
-        <div class="card">
-            <h5 class="card-header">
-                Circuits
-            </h5>
-            <div class="card-body">
-                {% include 'inc/table.html' with table=circuits_table %}
-            </div>
-        </div>
+<div class="row mb-3">
+  <div class="col col-md-12">
+    <div class="card">
+      <h5 class="card-header">Circuits</h5>
+      <div class="card-body table-responsive">
+        {% render_table circuits_table 'inc/table.html' %}
         {% include 'inc/paginator.html' with paginator=circuits_table.paginator page=circuits_table.page %}
+      </div>
     </div>
-</div>
-<div class="row">
-    <div class="col col-md-12">
-        {% plugin_full_width_page object %}
-    </div>
+    {% plugin_full_width_page object %}
+  </div>
 </div>
 {% endblock %}

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

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

+ 8 - 1
netbox/templates/dcim/consoleport.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_consoleports' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row">
         <div class="col col-md-6">

+ 8 - 1
netbox/templates/dcim/consoleserverport.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_consoleserverports' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row">
         <div class="col col-md-6">

+ 25 - 58
netbox/templates/dcim/device.html

@@ -148,6 +148,12 @@
                     </div>
                 </div>
             {% endif %}
+            {% include 'inc/panels/custom_fields.html' %}
+            {% include 'inc/panels/tags.html' %}
+            {% include 'inc/panels/comments.html' %}
+            {% plugin_left_page object %}
+        </div>
+        <div class="col col-md-6">
             <div class="card">
                 <h5 class="card-header">
                     Management
@@ -179,31 +185,31 @@
                         <tr>
                             <th scope="row">Primary IPv4</th>
                             <td>
-                                {% if object.primary_ip4 %}
-                                    <a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
-                                    {% if object.primary_ip4.nat_inside %}
-                                        <span>(NAT for {{ object.primary_ip4.nat_inside.address.ip }})</span>
-                                    {% elif object.primary_ip4.nat_outside %}
-                                        <span>(NAT: {{ object.primary_ip4.nat_outside.address.ip }})</span>
-                                    {% endif %}
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
+                              {% if object.primary_ip4 %}
+                                <a href="{% url 'ipam:ipaddress' pk=object.primary_ip4.pk %}">{{ object.primary_ip4.address.ip }}</a>
+                                {% if object.primary_ip4.nat_inside %}
+                                  (NAT for <a href="{{ object.primary_ip4.nat_inside.get_absolute_url }})">{{ object.primary_ip4.nat_inside.address.ip }}</a>)
+                                {% elif object.primary_ip4.nat_outside %}
+                                  (NAT: <a href="{{ object.primary_ip4.nat_outside.get_absolute_url }}">{{ object.primary_ip4.nat_outside.address.ip }}</a>)
                                 {% endif %}
+                              {% else %}
+                                <span class="text-muted">&mdash;</span>
+                              {% endif %}
                             </td>
                         </tr>
                         <tr>
                             <th scope="row">Primary IPv6</th>
                             <td>
-                                {% if object.primary_ip6 %}
-                                    <a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
-                                    {% if object.primary_ip6.nat_inside %}
-                                        <span>(NAT for {{ object.primary_ip6.nat_inside.address.ip }})</span>
-                                    {% elif object.primary_ip6.nat_outside %}
-                                        <span>(NAT: {{ object.primary_ip6.nat_outside.address.ip }})</span>
-                                    {% endif %}
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
+                              {% if object.primary_ip6 %}
+                                <a href="{% url 'ipam:ipaddress' pk=object.primary_ip6.pk %}">{{ object.primary_ip6.address.ip }}</a>
+                                {% if object.primary_ip6.nat_inside %}
+                                  (NAT for <a href="{{ object.primary_ip6.nat_inside.get_absolute_url }}">{{ object.primary_ip6.nat_inside.address.ip }}</a>)
+                                {% elif object.primary_ip6.nat_outside %}
+                                  (NAT: <a href="{{ object.primary_ip6.nat_outside.get_absolute_url }}">{{ object.primary_ip6.nat_outside.address.ip }}</a>)
                                 {% endif %}
+                              {% else %}
+                                <span class="text-muted">&mdash;</span>
+                              {% endif %}
                             </td>
                         </tr>
                         {% if object.cluster %}
@@ -220,12 +226,6 @@
                     </table>
                 </div>
             </div>
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' %}
-            {% include 'inc/panels/comments.html' %}
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-md-6">
             {% if object.powerports.exists and object.poweroutlets.exists %}
                 <div class="card">
                     <h5 class="card-header">
@@ -290,7 +290,7 @@
                 </div>
                 {% if perms.ipam.add_service %}
                 <div class="card-footer text-end noprint">
-                    <a href="{% url 'dcim:device_service_assign' device=object.pk %}" class="btn btn-sm btn-primary">
+                    <a href="{% url 'ipam:service_add' %}?device={{ object.pk }}" class="btn btn-sm btn-primary">
                         <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Assign Service
                     </a>
                 </div>
@@ -298,39 +298,6 @@
             </div>
             {% include 'inc/panels/contacts.html' %}
             {% include 'inc/panels/image_attachments.html' %}
-            <div class="card noprint">
-                <h5 class="card-header">
-                    Related Devices
-                </h5>
-                <div class="card-body">
-                    {% if related_devices %}
-                    <table class="table table-hover">
-                        <tr>
-                            <th>Device</th>
-                            <th>Rack</th>
-                            <th>Type</th>
-                        </tr>
-                        {% for rd in related_devices %}
-                        <tr>
-                            <td>
-                                <a href="{% url 'dcim:device' pk=rd.pk %}">{{ rd }}</a>
-                            </td>
-                            <td>
-                                {% if rd.rack %}
-                                    <a href="{% url 'dcim:rack' pk=rd.rack.pk %}">{{ rd.rack }}</a>
-                                {% else %}
-                                    <span class="text-muted">&mdash;</span>
-                                {% endif %}
-                            </td>
-                            <td>{{ rd.device_type }}</td>
-                        </tr>
-                        {% endfor %}
-                    </table>
-                    {% else %}
-                    <div class="text-muted">None</div>
-                    {% endif %}
-                </div>
-            </div>
             {% plugin_right_page object %}
         </div>
     </div>

+ 8 - 3
netbox/templates/dcim/device/consoleports.html

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceConsolePortTable_config" %}
-    {% 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 %}
@@ -36,6 +42,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

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

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceConsoleServerPortTable_config" %}
-    {% 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 %}
@@ -36,6 +42,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

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

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceDeviceBayTable_config" %}
-    {% 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 %}
@@ -33,6 +39,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

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

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceFrontPortTable_config" %}
-    {% 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 %}
@@ -36,6 +42,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

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

@@ -9,7 +9,15 @@
     <div class="row mb-3 justify-content-between">
       <div class="col col-12 col-lg-4 my-3 my-lg-0 d-flex noprint table-controls">
         <div class="input-group input-group-sm">
-          <input type="text" class="form-control interface-filter" placeholder="Filter" title="Filter text (regular expressions supported)" />
+          <input
+              type="text"
+              name="q"
+              class="form-control"
+              placeholder="Quick search"
+              hx-get="{{ request.full_path }}"
+              hx-target="#object_list"
+              hx-trigger="keyup changed delay:500ms"
+          />
         </div>
       </div>
       <div class="col col-md-3 mb-0 d-flex noprint table-controls">
@@ -34,7 +42,13 @@
         </div>
       </div>
     </div>
-    {% 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 %}
@@ -63,6 +77,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

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

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceInventoryItemTable_config" %}
-    {% 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 %}
@@ -33,6 +39,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

+ 3 - 3
netbox/templates/dcim/device/lldp_neighbors.html

@@ -31,12 +31,12 @@
                 <tbody>
                     {% for iface in interfaces %}
                         <tr id="{{ iface.name }}">
-                            <td class="font-monospace">{{ iface }}</td>
+                            <td>{{ iface }}</td>
                             {% if iface.connected_endpoint.device %}
-                                <td class="configured_device" data="{{ iface.connected_endpoint.device }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
+                                <td class="configured_device" data="{{ iface.connected_endpoint.device.name }}" data-chassis="{{ iface.connected_endpoint.device.virtual_chassis.name }}">
                                     <a href="{% url 'dcim:device' pk=iface.connected_endpoint.device.pk %}">{{ iface.connected_endpoint.device }}</a>
                                 </td>
-                                <td class="configured_interface" data="{{ iface.connected_endpoint }}">
+                                <td class="configured_interface" data="{{ iface.connected_endpoint.name }}">
                                     <span title="{{ iface.connected_endpoint.get_type_display }}">{{ iface.connected_endpoint }}</span>
                                 </td>
                             {% elif iface.connected_endpoint.circuit %}

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

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DevicePowerOutletTable_config" %}
-    {% 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 %}
@@ -36,6 +42,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

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

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DevicePowerPortTable_config" %}
-    {% 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 %}
@@ -36,6 +42,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

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

@@ -6,8 +6,14 @@
 {% block content %}
   <form method="post">
     {% csrf_token %}
-    {% include 'inc/table_controls.html' with table_modal="DeviceRearPortTable_config" %}
-    {% 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 %}
@@ -36,6 +42,5 @@
         {% endif %}
     </div>
   </form>
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
   {% table_config_form table %}
 {% endblock %}

+ 0 - 9
netbox/templates/dcim/device_component.html

@@ -1,9 +0,0 @@
-{% extends 'generic/object.html' %}
-{% load helpers %}
-{% load perms %}
-{% load plugins %}
-
-{% block breadcrumbs %}
-  {{ block.super }}
-  <li class="breadcrumb-item"><a href="{% url object|viewname:'list' %}?device_id={{ object.device.pk }}">{{ object.device }}</a></li>
-{% endblock %}

+ 8 - 1
netbox/templates/dcim/devicebay.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_devicebays' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row">
         <div class="col col-md-6">

+ 13 - 13
netbox/templates/dcim/devicerole.html

@@ -1,11 +1,20 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   <li class="breadcrumb-item"><a href="{% url 'dcim:devicerole_list' %}">Device Roles</a></li>
 {% endblock %}
 
+{% block extra_controls %}
+  {% if perms.dcim.add_device %}
+    <a href="{% url 'dcim:device_add' %}?device_role={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
 {% block content %}
 <div class="row mb-3">
 	<div class="col col-md-6">
@@ -69,21 +78,12 @@
 <div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Devices
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=devices_table %}
+      <h5 class="card-header">Devices</h5>
+      <div class="card-body table-responsive">
+        {% render_table devices_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
       </div>
-      {% if perms.dcim.add_device %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'dcim:device_add' %}?device_role={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device
-          </a>
-        </div>
-      {% endif %}
     </div>
-    {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
     {% plugin_full_width_page object %}
   </div>
 </div>

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

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

+ 8 - 1
netbox/templates/dcim/frontport.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_frontports' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row">
         <div class="col col-md-6">

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

@@ -1,8 +1,15 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 {% load render_table from django_tables2 %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_interfaces' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block extra_controls %}
   {% if perms.dcim.add_interface and not object.is_virtual %}
     <a href="{% url 'dcim:interface_add' %}?device={{ object.device.pk }}&parent={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-success">
@@ -450,7 +457,7 @@
                 <h5 class="card-header">
                     IP Addresses
                 </h5>
-                <div class="card-body">
+                <div class="card-body table-responsive">
                     {% if ipaddress_table.rows %}
                         {% render_table ipaddress_table 'inc/table.html' %}
                     {% else %}

+ 8 - 1
netbox/templates/dcim/inventoryitem.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_inventory' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row mb-3">
         <div class="col col-md-6">

+ 15 - 15
netbox/templates/dcim/location.html

@@ -1,6 +1,7 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -9,6 +10,14 @@
   {% endfor %}
 {% endblock %}
 
+{% block extra_controls %}
+  {% if perms.dcim.add_location %}
+    <a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Child Location
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
 {% block content %}
 <div class="row mb-3">
 	<div class="col col-md-6">
@@ -88,22 +97,13 @@
 <div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Locations
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=child_locations_table %}
-      </div>
-      {% if perms.dcim.add_location %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'dcim:location_add' %}?site={{ object.site.pk }}&parent={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Location
-          </a>
-        </div>
-      {% endif %}
+      <h5 class="card-header">Locations</h5>
+      <div class="card-body table-responsive">
+        {% render_table child_locations_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=child_locations_table.paginator page=child_locations_table.page %}
       </div>
-      {% include 'inc/paginator.html' with paginator=child_locations_table.paginator page=child_locations_table.page %}
-      {% plugin_full_width_page object %}
+    </div>
+    {% plugin_full_width_page object %}
   </div>
 </div>
 {% endblock %}

+ 13 - 13
netbox/templates/dcim/manufacturer.html

@@ -1,6 +1,15 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block extra_controls %}
+  {% if perms.dcim.add_devicetype %}
+    <a href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device Type
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
 
 {% block content %}
 <div class="row mb-3">
@@ -46,21 +55,12 @@
 <div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Device Types
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=devicetypes_table %}
+      <h5 class="card-header">Device Types</h5>
+      <div class="card-body table-responsive">
+        {% render_table devicetypes_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=devicetypes_table.paginator page=devicetypes_table.page %}
       </div>
-      {% if perms.dcim.add_devicetype %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add device type
-          </a>
-        </div>
-      {% endif %}
     </div>
-    {% include 'inc/paginator.html' with paginator=devicetypes_table.paginator page=devicetypes_table.page %}
     {% plugin_full_width_page object %}
   </div>
 </div>

+ 15 - 15
netbox/templates/dcim/platform.html

@@ -1,6 +1,7 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -9,6 +10,14 @@
   {% endif %}
 {% endblock %}
 
+{% block extra_controls %}
+  {% if perms.dcim.add_device %}
+    <a href="{% url 'dcim:device_add' %}?device_role={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
 {% block content %}
 <div class="row mb-3">
 	<div class="col col-md-6">
@@ -74,22 +83,13 @@
 <div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Devices
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=devices_table %}
+      <h5 class="card-header">Devices</h5>
+      <div class="card-body table-responsive">
+        {% render_table devices_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
       </div>
-      {% if perms.dcim.add_device %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'dcim:device_add' %}?device_role={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device
-          </a>
-        </div>
-      {% endif %}
-    </div>
-    {% include 'inc/paginator.html' with paginator=devices_table.paginator page=devices_table.page %}
-        {% plugin_full_width_page object %}
     </div>
+    {% plugin_full_width_page object %}
+  </div>
 </div>
 {% endblock %}

+ 8 - 1
netbox/templates/dcim/poweroutlet.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_poweroutlets' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row mb-3">
         <div class="col col-md-6">

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

@@ -54,7 +54,7 @@
         <form method="post">
             {% csrf_token %}
             <div class="card">
-                <div class="card-body">
+                <div class="card-body table-responsive">
                     {% render_table powerfeed_table 'inc/table.html' %}
                 </div>
                 <div class="card-footer noprint">

+ 8 - 1
netbox/templates/dcim/powerport.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_powerports' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row mb-3">
         <div class="col col-md-6">

+ 13 - 13
netbox/templates/dcim/rackrole.html

@@ -1,6 +1,15 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block extra_controls %}
+  {% if perms.dcim.add_rack %}
+    <a href="{% url 'dcim:rack_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Rack
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
 
 {% block content %}
 <div class="row mb-3">
@@ -45,21 +54,12 @@
 <div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Racks
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=racks_table %}
+      <h5 class="card-header">Racks</h5>
+      <div class="card-body table-responsive">
+        {% render_table racks_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %}
       </div>
-      {% if perms.dcim.add_rack %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'dcim:rack_add' %}?role={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Rack
-          </a>
-        </div>
-      {% endif %}
     </div>
-    {% include 'inc/paginator.html' with paginator=racks_table.paginator page=racks_table.page %}
     {% plugin_full_width_page object %}
   </div>
 </div>

+ 8 - 1
netbox/templates/dcim/rearport.html

@@ -1,7 +1,14 @@
-{% extends 'dcim/device_component.html' %}
+{% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
 
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:device_rearports' pk=object.device.pk %}">{{ object.device }}</a>
+  </li>
+{% endblock %}
+
 {% block content %}
     <div class="row">
         <div class="col col-md-6">

+ 18 - 18
netbox/templates/dcim/region.html

@@ -1,6 +1,7 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -9,6 +10,14 @@
   {% endfor %}
 {% endblock %}
 
+{% block extra_controls %}
+  {% if perms.dcim.add_site %}
+    <a href="{% url 'dcim:site_add' %}?region={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Site
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
 {% block content %}
 <div class="row mb-3">
 	<div class="col col-md-6">
@@ -55,8 +64,8 @@
       <h5 class="card-header">
         Child Regions
       </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=child_regions_table %}
+      <div class="card-body table-responsive">
+        {% render_table child_regions_table 'inc/table.html' %}
       </div>
       {% if perms.dcim.add_region %}
         <div class="card-footer text-end noprint">
@@ -69,25 +78,16 @@
     {% plugin_right_page object %}
 	</div>
 </div>
-<div class="row">
+<div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Sites
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=sites_table %}
+      <h5 class="card-header">Sites</h5>
+      <div class="card-body table-responsive">
+        {% render_table sites_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
       </div>
-      {% if perms.dcim.add_site %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'dcim:site_add' %}?region={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Site
-          </a>
-        </div>
-      {% endif %}
-      </div>
-      {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
-      {% plugin_full_width_page object %}
+    </div>
+    {% plugin_full_width_page object %}
   </div>
 </div>
 {% endblock %}

+ 15 - 15
netbox/templates/dcim/sitegroup.html

@@ -1,6 +1,7 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -9,6 +10,14 @@
   {% endfor %}
 {% endblock %}
 
+{% block extra_controls %}
+  {% if perms.dcim.add_site %}
+    <a href="{% url 'dcim:site_add' %}?group={{ object.pk }}" class="btn btn-sm btn-primary">
+      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Site
+    </a>
+  {% endif %}
+{% endblock extra_controls %}
+
 {% block content %}
 <div class="row mb-3">
 	<div class="col col-md-6">
@@ -55,8 +64,8 @@
       <h5 class="card-header">
         Child Groups
       </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=child_groups_table %}
+      <div class="card-body table-responsive">
+        {% render_table child_groups_table 'inc/table.html' %}
       </div>
       {% if perms.dcim.add_sitegroup %}
         <div class="card-footer text-end noprint">
@@ -72,21 +81,12 @@
 <div class="row mb-3">
 	<div class="col col-md-12">
     <div class="card">
-      <h5 class="card-header">
-        Sites
-      </h5>
-      <div class="card-body">
-        {% include 'inc/table.html' with table=sites_table %}
+      <h5 class="card-header">Sites</h5>
+      <div class="card-body table-responsive">
+        {% render_table sites_table 'inc/table.html' %}
+        {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
       </div>
-      {% if perms.dcim.add_site %}
-        <div class="card-footer text-end noprint">
-          <a href="{% url 'dcim:site_add' %}?group={{ object.pk }}" class="btn btn-sm btn-primary">
-            <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Site
-          </a>
-        </div>
-      {% endif %}
     </div>
-    {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
     {% plugin_full_width_page object %}
   </div>
 </div>

+ 11 - 3
netbox/templates/extras/object_changelog.html

@@ -2,9 +2,17 @@
 {% load render_table from django_tables2 %}
 
 {% block content %}
-    {% render_table table 'inc/table.html' %}
-    {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
-    <div class="text-muted">
+  <div class="row mb-3">
+    <div class="col col-md-12">
+      <div class="card">
+        <div class="card-body table-responsive">
+          {% render_table table 'inc/table.html' %}
+          {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+        </div>
+      </div>
+      <div class="text-muted">
         Change log retention: {% if settings.CHANGELOG_RETENTION %}{{ settings.CHANGELOG_RETENTION }} days{% else %}Indefinite{% endif %}
+      </div>
     </div>
+  </div>
 {% endblock %}

+ 6 - 2
netbox/templates/extras/object_journal.html

@@ -24,6 +24,10 @@
       </div>
     </form>
   {% endif %}
-  {% render_table table 'inc/table.html' %}
-  {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+  <div class="card">
+    <div class="card-body table-responsive">
+      {% render_table table 'inc/table.html' %}
+      {% include 'inc/paginator.html' with paginator=table.paginator page=table.page %}
+    </div>
+  </div>
 {% endblock %}

+ 11 - 4
netbox/templates/extras/tag.html

@@ -1,6 +1,7 @@
 {% extends 'generic/object.html' %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block content %}
   <div class="row">
@@ -63,12 +64,18 @@
           </table>
         </div>
       </div>
+    </div>
   </div>
   <div class="row">
-    <div class="col">
-      {% include 'inc/panel_table.html' with table=taggeditem_table heading='Tagged Objects' %}
-      {% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=items_table.page %}
+    <div class="col col-md-12">
+      <div class="card">
+        <h5 class="card-header">Tagged Objects</h5>
+        <div class="card-body table-responsive">
+          {% render_table taggeditem_table 'inc/table.html' %}
+          {% include 'inc/paginator.html' with paginator=taggeditem_table.paginator page=taggeditem_table.page %}
+        </div>
+      </div>
+      {% plugin_full_width_page object %}
     </div>
   </div>
-  {% plugin_full_width_page object %}
 {% endblock %}

+ 66 - 33
netbox/templates/generic/object_bulk_add_component.html

@@ -1,40 +1,73 @@
 {% extends 'base/layout.html' %}
+{% load helpers %}
 {% load form_helpers %}
+{% load render_table from django_tables2 %}
 
 {% block title %}Add {{ model_name|title }}{% endblock %}
 
-{% block content %}
-<p>{{ table.rows|length }} {{ parent_model_name }} selected</p>
-<form action="." method="post" class="form form-horizontal">
-    {% csrf_token %}
-    {% if request.POST.return_url %}
-        <input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
-    {% endif %}
-    {% for field in form.hidden_fields %}
-        {{ field }}
-    {% endfor %}
-    <div class="row">
-        <div class="col col-md-7">
-            <div class="card">
-                {% include 'inc/table.html' %}
-            </div>
-        </div>
-        <div class="col col-md-5">
-            <div class="card">
-                <h5 class="card-header">{{ model_name|title }} to Add</h5>
-                <div class="card-body">
-                    {% for field in form.visible_fields %}
-                        {% render_field field %}
-                    {% endfor %}
-                </div>
+{% block tabs %}
+  <ul class="nav nav-tabs px-3">
+    <li class="nav-item" role="presentation">
+      <button class="nav-link active" id="component-form-tab" data-bs-toggle="tab" data-bs-target="#component-form" type="button" role="tab" aria-controls="component-form" aria-selected="true">
+        Bulk Creation
+      </button>
+    </li>
+    <li class="nav-item" role="presentation">
+      <button class="nav-link" id="object-list-tab" data-bs-toggle="tab" data-bs-target="#object-list" type="button" role="tab" aria-controls="object-list" aria-selected="false">
+        Selected Objects
+        {% badge table.rows|length %}
+      </button>
+    </li>
+  </ul>
+{% endblock %}
+
+{% block content-wrapper %}
+  <div class="tab-content">
+    {% block content %}
+
+      {# Component creation form #}
+      <div class="tab-pane show active" id="component-form" role="tabpanel" aria-labelledby="component-form-tab">
+        <form action="" method="post" class="form form-horizontal">
+
+          {% csrf_token %}
+          {% if request.POST.return_url %}
+            <input type="hidden" name="return_url" value="{{ request.POST.return_url }}" />
+          {% endif %}
+          {% for field in form.hidden_fields %}
+            {{ field }}
+          {% endfor %}
+
+          <div class="row">
+            <div class="col col-md-12 col-lg-10 offset-lg-1">
+              <div class="card">
+                  <h5 class="card-header">{{ model_name|title }} to Add</h5>
+                  <div class="card-body">
+                      {% for field in form.visible_fields %}
+                          {% render_field field %}
+                      {% endfor %}
+                  </div>
+              </div>
+              <div class="form-group text-end">
+                  <div class="col col-md-12">
+                      <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
+                      <button type="submit" name="_create" class="btn btn-primary">Create</button>
+                  </div>
+              </div>
             </div>
-		    <div class="form-group text-end">
-                <div class="col col-md-12">
-                    <a href="{{ return_url }}" class="btn btn-outline-danger">Cancel</a>
-                    <button type="submit" name="_create" class="btn btn-primary">Create</button>
-                </div>
-		    </div>
+          </div>
+
+        </form>
+      </div>
+
+      {# Selected objects list #}
+      <div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
+        <div class="card">
+          <div class="card-body table-responsive">
+            {% render_table table 'inc/table.html' %}
+          </div>
         </div>
-    </div>
-</form>
-{% endblock content %}
+      </div>
+
+    {% endblock %}
+  </div>
+{% endblock %}

+ 4 - 1
netbox/templates/generic/object_bulk_delete.html

@@ -1,5 +1,6 @@
 {% extends 'base/layout.html' %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 
 {% block title %}Delete {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
 
@@ -15,7 +16,9 @@
         </div>
     </div>
     <div class="container-xl px-0">
-      {% include 'inc/table.html' %}
+      <div class="table-responsive">
+        {% render_table table 'inc/table.html' %}
+      </div>
       <div class="row mt-3">
         <form action="" method="post">
             {% csrf_token %}

+ 15 - 7
netbox/templates/generic/object_bulk_edit.html

@@ -1,6 +1,7 @@
 {% extends 'base/layout.html' %}
 {% load helpers %}
 {% load form_helpers %}
+{% load render_table from django_tables2 %}
 
 {% block title %}Editing {{ table.rows|length }} {{ obj_type_plural|bettertitle }}{% endblock %}
 
@@ -38,14 +39,17 @@
 
           <div class="row">
             <div class="col col-md-12 col-lg-10 offset-lg-1">
-              {% for field in form.visible_fields %}
-                  {% if field.name in form.nullable_fields %}
+              <div class="card">
+                <div class="card-body">
+                  {% for field in form.visible_fields %}
+                    {% if field.name in form.nullable_fields %}
                       {% render_field field bulk_nullable=True %}
-                  {% else %}
+                    {% else %}
                       {% render_field field %}
-                  {% endif %}
-              {% endfor %}
-
+                    {% endif %}
+                  {% endfor %}
+                </div>
+              </div>
 
               <div class="text-end">
                 <a href="{{ return_url }}" class="btn btn-sm btn-outline-danger">Cancel</a>
@@ -59,7 +63,11 @@
 
       {# Selected objects list #}
       <div class="tab-pane" id="object-list" role="tabpanel" aria-labelledby="object-list-tab">
-        {% include 'inc/table.html' %}
+        <div class="card">
+          <div class="card-body table-responsive">
+            {% render_table table 'inc/table.html' %}
+          </div>
+        </div>
       </div>
 
     {% endblock %}

+ 4 - 1
netbox/templates/generic/object_bulk_remove.html

@@ -1,5 +1,6 @@
 {% extends 'base/layout.html' %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 
 {% block title %}Remove {{ table.rows|length }} {{ obj_type_plural|bettertitle }}?{% endblock %}
 
@@ -13,7 +14,9 @@
   </div>
 </div>
 <div class="container-xl px-0">
-  {% include 'inc/table.html' %}
+  <div class="table-responsive">
+    {% render_table table 'inc/table.html' %}
+  </div>
   <form action="." method="post" class="form">
     {% csrf_token %}
     {% for field in form.hidden_fields %}

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

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

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

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

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

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

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

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

+ 4 - 4
netbox/templates/inc/panel_table.html

@@ -6,11 +6,11 @@
         {{ heading }}
     </h5>
     {% endif %}
-    <div class="card-body">
-    {% if table.rows %}
+    <div class="card-body table-responsive">
+      {% if table.rows %}
         {% render_table table 'inc/table.html' %}
-    {% else %}
+      {% else %}
         <div class="text-muted">None</div>
-    {% endif %}
+      {% endif %}
     </div>
 </div>

+ 37 - 39
netbox/templates/inc/table.html

@@ -1,43 +1,41 @@
 {% load django_tables2 %}
 
-<div class="table-responsive">
-  <table{% if table.attrs %} {{ table.attrs.as_html }}{% endif %}>
-    {% if table.show_header %}
-      <thead>
+<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="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ 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>
-          {% for column in table.columns %}
-            {% if column.orderable %}
-              <th {{ column.attrs.th.as_html }}><a href="{% querystring table.prefixed_order_by_field=column.order_by_alias.next %}">{{ column.header }}</a></th>
-            {% else %}
-              <th {{ column.attrs.th.as_html }}>{{ column.header }}</th>
-            {% endif %}
-          {% endfor %}
+          <td colspan="{{ table.columns|length }}" class="text-center text-muted">&mdash; {{ table.empty_text }} &mdash;</td>
         </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>
+      {% endif %}
+    {% endfor %}
+  </tbody>
+  {% if table.has_footer %}
+    <tfoot>
+      <tr>
+        {% for column in table.columns %}
+          <td>{{ column.footer }}</td>
+        {% endfor %}
+      </tr>
+    </tfoot>
+  {% endif %}
+</table>

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

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

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

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

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

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

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

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

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

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

+ 4 - 3
netbox/templates/ipam/asn.html

@@ -2,6 +2,7 @@
 {% load buttons %}
 {% load helpers %}
 {% load plugins %}
+{% load render_table from django_tables2 %}
 
 {% block breadcrumbs %}
   {{ block.super }}
@@ -67,11 +68,11 @@
     <div class="col col-md-12">
       <div class="card">
         <h5 class="card-header">Sites</h5>
-        <div class="card-body">
-          {% include 'inc/table.html' with table=sites_table %}
+        <div class="card-body table-responsive">
+          {% render_table sites_table 'inc/table.html' %}
+          {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
         </div>
       </div>
-      {% include 'inc/paginator.html' with paginator=sites_table.paginator page=sites_table.page %}
       {% plugin_full_width_page object %}
     </div>
   </div>

+ 2 - 2
netbox/templates/ipam/fhrpgroup.html

@@ -64,7 +64,7 @@
     <div class="col col-md-12">
       <div class="card">
         <h5 class="card-header">Virtual IP Addresses</h5>
-        <div class="card-body">
+        <div class="card-body table-responsive">
           {% if ipaddress_table.rows %}
             {% render_table ipaddress_table 'inc/table.html' %}
           {% else %}
@@ -81,7 +81,7 @@
       </div>
       <div class="card">
         <h5 class="card-header">Members</h5>
-        <div class="card-body">
+        <div class="card-body table-responsive">
           {% if members_table.rows %}
             {% render_table members_table 'inc/table.html' %}
           {% else %}

+ 0 - 0
netbox/templates/ipam/inc/ipadress_edit_header.html → netbox/templates/ipam/inc/ipaddress_edit_header.html


+ 8 - 5
netbox/templates/ipam/inc/toggle_available.html

@@ -1,12 +1,15 @@
 {% load helpers %}
 
-{% if show_available is not None %}
+{% if show_assigned or show_available is not None %}
   <div class="btn-group" role="group">
-    <a href="{{ request.path }}{% querystring request show_available='true' %}" class="btn btn-sm btn-outline-primary{% if show_available %} active disabled{% endif %}">
-      <i class="mdi mdi-eye"></i> Show Available
+    <a href="{{ request.path }}{% querystring request show_assigned='true' show_available='false' %}" class="btn btn-sm {% if show_assigned and not show_available %}btn-secondary active{% else %}btn-outline-secondary{% endif %}">
+      Show Assigned
     </a>
-    <a href="{{ request.path }}{% querystring request show_available='false' %}" class="btn btn-sm btn-outline-primary{% if not show_available %} active disabled{% endif %}">
-      <i class="mdi mdi-eye-off"></i> Hide Available
+    <a href="{{ request.path }}{% querystring request show_assigned='false' show_available='true' %}" class="btn btn-sm {% if show_available and not show_assigned %}btn-secondary active{% else %}btn-outline-secondary{% endif %}">
+      Show Available
+    </a>
+    <a href="{{ request.path }}{% querystring request show_assigned='true' show_available='true' %}" class="btn btn-sm {% if show_available and show_assigned %}btn-secondary active{% else %}btn-outline-secondary{% endif %}">
+      Show All
     </a>
   </div>
 {% endif %}

+ 4 - 4
netbox/templates/ipam/ipaddress.html

@@ -87,7 +87,7 @@
                         <th scope="row">NAT (inside)</th>
                         <td>
                             {% if object.nat_inside %}
-                                <a href="{% url 'ipam:ipaddress' pk=object.nat_inside.pk %}">{{ object.nat_inside }}</a>
+                                <a href="{{ object.nat_inside.get_absolute_url }}">{{ object.nat_inside }}</a>
                                 {% if object.nat_inside.assigned_object %}
                                     (<a href="{{ object.nat_inside.assigned_object.parent_object.get_absolute_url }}">{{ object.nat_inside.assigned_object.parent_object }}</a>)
                                 {% endif %}
@@ -100,7 +100,7 @@
                         <th scope="row">NAT (outside)</th>
                         <td>
                             {% if object.nat_outside %}
-                                <a href="{% url 'ipam:ipaddress' pk=object.nat_outside.pk %}">{{ object.nat_outside }}</a>
+                                <a href="{{ object.nat_outside.get_absolute_url }}">{{ object.nat_outside }}</a>
                             {% else %}
                                 <span class="text-muted">None</span>
                             {% endif %}
@@ -133,8 +133,8 @@
                       </div>
                     {% endif %}
                 </h5>
-                <div class="card-body">
-                {% render_table duplicate_ips_table 'inc/table.html' %}
+                <div class="card-body table-responsive">
+                  {% render_table duplicate_ips_table 'inc/table.html' %}
                 </div>
             </div>
         {% endif %}

+ 5 - 2
netbox/templates/ipam/ipaddress_assign.html

@@ -2,11 +2,12 @@
 {% load static %}
 {% load form_helpers %}
 {% load helpers %}
+{% load render_table from django_tables2 %}
 
 {% block title %}Assign an IP Address{% endblock title %}
 
 {% block tabs %}
-  {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='assign' %}
+  {% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='assign' %}
 {% endblock %}
 
 {% block form %}
@@ -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 %}

+ 1 - 1
netbox/templates/ipam/ipaddress_bulk_add.html

@@ -5,7 +5,7 @@
 {% block title %}Bulk Add IP Addresses{% endblock %}
 
 {% block tabs %}
-  {% include 'ipam/inc/ipadress_edit_header.html' with active_tab='bulk_add' %}
+  {% include 'ipam/inc/ipaddress_edit_header.html' with active_tab='bulk_add' %}
 {% endblock %}
 
 {% block form %}

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