Pārlūkot izejas kodu

Merge branch 'main' into feature

Jeremy Stretch 5 dienas atpakaļ
vecāks
revīzija
b62c5e1ac4
100 mainītis faili ar 3589 papildinājumiem un 1829 dzēšanām
  1. 1 1
      .github/ISSUE_TEMPLATE/01-feature_request.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/02-bug_report.yaml
  3. 1 1
      .github/ISSUE_TEMPLATE/03-performance.yaml
  4. 4 4
      .github/workflows/ci.yml
  5. 1 1
      .github/workflows/claude-code-review.yml
  6. 1 1
      .github/workflows/claude.yml
  7. 1 1
      .github/workflows/close-incomplete-issues.yml
  8. 1 1
      .github/workflows/close-stale-issues.yml
  9. 3 3
      .github/workflows/codeql.yml
  10. 1 1
      .github/workflows/lock-threads.yml
  11. 2 2
      .github/workflows/update-translation-strings.yml
  12. 4 1
      CLAUDE.md
  13. 8 0
      contrib/generated_schema.json
  14. 33 25
      contrib/openapi.json
  15. 8 10
      docs/plugins/development/search.md
  16. 19 0
      docs/release-notes/version-4.5.md
  17. 33 4
      netbox/circuits/forms/model_forms.py
  18. 4 2
      netbox/circuits/tables/circuits.py
  19. 32 7
      netbox/circuits/tests/test_tables.py
  20. 0 0
      netbox/circuits/ui/__init__.py
  21. 139 0
      netbox/circuits/ui/panels.py
  22. 194 1
      netbox/circuits/views.py
  23. 0 0
      netbox/core/ui/__init__.py
  24. 91 0
      netbox/core/ui/panels.py
  25. 84 0
      netbox/core/views.py
  26. 28 2
      netbox/dcim/choices.py
  27. 29 3
      netbox/dcim/forms/model_forms.py
  28. 13 31
      netbox/dcim/models/device_component_templates.py
  29. 110 1
      netbox/dcim/tests/test_forms.py
  30. 44 0
      netbox/dcim/tests/test_models.py
  31. 351 1
      netbox/dcim/ui/panels.py
  32. 321 18
      netbox/dcim/views.py
  33. 48 2
      netbox/extras/api/customfields.py
  34. 1 1
      netbox/extras/models/customfields.py
  35. 2 1
      netbox/extras/tables/tables.py
  36. 24 0
      netbox/extras/tests/test_tables.py
  37. 441 1
      netbox/extras/ui/panels.py
  38. 173 1
      netbox/extras/views.py
  39. 35 9
      netbox/ipam/models/ip.py
  40. 35 0
      netbox/ipam/tests/test_models.py
  41. 24 0
      netbox/ipam/ui/attrs.py
  42. 221 2
      netbox/ipam/ui/panels.py
  43. 306 44
      netbox/ipam/views.py
  44. 24 1
      netbox/netbox/api/serializers/features.py
  45. 39 3
      netbox/netbox/forms/model_forms.py
  46. 6 4
      netbox/netbox/graphql/scalars.py
  47. 9 2
      netbox/netbox/graphql/schema.py
  48. 1 1
      netbox/netbox/tables/tables.py
  49. 11 0
      netbox/netbox/tests/test_tables.py
  50. 215 0
      netbox/netbox/tests/test_ui.py
  51. 106 10
      netbox/netbox/ui/attrs.py
  52. 21 0
      netbox/netbox/ui/panels.py
  53. 0 0
      netbox/project-static/dist/netbox.css
  54. 3 1
      netbox/project-static/package.json
  55. 17 13
      netbox/project-static/styles/custom/_code.scss
  56. 19 9
      netbox/project-static/yarn.lock
  57. 2 2
      netbox/release.yaml
  58. 0 98
      netbox/templates/circuits/circuit.html
  59. 2 0
      netbox/templates/circuits/circuit/attrs/commit_rate.html
  60. 0 30
      netbox/templates/circuits/circuit_terminations_swap.html
  61. 0 41
      netbox/templates/circuits/circuitgroup.html
  62. 0 44
      netbox/templates/circuits/circuitgroupassignment.html
  63. 0 42
      netbox/templates/circuits/circuittermination.html
  64. 0 46
      netbox/templates/circuits/circuittype.html
  65. 16 14
      netbox/templates/circuits/inc/circuit_termination_fields.html
  66. 69 0
      netbox/templates/circuits/panels/circuit_circuit_termination.html
  67. 16 0
      netbox/templates/circuits/panels/circuit_termination.html
  68. 0 49
      netbox/templates/circuits/provider.html
  69. 0 48
      netbox/templates/circuits/provideraccount.html
  70. 0 63
      netbox/templates/circuits/providernetwork.html
  71. 0 90
      netbox/templates/circuits/virtualcircuit.html
  72. 0 66
      netbox/templates/circuits/virtualcircuittermination.html
  73. 0 46
      netbox/templates/circuits/virtualcircuittype.html
  74. 0 22
      netbox/templates/core/configrevision.html
  75. 0 55
      netbox/templates/core/datafile.html
  76. 1 0
      netbox/templates/core/datafile/attrs/size.html
  77. 0 103
      netbox/templates/core/datasource.html
  78. 1 0
      netbox/templates/core/datasource/attrs/ignore_rules.html
  79. 1 0
      netbox/templates/core/datasource/attrs/source_url.html
  80. 0 77
      netbox/templates/core/job.html
  81. 1 0
      netbox/templates/core/job/attrs/object_type.html
  82. 3 0
      netbox/templates/core/job/attrs/scheduled.html
  83. 0 11
      netbox/templates/core/job/log.html
  84. 0 180
      netbox/templates/core/objectchange.html
  85. 2 0
      netbox/templates/core/objectchange/attrs/changed_object.html
  86. 1 0
      netbox/templates/core/objectchange/attrs/request_id.html
  87. 1 0
      netbox/templates/core/objectchange/attrs/user.html
  88. 11 0
      netbox/templates/core/panels/configrevision_comment.html
  89. 5 0
      netbox/templates/core/panels/configrevision_data.html
  90. 8 0
      netbox/templates/core/panels/datafile_content.html
  91. 26 0
      netbox/templates/core/panels/datasource_backend.html
  92. 31 0
      netbox/templates/core/panels/objectchange_difference.html
  93. 18 0
      netbox/templates/core/panels/objectchange_postchange.html
  94. 20 0
      netbox/templates/core/panels/objectchange_prechange.html
  95. 11 0
      netbox/templates/core/panels/objectchange_related.html
  96. 0 90
      netbox/templates/dcim/cable.html
  97. 0 87
      netbox/templates/dcim/consoleport.html
  98. 0 87
      netbox/templates/dcim/consoleserverport.html
  99. 0 62
      netbox/templates/dcim/devicebay.html
  100. 0 148
      netbox/templates/dcim/frontport.html

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

@@ -15,7 +15,7 @@ body:
     attributes:
     attributes:
       label: NetBox version
       label: NetBox version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.5
+      placeholder: v4.5.6
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

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

@@ -27,7 +27,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.5
+      placeholder: v4.5.6
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 1 - 1
.github/ISSUE_TEMPLATE/03-performance.yaml

@@ -8,7 +8,7 @@ body:
     attributes:
     attributes:
       label: NetBox Version
       label: NetBox Version
       description: What version of NetBox are you currently running?
       description: What version of NetBox are you currently running?
-      placeholder: v4.5.5
+      placeholder: v4.5.6
     validations:
     validations:
       required: true
       required: true
   - type: dropdown
   - type: dropdown

+ 4 - 4
.github/workflows/ci.yml

@@ -53,7 +53,7 @@ jobs:
 
 
     steps:
     steps:
     - name: Check out repo
     - name: Check out repo
-      uses: actions/checkout@v4
+      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
 
 
     - name: Check Python linting & PEP8 compliance
     - name: Check Python linting & PEP8 compliance
       uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
       uses: astral-sh/ruff-action@4919ec5cf1f49eff0871dbcea0da843445b837e6 # v3.6.1
@@ -63,12 +63,12 @@ jobs:
         src: "netbox/"
         src: "netbox/"
 
 
     - name: Set up Python ${{ matrix.python-version }}
     - name: Set up Python ${{ matrix.python-version }}
-      uses: actions/setup-python@v5
+      uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
       with:
       with:
         python-version: ${{ matrix.python-version }}
         python-version: ${{ matrix.python-version }}
 
 
     - name: Use Node.js ${{ matrix.node-version }}
     - name: Use Node.js ${{ matrix.node-version }}
-      uses: actions/setup-node@v4
+      uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
     
     
@@ -76,7 +76,7 @@ jobs:
       run: npm install -g yarn
       run: npm install -g yarn
     
     
     - name: Setup Node.js with Yarn Caching
     - name: Setup Node.js with Yarn Caching
-      uses: actions/setup-node@v4
+      uses: actions/setup-node@53b83947a5a98c8d113130e565377fae1a50d02f # v6.3.0
       with:
       with:
         node-version: ${{ matrix.node-version }}
         node-version: ${{ matrix.node-version }}
         cache: yarn
         cache: yarn

+ 1 - 1
.github/workflows/claude-code-review.yml

@@ -21,7 +21,7 @@ jobs:
 
 
     steps:
     steps:
       - name: Checkout repository
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
         with:
           fetch-depth: 1
           fetch-depth: 1
 
 

+ 1 - 1
.github/workflows/claude.yml

@@ -26,7 +26,7 @@ jobs:
       actions: read # Required for Claude to read CI results on PRs
       actions: read # Required for Claude to read CI results on PRs
     steps:
     steps:
       - name: Checkout repository
       - name: Checkout repository
-        uses: actions/checkout@v4
+        uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
         with:
         with:
           fetch-depth: 1
           fetch-depth: 1
 
 

+ 1 - 1
.github/workflows/close-incomplete-issues.yml

@@ -15,7 +15,7 @@ jobs:
     if: github.repository == 'netbox-community/netbox'
     if: github.repository == 'netbox-community/netbox'
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: actions/stale@v9
+      - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
         with:
         with:
           close-issue-message: >
           close-issue-message: >
             This issue is being closed as no further information has been provided. If
             This issue is being closed as no further information has been provided. If

+ 1 - 1
.github/workflows/close-stale-issues.yml

@@ -16,7 +16,7 @@ jobs:
     if: github.repository == 'netbox-community/netbox'
     if: github.repository == 'netbox-community/netbox'
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: actions/stale@v9
+      - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
         with:
         with:
           # General parameters
           # General parameters
           operations-per-run: 200
           operations-per-run: 200

+ 3 - 3
.github/workflows/codeql.yml

@@ -27,16 +27,16 @@ jobs:
           build-mode: none
           build-mode: none
     steps:
     steps:
     - name: Checkout repository
     - name: Checkout repository
-      uses: actions/checkout@v4
+      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
 
 
     - name: Initialize CodeQL
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@v4
+      uses: github/codeql-action/init@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
       with:
       with:
         languages: ${{ matrix.language }}
         languages: ${{ matrix.language }}
         build-mode: ${{ matrix.build-mode }}
         build-mode: ${{ matrix.build-mode }}
         config-file: .github/codeql/codeql-config.yml
         config-file: .github/codeql/codeql-config.yml
 
 
     - name: Perform CodeQL Analysis
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v4
+      uses: github/codeql-action/analyze@b1bff81932f5cdfc8695c7752dcee935dcd061c8 # v4.33.0
       with:
       with:
         category: "/language:${{matrix.language}}"
         category: "/language:${{matrix.language}}"

+ 1 - 1
.github/workflows/lock-threads.yml

@@ -19,6 +19,6 @@ jobs:
     if: github.repository == 'netbox-community/netbox'
     if: github.repository == 'netbox-community/netbox'
     runs-on: ubuntu-latest
     runs-on: ubuntu-latest
     steps:
     steps:
-      - uses: dessant/lock-threads@v6.0.0
+      - uses: dessant/lock-threads@7266a7ce5c1df01b1c6db85bf8cd86c737dadbe7 # v6.0.0
         with:
         with:
           discussion-inactive-days: 180
           discussion-inactive-days: 180

+ 2 - 2
.github/workflows/update-translation-strings.yml

@@ -27,12 +27,12 @@ jobs:
         private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
         private-key: ${{ secrets.HOUSEKEEPING_SECRET_KEY }}
 
 
     - name: Check out repo
     - name: Check out repo
-      uses: actions/checkout@v4
+      uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
       with:
       with:
           token: ${{ steps.app-token.outputs.token }}
           token: ${{ steps.app-token.outputs.token }}
 
 
     - name: Set up Python
     - name: Set up Python
-      uses: actions/setup-python@v5
+      uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
       with:
       with:
         python-version: 3.12
         python-version: 3.12
 
 

+ 4 - 1
CLAUDE.md

@@ -54,7 +54,8 @@ python manage.py nbshell   # NetBox-enhanced shell
 
 
 ## Architecture Conventions
 ## Architecture Conventions
 - **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
 - **Apps**: Each Django app owns its models, views, API serializers, filtersets, forms, and tests.
-- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`.
+- **Views**: Use `register_model_view()` to register model views by action (e.g. "add", "list", etc.). List views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the table class so that only relevant fields are prefetched.
+- **REST API**: DRF serializers live in `<app>/api/serializers.py`; viewsets in `<app>/api/views.py`; URLs auto-registered in `<app>/api/urls.py`. REST API views typically don't need to add `select_related()` or `prefetch_related()` on their querysets: Prefetching is handled dynamically by the serializer so that only relevant fields are prefetched.
 - **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
 - **GraphQL**: Strawberry types in `<app>/graphql/types.py`.
 - **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
 - **Filtersets**: `<app>/filtersets.py` — used for both UI filtering and API `?filter=` params.
 - **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
 - **Tables**: `django-tables2` used for all object list views (`<app>/tables.py`).
@@ -68,6 +69,8 @@ python manage.py nbshell   # NetBox-enhanced shell
 - API serializers must include a `url` field (absolute URL of the object).
 - API serializers must include a `url` field (absolute URL of the object).
 - Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
 - Use `FeatureQuery` for generic relations (config contexts, custom fields, tags, etc.).
 - Avoid adding new dependencies without strong justification.
 - Avoid adding new dependencies without strong justification.
+- Avoid running `ruff format` on existing files, as this tends to introduce unnecessary style changes.
+- Don't craft Django database migrations manually: Prompt the user to run `manage.py makemigrations` instead.
 
 
 ## Branch & PR Conventions
 ## Branch & PR Conventions
 - Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)
 - Branch naming: `<issue-number>-short-description` (e.g., `1234-device-typerror`)

+ 8 - 0
contrib/generated_schema.json

@@ -416,9 +416,13 @@
                         "800gbase-dr8",
                         "800gbase-dr8",
                         "800gbase-sr8",
                         "800gbase-sr8",
                         "800gbase-vr8",
                         "800gbase-vr8",
+                        "1.6tbase-cr8",
+                        "1.6tbase-dr8",
+                        "1.6tbase-dr8-2",
                         "100base-x-sfp",
                         "100base-x-sfp",
                         "1000base-x-gbic",
                         "1000base-x-gbic",
                         "1000base-x-sfp",
                         "1000base-x-sfp",
+                        "2.5gbase-x-sfp",
                         "10gbase-x-sfpp",
                         "10gbase-x-sfpp",
                         "10gbase-x-xenpak",
                         "10gbase-x-xenpak",
                         "10gbase-x-xfp",
                         "10gbase-x-xfp",
@@ -448,6 +452,9 @@
                         "400gbase-x-osfp-rhs",
                         "400gbase-x-osfp-rhs",
                         "800gbase-x-osfp",
                         "800gbase-x-osfp",
                         "800gbase-x-qsfpdd",
                         "800gbase-x-qsfpdd",
+                        "1.6tbase-x-osfp1600",
+                        "1.6tbase-x-osfp1600-rhs",
+                        "1.6tbase-x-qsfpdd1600",
                         "1000base-kx",
                         "1000base-kx",
                         "2.5gbase-kx",
                         "2.5gbase-kx",
                         "5gbase-kr",
                         "5gbase-kr",
@@ -459,6 +466,7 @@
                         "100gbase-kp4",
                         "100gbase-kp4",
                         "100gbase-kr2",
                         "100gbase-kr2",
                         "100gbase-kr4",
                         "100gbase-kr4",
+                        "1.6tbase-kr8",
                         "ieee802.11a",
                         "ieee802.11a",
                         "ieee802.11g",
                         "ieee802.11g",
                         "ieee802.11n",
                         "ieee802.11n",

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 33 - 25
contrib/openapi.json


+ 8 - 10
docs/plugins/development/search.md

@@ -1,12 +1,14 @@
 # Search
 # Search
 
 
-Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models (see the example below).
+Plugins can define and register their own models to extend NetBox's core search functionality. Typically, a plugin will include a file named `search.py`, which holds all search indexes for its models.
 
 
-```python
+```python title="search.py"
 # search.py
 # search.py
-from netbox.search import SearchIndex
+from netbox.search import SearchIndex, register_search
+
 from .models import MyModel
 from .models import MyModel
 
 
+@register_search
 class MyModelIndex(SearchIndex):
 class MyModelIndex(SearchIndex):
     model = MyModel
     model = MyModel
     fields = (
     fields = (
@@ -17,15 +19,11 @@ class MyModelIndex(SearchIndex):
     display_attrs = ('site', 'device', 'status', 'description')
     display_attrs = ('site', 'device', 'status', 'description')
 ```
 ```
 
 
-Fields listed in `display_attrs` will not be cached for search, but will be displayed alongside the object when it appears in global search results. This is helpful for conveying to the user additional information about an object.
-
-To register one or more indexes with NetBox, define a list named `indexes` at the end of this file:
+Decorate each `SearchIndex` subclass with `@register_search` to register it with NetBox. When using the default `search.py` module, no additional `indexes = [...]` list is required.
 
 
-```python
-indexes = [MyModelIndex]
-```
+Fields listed in `display_attrs` are not cached for matching, but they are displayed alongside the object in global search results to provide additional context.
 
 
 !!! tip
 !!! tip
-    The path to the list of search indexes can be modified by setting `search_indexes` in the PluginConfig instance.
+    The legacy `indexes = [...]` list remains supported via `PluginConfig.search_indexes` for backward compatibility and custom loading patterns.
 
 
 ::: netbox.search.SearchIndex
 ::: netbox.search.SearchIndex

+ 19 - 0
docs/release-notes/version-4.5.md

@@ -1,5 +1,24 @@
 # NetBox v4.5
 # NetBox v4.5
 
 
+## v4.5.6 (2026-03-31)
+
+### Enhancements
+
+* [#21480](https://github.com/netbox-community/netbox/issues/21480) - Add OSFP224 (1.6T) interface type
+* [#21727](https://github.com/netbox-community/netbox/issues/21727) - Add 2.5GBASE-X SFP modular interface type
+* [#21743](https://github.com/netbox-community/netbox/issues/21743) - Improve object change diff styling and layout
+* [#21793](https://github.com/netbox-community/netbox/issues/21793) - Add 50 Gbps, 800 Gbps, and 1.6 Tbps interface speed options
+
+### Bug Fixes
+
+* [#20467](https://github.com/netbox-community/netbox/issues/20467) - Fix resolution of the `{module}` variable for position fields in nested modules
+* [#21698](https://github.com/netbox-community/netbox/issues/21698) - Adjust custom field URL filter to support non-standard port numbers
+* [#21707](https://github.com/netbox-community/netbox/issues/21707) - Fix grouping of owner fields in provider account add/edit forms
+* [#21749](https://github.com/netbox-community/netbox/issues/21749) - Fix `FieldError` exception when sorting the circuit group assignment table by the member column
+* [#21763](https://github.com/netbox-community/netbox/issues/21763) - Use separate add/remove form fields when editing a site or provider with a large number of ASNs assigned
+
+---
+
 ## v4.5.5 (2026-03-17)
 ## v4.5.5 (2026-03-17)
 
 
 ### Enhancements
 ### Enhancements

+ 33 - 4
netbox/circuits/forms/model_forms.py

@@ -22,7 +22,7 @@ from utilities.forms.fields import (
     SlugField,
     SlugField,
 )
 )
 from utilities.forms.mixins import DistanceValidationMixin
 from utilities.forms.mixins import DistanceValidationMixin
-from utilities.forms.rendering import FieldSet, InlineFields
+from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields
 from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
 from utilities.forms.widgets import DatePicker, HTMXSelect, NumberWithOptions
 from utilities.templatetags.builtins.filters import bettertitle
 from utilities.templatetags.builtins.filters import bettertitle
 
 
@@ -48,17 +48,42 @@ class ProviderForm(PrimaryModelForm):
         label=_('ASNs'),
         label=_('ASNs'),
         required=False
         required=False
     )
     )
+    add_asns = DynamicModelMultipleChoiceField(
+        queryset=ASN.objects.all(),
+        label=_('Add ASNs'),
+        required=False
+    )
+    remove_asns = DynamicModelMultipleChoiceField(
+        queryset=ASN.objects.all(),
+        label=_('Remove ASNs'),
+        required=False
+    )
 
 
     fieldsets = (
     fieldsets = (
-        FieldSet('name', 'slug', 'asns', 'description', 'tags'),
+        FieldSet('name', 'slug', M2MAddRemoveFields('asns'), 'description', 'tags'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = Provider
         model = Provider
         fields = [
         fields = [
-            'name', 'slug', 'asns', 'description', 'owner', 'comments', 'tags',
+            'name', 'slug', 'description', 'owner', 'comments', 'tags',
         ]
         ]
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
+            # Add/remove mode for large M2M sets
+            self.fields.pop('asns')
+            self.fields['add_asns'].widget.add_query_param('provider_id__n', self.instance.pk)
+            self.fields['remove_asns'].widget.add_query_param('provider_id', self.instance.pk)
+            self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
+        else:
+            # Simple mode for new objects or small M2M sets
+            self.fields.pop('add_asns')
+            self.fields.pop('remove_asns')
+            if self.instance.pk:
+                self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
+
 
 
 class ProviderAccountForm(PrimaryModelForm):
 class ProviderAccountForm(PrimaryModelForm):
     provider = DynamicModelChoiceField(
     provider = DynamicModelChoiceField(
@@ -68,10 +93,14 @@ class ProviderAccountForm(PrimaryModelForm):
         quick_add=True
         quick_add=True
     )
     )
 
 
+    fieldsets = (
+        FieldSet('provider', 'account', 'name', 'description', 'tags'),
+    )
+
     class Meta:
     class Meta:
         model = ProviderAccount
         model = ProviderAccount
         fields = [
         fields = [
-            'provider', 'name', 'account', 'description', 'owner', 'comments', 'tags',
+            'provider', 'account', 'name', 'description', 'owner', 'comments', 'tags',
         ]
         ]
 
 
 
 

+ 4 - 2
netbox/circuits/tables/circuits.py

@@ -190,14 +190,16 @@ class CircuitGroupAssignmentTable(NetBoxTable):
     provider = tables.Column(
     provider = tables.Column(
         accessor='member__provider',
         accessor='member__provider',
         verbose_name=_('Provider'),
         verbose_name=_('Provider'),
-        linkify=True
+        orderable=False,
+        linkify=True,
     )
     )
     member_type = columns.ContentTypeColumn(
     member_type = columns.ContentTypeColumn(
         verbose_name=_('Type')
         verbose_name=_('Type')
     )
     )
     member = tables.Column(
     member = tables.Column(
         verbose_name=_('Circuit'),
         verbose_name=_('Circuit'),
-        linkify=True
+        orderable=False,
+        linkify=True,
     )
     )
     priority = tables.Column(
     priority = tables.Column(
         verbose_name=_('Priority'),
         verbose_name=_('Priority'),

+ 32 - 7
netbox/circuits/tests/test_tables.py

@@ -1,23 +1,48 @@
 from django.test import RequestFactory, TestCase, tag
 from django.test import RequestFactory, TestCase, tag
 
 
-from circuits.models import CircuitTermination
-from circuits.tables import CircuitTerminationTable
+from circuits.models import CircuitGroupAssignment, CircuitTermination
+from circuits.tables import CircuitGroupAssignmentTable, CircuitTerminationTable
 
 
 
 
 @tag('regression')
 @tag('regression')
 class CircuitTerminationTableTest(TestCase):
 class CircuitTerminationTableTest(TestCase):
     def test_every_orderable_field_does_not_throw_exception(self):
     def test_every_orderable_field_does_not_throw_exception(self):
         terminations = CircuitTermination.objects.all()
         terminations = CircuitTermination.objects.all()
-        disallowed = {'actions', }
+        disallowed = {
+            'actions',
+        }
 
 
         orderable_columns = [
         orderable_columns = [
-            column.name for column in CircuitTerminationTable(terminations).columns
+            column.name
+            for column in CircuitTerminationTable(terminations).columns
             if column.orderable and column.name not in disallowed
             if column.orderable and column.name not in disallowed
         ]
         ]
-        fake_request = RequestFactory().get("/")
+        fake_request = RequestFactory().get('/')
 
 
         for col in orderable_columns:
         for col in orderable_columns:
-            for dir in ('-', ''):
+            for direction in ('-', ''):
                 table = CircuitTerminationTable(terminations)
                 table = CircuitTerminationTable(terminations)
-                table.order_by = f'{dir}{col}'
+                table.order_by = f'{direction}{col}'
+                table.as_html(fake_request)
+
+
+@tag('regression')
+class CircuitGroupAssignmentTableTest(TestCase):
+    def test_every_orderable_field_does_not_throw_exception(self):
+        assignment = CircuitGroupAssignment.objects.all()
+        disallowed = {
+            'actions',
+        }
+
+        orderable_columns = [
+            column.name
+            for column in CircuitGroupAssignmentTable(assignment).columns
+            if column.orderable and column.name not in disallowed
+        ]
+        fake_request = RequestFactory().get('/')
+
+        for col in orderable_columns:
+            for direction in ('-', ''):
+                table = CircuitGroupAssignmentTable(assignment)
+                table.order_by = f'{direction}{col}'
                 table.as_html(fake_request)
                 table.as_html(fake_request)

+ 0 - 0
netbox/circuits/ui/__init__.py


+ 139 - 0
netbox/circuits/ui/panels.py

@@ -0,0 +1,139 @@
+from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import actions, attrs, panels
+from utilities.data import resolve_attr_path
+
+
+class CircuitCircuitTerminationPanel(panels.ObjectPanel):
+    """
+    A panel showing the CircuitTermination assigned to the object.
+    """
+
+    template_name = 'circuits/panels/circuit_circuit_termination.html'
+    title = _('Termination')
+
+    def __init__(self, accessor=None, side=None, **kwargs):
+        super().__init__(**kwargs)
+
+        if accessor is not None:
+            self.accessor = accessor
+        if side is not None:
+            self.side = side
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'side': self.side,
+            'termination': resolve_attr_path(context, f'{self.accessor}.termination_{self.side.lower()}'),
+        }
+
+
+class CircuitGroupAssignmentsPanel(panels.ObjectsTablePanel):
+    """
+    A panel showing all Circuit Groups attached to the object.
+    """
+
+    title = _('Group Assignments')
+    actions = [
+        actions.AddObject(
+            'circuits.CircuitGroupAssignment',
+            url_params={
+                'member_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                'member': lambda ctx: ctx['object'].pk,
+                'return_url': lambda ctx: ctx['object'].get_absolute_url(),
+            },
+            label=_('Assign Group'),
+        ),
+    ]
+
+    def __init__(self, **kwargs):
+        super().__init__(
+            'circuits.CircuitGroupAssignment',
+            filters={
+                'member_type_id': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                'member_id': lambda ctx: ctx['object'].pk,
+            },
+            **kwargs,
+        )
+
+
+class CircuitGroupPanel(panels.OrganizationalObjectPanel):
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+
+
+class CircuitGroupAssignmentPanel(panels.ObjectAttributesPanel):
+    group = attrs.RelatedObjectAttr('group', linkify=True)
+    provider = attrs.RelatedObjectAttr('member.provider', linkify=True)
+    member = attrs.GenericForeignKeyAttr('member', linkify=True)
+    priority = attrs.ChoiceAttr('priority')
+
+
+class CircuitPanel(panels.ObjectAttributesPanel):
+    provider = attrs.RelatedObjectAttr('provider', linkify=True)
+    provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
+    cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
+    type = attrs.RelatedObjectAttr('type', linkify=True)
+    status = attrs.ChoiceAttr('status')
+    distance = attrs.NumericAttr('distance', unit_accessor='get_distance_unit_display')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    install_date = attrs.DateTimeAttr('install_date', spec='date')
+    termination_date = attrs.DateTimeAttr('termination_date', spec='date')
+    commit_rate = attrs.TemplatedAttr('commit_rate', template_name='circuits/circuit/attrs/commit_rate.html')
+    description = attrs.TextAttr('description')
+
+
+class CircuitTypePanel(panels.OrganizationalObjectPanel):
+    color = attrs.ColorAttr('color')
+
+
+class ProviderPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    asns = attrs.RelatedObjectListAttr('asns', linkify=True, label=_('ASNs'))
+    description = attrs.TextAttr('description')
+
+
+class ProviderAccountPanel(panels.ObjectAttributesPanel):
+    provider = attrs.RelatedObjectAttr('provider', linkify=True)
+    account = attrs.TextAttr('account', style='font-monospace', copy_button=True)
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+
+
+class ProviderNetworkPanel(panels.ObjectAttributesPanel):
+    provider = attrs.RelatedObjectAttr('provider', linkify=True)
+    name = attrs.TextAttr('name')
+    service_id = attrs.TextAttr('service_id', label=_('Service ID'), style='font-monospace', copy_button=True)
+    description = attrs.TextAttr('description')
+
+
+class VirtualCircuitTypePanel(panels.OrganizationalObjectPanel):
+    color = attrs.ColorAttr('color')
+
+
+class VirtualCircuitPanel(panels.ObjectAttributesPanel):
+    provider = attrs.RelatedObjectAttr('provider', linkify=True)
+    provider_network = attrs.RelatedObjectAttr('provider_network', linkify=True)
+    provider_account = attrs.RelatedObjectAttr('provider_account', linkify=True)
+    cid = attrs.TextAttr('cid', label=_('Circuit ID'), style='font-monospace', copy_button=True)
+    type = attrs.RelatedObjectAttr('type', linkify=True)
+    status = attrs.ChoiceAttr('status')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    description = attrs.TextAttr('description')
+
+
+class VirtualCircuitTerminationPanel(panels.ObjectAttributesPanel):
+    provider = attrs.RelatedObjectAttr('virtual_circuit.provider', linkify=True)
+    provider_network = attrs.RelatedObjectAttr('virtual_circuit.provider_network', linkify=True)
+    provider_account = attrs.RelatedObjectAttr('virtual_circuit.provider_account', linkify=True)
+    virtual_circuit = attrs.RelatedObjectAttr('virtual_circuit', linkify=True)
+    role = attrs.ChoiceAttr('role')
+
+
+class VirtualCircuitTerminationInterfacePanel(panels.ObjectAttributesPanel):
+    title = _('Interface')
+
+    device = attrs.RelatedObjectAttr('interface.device', linkify=True)
+    interface = attrs.RelatedObjectAttr('interface', linkify=True)
+    type = attrs.ChoiceAttr('interface.type')
+    description = attrs.TextAttr('interface.description')

+ 194 - 1
netbox/circuits/views.py

@@ -1,13 +1,23 @@
+from django.utils.translation import gettext_lazy as _
 
 
 from dcim.views import PathTraceView
 from dcim.views import PathTraceView
+from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from ipam.models import ASN
 from ipam.models import ASN
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
+from netbox.ui import actions, layout
+from netbox.ui.panels import (
+    CommentsPanel,
+    ObjectsTablePanel,
+    Panel,
+    RelatedObjectsPanel,
+)
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.views import GetRelatedModelsMixin, register_model_view
 from utilities.views import GetRelatedModelsMixin, register_model_view
 
 
 from . import filtersets, forms, tables
 from . import filtersets, forms, tables
 from .models import *
 from .models import *
+from .ui import panels
 
 
 #
 #
 # Providers
 # Providers
@@ -29,6 +39,35 @@ class ProviderListView(generic.ObjectListView):
 @register_model_view(Provider)
 @register_model_view(Provider)
 class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
 class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Provider.objects.all()
     queryset = Provider.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ProviderPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='circuits.ProviderAccount',
+                filters={'provider_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'circuits.ProviderAccount', url_params={'provider': lambda ctx: ctx['object'].pk}
+                    ),
+                ],
+            ),
+            ObjectsTablePanel(
+                model='circuits.Circuit',
+                filters={'provider_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject('circuits.Circuit', url_params={'provider': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -44,7 +83,7 @@ class ProviderView(GetRelatedModelsMixin, generic.ObjectView):
                         'provider_id',
                         'provider_id',
                     ),
                     ),
                 ),
                 ),
-                ),
+            ),
         }
         }
 
 
 
 
@@ -108,6 +147,32 @@ class ProviderAccountListView(generic.ObjectListView):
 @register_model_view(ProviderAccount)
 @register_model_view(ProviderAccount)
 class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
 class ProviderAccountView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ProviderAccount.objects.all()
     queryset = ProviderAccount.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ProviderAccountPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='circuits.Circuit',
+                filters={'provider_account_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'circuits.Circuit',
+                        url_params={
+                            'provider': lambda ctx: ctx['object'].provider.pk,
+                            'provider_account': lambda ctx: ctx['object'].pk,
+                        },
+                    ),
+                ],
+            ),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -174,6 +239,32 @@ class ProviderNetworkListView(generic.ObjectListView):
 @register_model_view(ProviderNetwork)
 @register_model_view(ProviderNetwork)
 class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
 class ProviderNetworkView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ProviderNetwork.objects.all()
     queryset = ProviderNetwork.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ProviderNetworkPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='circuits.Circuit',
+                filters={'provider_network_id': lambda ctx: ctx['object'].pk},
+            ),
+            ObjectsTablePanel(
+                model='circuits.VirtualCircuit',
+                filters={'provider_network_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'circuits.VirtualCircuit', url_params={'provider_network': lambda ctx: ctx['object'].pk}
+                    ),
+                ],
+            ),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -251,6 +342,17 @@ class CircuitTypeListView(generic.ObjectListView):
 @register_model_view(CircuitType)
 @register_model_view(CircuitType)
 class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
 class CircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = CircuitType.objects.all()
     queryset = CircuitType.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CircuitTypePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -318,6 +420,20 @@ class CircuitListView(generic.ObjectListView):
 @register_model_view(Circuit)
 @register_model_view(Circuit)
 class CircuitView(generic.ObjectView):
 class CircuitView(generic.ObjectView):
     queryset = Circuit.objects.all()
     queryset = Circuit.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CircuitPanel(),
+            panels.CircuitGroupAssignmentsPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            panels.CircuitCircuitTerminationPanel(side='A'),
+            panels.CircuitCircuitTerminationPanel(side='Z'),
+            ImageAttachmentsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(Circuit, 'add', detail=False)
 @register_model_view(Circuit, 'add', detail=False)
@@ -390,6 +506,18 @@ class CircuitTerminationListView(generic.ObjectListView):
 @register_model_view(CircuitTermination)
 @register_model_view(CircuitTermination)
 class CircuitTerminationView(generic.ObjectView):
 class CircuitTerminationView(generic.ObjectView):
     queryset = CircuitTermination.objects.all()
     queryset = CircuitTermination.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            Panel(
+                template_name='circuits/panels/circuit_termination.html',
+                title=_('Circuit Termination'),
+            )
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(CircuitTermination, 'add', detail=False)
 @register_model_view(CircuitTermination, 'add', detail=False)
@@ -446,6 +574,17 @@ class CircuitGroupListView(generic.ObjectListView):
 @register_model_view(CircuitGroup)
 @register_model_view(CircuitGroup)
 class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
 class CircuitGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = CircuitGroup.objects.all()
     queryset = CircuitGroup.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CircuitGroupPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -508,6 +647,15 @@ class CircuitGroupAssignmentListView(generic.ObjectListView):
 @register_model_view(CircuitGroupAssignment)
 @register_model_view(CircuitGroupAssignment)
 class CircuitGroupAssignmentView(generic.ObjectView):
 class CircuitGroupAssignmentView(generic.ObjectView):
     queryset = CircuitGroupAssignment.objects.all()
     queryset = CircuitGroupAssignment.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CircuitGroupAssignmentPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(CircuitGroupAssignment, 'add', detail=False)
 @register_model_view(CircuitGroupAssignment, 'add', detail=False)
@@ -560,6 +708,17 @@ class VirtualCircuitTypeListView(generic.ObjectListView):
 @register_model_view(VirtualCircuitType)
 @register_model_view(VirtualCircuitType)
 class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
 class VirtualCircuitTypeView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VirtualCircuitType.objects.all()
     queryset = VirtualCircuitType.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualCircuitTypePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -627,6 +786,30 @@ class VirtualCircuitListView(generic.ObjectListView):
 @register_model_view(VirtualCircuit)
 @register_model_view(VirtualCircuit)
 class VirtualCircuitView(generic.ObjectView):
 class VirtualCircuitView(generic.ObjectView):
     queryset = VirtualCircuit.objects.all()
     queryset = VirtualCircuit.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualCircuitPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            CommentsPanel(),
+            panels.CircuitGroupAssignmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='circuits.VirtualCircuitTermination',
+                title=_('Terminations'),
+                filters={'virtual_circuit_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject(
+                        'circuits.VirtualCircuitTermination',
+                        url_params={'virtual_circuit': lambda ctx: ctx['object'].pk},
+                    ),
+                ],
+            ),
+        ],
+    )
 
 
 
 
 @register_model_view(VirtualCircuit, 'add', detail=False)
 @register_model_view(VirtualCircuit, 'add', detail=False)
@@ -698,6 +881,16 @@ class VirtualCircuitTerminationListView(generic.ObjectListView):
 @register_model_view(VirtualCircuitTermination)
 @register_model_view(VirtualCircuitTermination)
 class VirtualCircuitTerminationView(generic.ObjectView):
 class VirtualCircuitTerminationView(generic.ObjectView):
     queryset = VirtualCircuitTermination.objects.all()
     queryset = VirtualCircuitTermination.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualCircuitTerminationPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+        ],
+        right_panels=[
+            panels.VirtualCircuitTerminationInterfacePanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(VirtualCircuitTermination, 'edit')
 @register_model_view(VirtualCircuitTermination, 'edit')

+ 0 - 0
netbox/core/ui/__init__.py


+ 91 - 0
netbox/core/ui/panels.py

@@ -0,0 +1,91 @@
+from django.utils.translation import gettext_lazy as _
+
+from netbox.ui import attrs, panels
+
+
+class DataSourcePanel(panels.ObjectAttributesPanel):
+    title = _('Data Source')
+    name = attrs.TextAttr('name')
+    type = attrs.ChoiceAttr('type')
+    enabled = attrs.BooleanAttr('enabled')
+    status = attrs.ChoiceAttr('status')
+    sync_interval = attrs.ChoiceAttr('sync_interval', label=_('Sync interval'))
+    last_synced = attrs.DateTimeAttr('last_synced', label=_('Last synced'))
+    description = attrs.TextAttr('description')
+    source_url = attrs.TemplatedAttr(
+        'source_url',
+        label=_('URL'),
+        template_name='core/datasource/attrs/source_url.html',
+    )
+    ignore_rules = attrs.TemplatedAttr(
+        'ignore_rules',
+        label=_('Ignore rules'),
+        template_name='core/datasource/attrs/ignore_rules.html',
+    )
+
+
+class DataSourceBackendPanel(panels.ObjectPanel):
+    template_name = 'core/panels/datasource_backend.html'
+    title = _('Backend')
+
+
+class DataFilePanel(panels.ObjectAttributesPanel):
+    title = _('Data File')
+    source = attrs.RelatedObjectAttr('source', linkify=True)
+    path = attrs.TextAttr('path', style='font-monospace', copy_button=True)
+    last_updated = attrs.DateTimeAttr('last_updated')
+    size = attrs.TemplatedAttr('size', template_name='core/datafile/attrs/size.html')
+    hash = attrs.TextAttr('hash', label=_('SHA256 hash'), style='font-monospace', copy_button=True)
+
+
+class DataFileContentPanel(panels.ObjectPanel):
+    template_name = 'core/panels/datafile_content.html'
+    title = _('Content')
+
+
+class JobPanel(panels.ObjectAttributesPanel):
+    title = _('Job')
+    object_type = attrs.TemplatedAttr(
+        'object_type',
+        label=_('Object type'),
+        template_name='core/job/attrs/object_type.html',
+    )
+    name = attrs.TextAttr('name')
+    status = attrs.ChoiceAttr('status')
+    error = attrs.TextAttr('error')
+    user = attrs.TextAttr('user', label=_('Created by'))
+
+
+class JobSchedulingPanel(panels.ObjectAttributesPanel):
+    title = _('Scheduling')
+    created = attrs.DateTimeAttr('created')
+    scheduled = attrs.TemplatedAttr('scheduled', template_name='core/job/attrs/scheduled.html')
+    started = attrs.DateTimeAttr('started')
+    completed = attrs.DateTimeAttr('completed')
+    queue = attrs.TextAttr('queue_name', label=_('Queue'))
+
+
+class ObjectChangePanel(panels.ObjectAttributesPanel):
+    title = _('Change')
+    time = attrs.DateTimeAttr('time')
+    user = attrs.TemplatedAttr(
+        'user_name',
+        label=_('User'),
+        template_name='core/objectchange/attrs/user.html',
+    )
+    action = attrs.ChoiceAttr('action')
+    changed_object_type = attrs.TextAttr(
+        'changed_object_type',
+        label=_('Object type'),
+    )
+    changed_object = attrs.TemplatedAttr(
+        'object_repr',
+        label=_('Object'),
+        template_name='core/objectchange/attrs/changed_object.html',
+    )
+    message = attrs.TextAttr('message')
+    request_id = attrs.TemplatedAttr(
+        'request_id',
+        label=_('Request ID'),
+        template_name='core/objectchange/attrs/request_id.html',
+    )

+ 84 - 0
netbox/core/views.py

@@ -23,9 +23,20 @@ from rq.worker import Worker
 from rq.worker_registration import clean_worker_registry
 from rq.worker_registration import clean_worker_registry
 
 
 from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
 from core.utils import delete_rq_job, enqueue_rq_job, get_rq_jobs_from_status, requeue_rq_job, stop_rq_job
+from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from netbox.config import PARAMS, get_config
 from netbox.config import PARAMS, get_config
 from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
 from netbox.object_actions import AddObject, BulkDelete, BulkExport, DeleteObject
 from netbox.plugins.utils import get_installed_plugins
 from netbox.plugins.utils import get_installed_plugins
+from netbox.ui import layout
+from netbox.ui.panels import (
+    CommentsPanel,
+    ContextTablePanel,
+    JSONPanel,
+    ObjectsTablePanel,
+    PluginContentPanel,
+    RelatedObjectsPanel,
+    TemplatePanel,
+)
 from netbox.views import generic
 from netbox.views import generic
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.base import BaseObjectView
 from netbox.views.generic.mixins import TableMixin
 from netbox.views.generic.mixins import TableMixin
@@ -48,6 +59,7 @@ from .jobs import SyncDataSourceJob
 from .models import *
 from .models import *
 from .plugins import get_catalog_plugins, get_local_plugins
 from .plugins import get_catalog_plugins, get_local_plugins
 from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
 from .tables import CatalogPluginTable, JobLogEntryTable, PluginVersionTable
+from .ui import panels
 
 
 #
 #
 # Data sources
 # Data sources
@@ -67,6 +79,24 @@ class DataSourceListView(generic.ObjectListView):
 @register_model_view(DataSource)
 @register_model_view(DataSource)
 class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
 class DataSourceView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = DataSource.objects.all()
     queryset = DataSource.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.DataSourcePanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            panels.DataSourceBackendPanel(),
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='core.DataFile',
+                filters={'source_id': lambda ctx: ctx['object'].pk},
+            ),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -157,6 +187,14 @@ class DataFileListView(generic.ObjectListView):
 class DataFileView(generic.ObjectView):
 class DataFileView(generic.ObjectView):
     queryset = DataFile.objects.all()
     queryset = DataFile.objects.all()
     actions = (DeleteObject,)
     actions = (DeleteObject,)
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(
+                panels.DataFilePanel(),
+                panels.DataFileContentPanel(),
+            ),
+        ),
+    )
 
 
 
 
 @register_model_view(DataFile, 'delete')
 @register_model_view(DataFile, 'delete')
@@ -188,6 +226,17 @@ class JobListView(generic.ObjectListView):
 class JobView(generic.ObjectView):
 class JobView(generic.ObjectView):
     queryset = Job.objects.all()
     queryset = Job.objects.all()
     actions = (DeleteObject,)
     actions = (DeleteObject,)
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.JobPanel(),
+        ],
+        right_panels=[
+            panels.JobSchedulingPanel(),
+        ],
+        bottom_panels=[
+            JSONPanel('data', title=_('Data')),
+        ],
+    )
 
 
 
 
 @register_model_view(Job, 'log')
 @register_model_view(Job, 'log')
@@ -200,6 +249,13 @@ class JobLogView(generic.ObjectView):
         badge=lambda obj: len(obj.log_entries),
         badge=lambda obj: len(obj.log_entries),
         weight=500,
         weight=500,
     )
     )
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(
+                ContextTablePanel('table', title=_('Log Entries')),
+            ),
+        ),
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         table = JobLogEntryTable(instance.log_entries)
         table = JobLogEntryTable(instance.log_entries)
@@ -241,6 +297,26 @@ class ObjectChangeListView(generic.ObjectListView):
 @register_model_view(ObjectChange)
 @register_model_view(ObjectChange)
 class ObjectChangeView(generic.ObjectView):
 class ObjectChangeView(generic.ObjectView):
     queryset = None
     queryset = None
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(panels.ObjectChangePanel()),
+            layout.Column(TemplatePanel('core/panels/objectchange_difference.html')),
+        ),
+        layout.Row(
+            layout.Column(TemplatePanel('core/panels/objectchange_prechange.html')),
+            layout.Column(TemplatePanel('core/panels/objectchange_postchange.html')),
+        ),
+        layout.Row(
+            layout.Column(PluginContentPanel('left_page')),
+            layout.Column(PluginContentPanel('right_page')),
+        ),
+        layout.Row(
+            layout.Column(
+                TemplatePanel('core/panels/objectchange_related.html'),
+                PluginContentPanel('full_width_page'),
+            ),
+        ),
+    )
 
 
     def get_queryset(self, request):
     def get_queryset(self, request):
         return ObjectChange.objects.valid_models()
         return ObjectChange.objects.valid_models()
@@ -309,6 +385,14 @@ class ConfigRevisionListView(generic.ObjectListView):
 @register_model_view(ConfigRevision)
 @register_model_view(ConfigRevision)
 class ConfigRevisionView(generic.ObjectView):
 class ConfigRevisionView(generic.ObjectView):
     queryset = ConfigRevision.objects.all()
     queryset = ConfigRevision.objects.all()
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(
+                TemplatePanel('core/panels/configrevision_data.html'),
+                TemplatePanel('core/panels/configrevision_comment.html'),
+            ),
+        ),
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         """
         """

+ 28 - 2
netbox/dcim/choices.py

@@ -1003,10 +1003,16 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_800GE_SR8 = '800gbase-sr8'
     TYPE_800GE_SR8 = '800gbase-sr8'
     TYPE_800GE_VR8 = '800gbase-vr8'
     TYPE_800GE_VR8 = '800gbase-vr8'
 
 
+    # 1.6 Tbps Ethernet
+    TYPE_1TE_CR8 = '1.6tbase-cr8'
+    TYPE_1TE_DR8 = '1.6tbase-dr8'
+    TYPE_1TE_DR8_2 = '1.6tbase-dr8-2'
+
     # Ethernet (modular)
     # Ethernet (modular)
     TYPE_100ME_SFP = '100base-x-sfp'
     TYPE_100ME_SFP = '100base-x-sfp'
     TYPE_1GE_GBIC = '1000base-x-gbic'
     TYPE_1GE_GBIC = '1000base-x-gbic'
     TYPE_1GE_SFP = '1000base-x-sfp'
     TYPE_1GE_SFP = '1000base-x-sfp'
+    TYPE_2GE_SFP = '2.5gbase-x-sfp'
     TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp'
     TYPE_10GE_SFP_PLUS = '10gbase-x-sfpp'
     TYPE_10GE_XFP = '10gbase-x-xfp'
     TYPE_10GE_XFP = '10gbase-x-xfp'
     TYPE_10GE_XENPAK = '10gbase-x-xenpak'
     TYPE_10GE_XENPAK = '10gbase-x-xenpak'
@@ -1034,8 +1040,11 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
     TYPE_400GE_OSFP_RHS = '400gbase-x-osfp-rhs'
     TYPE_400GE_CDFP = '400gbase-x-cdfp'
     TYPE_400GE_CDFP = '400gbase-x-cdfp'
     TYPE_400GE_CFP8 = '400gbase-x-cfp8'
     TYPE_400GE_CFP8 = '400gbase-x-cfp8'
-    TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'
-    TYPE_800GE_OSFP = '800gbase-x-osfp'
+    TYPE_800GE_QSFP_DD = '800gbase-x-qsfpdd'  # TODO: Rename to _QSFP_DD800
+    TYPE_800GE_OSFP = '800gbase-x-osfp'  # TODO: Rename to _OSFP800
+    TYPE_1TE_OSFP1600 = '1.6tbase-x-osfp1600'
+    TYPE_1TE_OSFP1600_RHS = '1.6tbase-x-osfp1600-rhs'
+    TYPE_1TE_QSFP_DD1600 = '1.6tbase-x-qsfpdd1600'
 
 
     # Backplane Ethernet
     # Backplane Ethernet
     TYPE_1GE_KX = '1000base-kx'
     TYPE_1GE_KX = '1000base-kx'
@@ -1049,6 +1058,7 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_100GE_KP4 = '100gbase-kp4'
     TYPE_100GE_KP4 = '100gbase-kp4'
     TYPE_100GE_KR2 = '100gbase-kr2'
     TYPE_100GE_KR2 = '100gbase-kr2'
     TYPE_100GE_KR4 = '100gbase-kr4'
     TYPE_100GE_KR4 = '100gbase-kr4'
+    TYPE_1TE_KR8 = '1.6tbase-kr8'
 
 
     # Wireless
     # Wireless
     TYPE_80211A = 'ieee802.11a'
     TYPE_80211A = 'ieee802.11a'
@@ -1298,12 +1308,21 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_800GE_VR8, '800GBASE-VR8 (800GE)'),
                 (TYPE_800GE_VR8, '800GBASE-VR8 (800GE)'),
             )
             )
         ),
         ),
+        (
+            _('1.6 Tbps Ethernet'),
+            (
+                (TYPE_1TE_CR8, '1.6TBASE-CR8 (1.6TE)'),
+                (TYPE_1TE_DR8, '1.6TBASE-DR8 (1.6TE)'),
+                (TYPE_1TE_DR8_2, '1.6TBASE-DR8-2 (1.6TE)'),
+            )
+        ),
         (
         (
             _('Pluggable transceivers'),
             _('Pluggable transceivers'),
             (
             (
                 (TYPE_100ME_SFP, 'SFP (100ME)'),
                 (TYPE_100ME_SFP, 'SFP (100ME)'),
                 (TYPE_1GE_GBIC, 'GBIC (1GE)'),
                 (TYPE_1GE_GBIC, 'GBIC (1GE)'),
                 (TYPE_1GE_SFP, 'SFP (1GE)'),
                 (TYPE_1GE_SFP, 'SFP (1GE)'),
+                (TYPE_2GE_SFP, 'SFP (2.5GE)'),
                 (TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
                 (TYPE_10GE_SFP_PLUS, 'SFP+ (10GE)'),
                 (TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
                 (TYPE_10GE_XENPAK, 'XENPAK (10GE)'),
                 (TYPE_10GE_XFP, 'XFP (10GE)'),
                 (TYPE_10GE_XFP, 'XFP (10GE)'),
@@ -1333,6 +1352,9 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
                 (TYPE_400GE_OSFP_RHS, 'OSFP-RHS (400GE)'),
                 (TYPE_800GE_OSFP, 'OSFP (800GE)'),
                 (TYPE_800GE_OSFP, 'OSFP (800GE)'),
                 (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
                 (TYPE_800GE_QSFP_DD, 'QSFP-DD (800GE)'),
+                (TYPE_1TE_OSFP1600, 'OSFP1600 (1.6TE)'),
+                (TYPE_1TE_OSFP1600_RHS, 'OSFP1600-RHS (1.6TE)'),
+                (TYPE_1TE_QSFP_DD1600, 'QSFP-DD1600 (1.6TE)'),
             )
             )
         ),
         ),
         (
         (
@@ -1349,6 +1371,7 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
                 (TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
                 (TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
                 (TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
                 (TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
                 (TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
+                (TYPE_1TE_KR8, '1.6TBASE-KR8 (1.6TE)'),
             )
             )
         ),
         ),
         (
         (
@@ -1495,9 +1518,12 @@ class InterfaceSpeedChoices(ChoiceSet):
         (10000000, '10 Gbps'),
         (10000000, '10 Gbps'),
         (25000000, '25 Gbps'),
         (25000000, '25 Gbps'),
         (40000000, '40 Gbps'),
         (40000000, '40 Gbps'),
+        (50000000, '50 Gbps'),
         (100000000, '100 Gbps'),
         (100000000, '100 Gbps'),
         (200000000, '200 Gbps'),
         (200000000, '200 Gbps'),
         (400000000, '400 Gbps'),
         (400000000, '400 Gbps'),
+        (800000000, '800 Gbps'),
+        (1600000000, '1.6 Tbps'),
     ]
     ]
 
 
 
 

+ 29 - 3
netbox/dcim/forms/model_forms.py

@@ -23,7 +23,7 @@ from utilities.forms.fields import (
     NumericArrayField,
     NumericArrayField,
     SlugField,
     SlugField,
 )
 )
-from utilities.forms.rendering import FieldSet, InlineFields, TabbedGroups
+from utilities.forms.rendering import FieldSet, InlineFields, M2MAddRemoveFields, TabbedGroups
 from utilities.forms.widgets import (
 from utilities.forms.widgets import (
     APISelect,
     APISelect,
     ClearableFileInput,
     ClearableFileInput,
@@ -144,6 +144,16 @@ class SiteForm(TenancyForm, PrimaryModelForm):
         label=_('ASNs'),
         label=_('ASNs'),
         required=False
         required=False
     )
     )
+    add_asns = DynamicModelMultipleChoiceField(
+        queryset=ASN.objects.all(),
+        label=_('Add ASNs'),
+        required=False
+    )
+    remove_asns = DynamicModelMultipleChoiceField(
+        queryset=ASN.objects.all(),
+        label=_('Remove ASNs'),
+        required=False
+    )
     slug = SlugField()
     slug = SlugField()
     time_zone = TimeZoneFormField(
     time_zone = TimeZoneFormField(
         label=_('Time zone'),
         label=_('Time zone'),
@@ -153,7 +163,8 @@ class SiteForm(TenancyForm, PrimaryModelForm):
 
 
     fieldsets = (
     fieldsets = (
         FieldSet(
         FieldSet(
-            'name', 'slug', 'status', 'region', 'group', 'facility', 'asns', 'time_zone', 'description', 'tags',
+            'name', 'slug', 'status', 'region', 'group', 'facility', M2MAddRemoveFields('asns'), 'time_zone',
+            'description', 'tags',
             name=_('Site')
             name=_('Site')
         ),
         ),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
         FieldSet('tenant_group', 'tenant', name=_('Tenancy')),
@@ -163,7 +174,7 @@ class SiteForm(TenancyForm, PrimaryModelForm):
     class Meta:
     class Meta:
         model = Site
         model = Site
         fields = (
         fields = (
-            'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'asns', 'time_zone',
+            'name', 'slug', 'status', 'region', 'group', 'tenant_group', 'tenant', 'facility', 'time_zone',
             'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner', 'comments', 'tags',
             'description', 'physical_address', 'shipping_address', 'latitude', 'longitude', 'owner', 'comments', 'tags',
         )
         )
         widgets = {
         widgets = {
@@ -179,6 +190,21 @@ class SiteForm(TenancyForm, PrimaryModelForm):
             ),
             ),
         }
         }
 
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        if self.instance.pk and (count := self.instance.asns.count()) >= M2MAddRemoveFields.THRESHOLD:
+            # Add/remove mode for large M2M sets
+            self.fields.pop('asns')
+            self.fields['add_asns'].widget.add_query_param('site_id__n', self.instance.pk)
+            self.fields['remove_asns'].widget.add_query_param('site_id', self.instance.pk)
+            self.fields['remove_asns'].help_text = _("{count} ASNs currently assigned").format(count=count)
+        else:
+            # Simple mode for new objects or small M2M sets
+            self.fields.pop('add_asns')
+            self.fields.pop('remove_asns')
+            if self.instance.pk:
+                self.initial['asns'] = list(self.instance.asns.values_list('pk', flat=True))
+
 
 
 class LocationForm(TenancyForm, NestedGroupModelForm):
 class LocationForm(TenancyForm, NestedGroupModelForm):
     site = DynamicModelChoiceField(
     site = DynamicModelChoiceField(

+ 13 - 31
netbox/dcim/models/device_component_templates.py

@@ -197,42 +197,21 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
         modules.reverse()
         modules.reverse()
         return modules
         return modules
 
 
-    def resolve_name(self, module=None, device=None):
-        has_module = MODULE_TOKEN in self.name
-        has_vc = VC_POSITION_RE.search(self.name) is not None
-        if not has_module and not has_vc:
-            return self.name
-
-        name = self.name
-
-        if has_module and module:
+    def _resolve_module_placeholder(self, value, module=None, device=None):
+        if MODULE_TOKEN in value and module:
             modules = self._get_module_tree(module)
             modules = self._get_module_tree(module)
             for m in modules:
             for m in modules:
-                name = name.replace(MODULE_TOKEN, m.module_bay.position, 1)
-
-        if has_vc:
+                value = value.replace(MODULE_TOKEN, m.module_bay.position, 1)
+        if VC_POSITION_RE.search(value) is not None:
             resolved_device = (module.device if module else None) or device
             resolved_device = (module.device if module else None) or device
-            name = self._resolve_vc_position(name, resolved_device)
+            value = self._resolve_vc_position(value, resolved_device)
+        return value
 
 
-        return name
+    def resolve_name(self, module=None, device=None):
+        return self._resolve_module_placeholder(self.name, module, device)
 
 
     def resolve_label(self, module=None, device=None):
     def resolve_label(self, module=None, device=None):
-        has_module = MODULE_TOKEN in self.label
-        has_vc = VC_POSITION_RE.search(self.label) is not None
-        if not has_module and not has_vc:
-            return self.label
-
-        label = self.label
-
-        if has_module and module:
-            modules = self._get_module_tree(module)
-            for m in modules:
-                label = label.replace(MODULE_TOKEN, m.module_bay.position, 1)
-        if has_vc:
-            resolved_device = (module.device if module else None) or device
-            label = self._resolve_vc_position(label, resolved_device)
-
-        return label
+        return self._resolve_module_placeholder(self.label, module, device)
 
 
 
 
 class ConsolePortTemplate(ModularComponentTemplateModel):
 class ConsolePortTemplate(ModularComponentTemplateModel):
@@ -766,11 +745,14 @@ class ModuleBayTemplate(ModularComponentTemplateModel):
         verbose_name = _('module bay template')
         verbose_name = _('module bay template')
         verbose_name_plural = _('module bay templates')
         verbose_name_plural = _('module bay templates')
 
 
+    def resolve_position(self, module):
+        return self._resolve_module_placeholder(self.position, module)
+
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
             name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
             name=self.resolve_name(kwargs.get('module'), kwargs.get('device')),
             label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
             label=self.resolve_label(kwargs.get('module'), kwargs.get('device')),
-            position=self.position,
+            position=self.resolve_position(kwargs.get('module')),
             enabled=self.enabled,
             enabled=self.enabled,
             **kwargs
             **kwargs
         )
         )

+ 110 - 1
netbox/dcim/tests/test_forms.py

@@ -10,8 +10,9 @@ from dcim.choices import (
 )
 )
 from dcim.forms import *
 from dcim.forms import *
 from dcim.models import *
 from dcim.models import *
-from ipam.models import VLAN
+from ipam.models import ASN, RIR, VLAN
 from utilities.exceptions import AbortRequest
 from utilities.exceptions import AbortRequest
+from utilities.forms.rendering import M2MAddRemoveFields
 from utilities.testing import create_test_device
 from utilities.testing import create_test_device
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 from virtualization.models import Cluster, ClusterGroup, ClusterType
 
 
@@ -500,3 +501,111 @@ class InterfaceTestCase(TestCase):
         self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
         self.assertNotIn('untagged_vlan', form.cleaned_data.keys())
         self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
         self.assertNotIn('tagged_vlans', form.cleaned_data.keys())
         self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
         self.assertNotIn('qinq_svlan', form.cleaned_data.keys())
+
+
+class SiteFormTestCase(TestCase):
+    """
+    Tests for M2MAddRemoveFields using Site ASN assignments as the test case.
+    Covers both simple mode (single multi-select field) and add/remove mode (dual fields).
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.rir = RIR.objects.create(name='RIR 1', slug='rir-1')
+        # Create 110 ASNs: 100 to pre-assign (triggering add/remove mode) plus 10 extras
+        ASN.objects.bulk_create([ASN(asn=i, rir=cls.rir) for i in range(1, 111)])
+        cls.asns = list(ASN.objects.order_by('asn'))
+
+    def _site_data(self, **kwargs):
+        data = {'name': 'Test Site', 'slug': 'test-site', 'status': 'active'}
+        data.update(kwargs)
+        return data
+
+    def test_new_site_uses_simple_mode(self):
+        """A form for a new site uses the single 'asns' field (simple mode)."""
+        form = SiteForm(data=self._site_data())
+        self.assertIn('asns', form.fields)
+        self.assertNotIn('add_asns', form.fields)
+        self.assertNotIn('remove_asns', form.fields)
+
+    def test_existing_site_below_threshold_uses_simple_mode(self):
+        """A form for an existing site with fewer than THRESHOLD ASNs uses simple mode."""
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        site.asns.set(self.asns[:5])
+        form = SiteForm(instance=site)
+        self.assertIn('asns', form.fields)
+        self.assertNotIn('add_asns', form.fields)
+        self.assertNotIn('remove_asns', form.fields)
+
+    def test_existing_site_at_threshold_uses_add_remove_mode(self):
+        """A form for an existing site with THRESHOLD or more ASNs uses add/remove mode."""
+        site = Site.objects.create(name='Site 2', slug='site-2')
+        site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
+        form = SiteForm(instance=site)
+        self.assertNotIn('asns', form.fields)
+        self.assertIn('add_asns', form.fields)
+        self.assertIn('remove_asns', form.fields)
+
+    def test_simple_mode_assigns_asns_on_create(self):
+        """Saving a new site via simple mode assigns the selected ASNs."""
+        asn_pks = [asn.pk for asn in self.asns[:3]]
+        form = SiteForm(data=self._site_data(asns=asn_pks))
+        self.assertTrue(form.is_valid(), form.errors)
+        site = form.save()
+        self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(asn_pks))
+
+    def test_simple_mode_replaces_asns_on_edit(self):
+        """Saving an existing site via simple mode replaces the current ASN assignments."""
+        site = Site.objects.create(name='Site 3', slug='site-3')
+        site.asns.set(self.asns[:3])
+        new_asn_pks = [asn.pk for asn in self.asns[3:6]]
+        form = SiteForm(
+            data=self._site_data(name='Site 3', slug='site-3', asns=new_asn_pks),
+            instance=site
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        site = form.save()
+        self.assertEqual(set(site.asns.values_list('pk', flat=True)), set(new_asn_pks))
+
+    def test_add_remove_mode_adds_asns(self):
+        """In add/remove mode, specifying 'add_asns' appends to current assignments."""
+        site = Site.objects.create(name='Site 4', slug='site-4')
+        site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
+        new_asn_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:]]
+        form = SiteForm(
+            data=self._site_data(name='Site 4', slug='site-4', add_asns=new_asn_pks),
+            instance=site
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        site = form.save()
+        self.assertEqual(site.asns.count(), len(self.asns))
+
+    def test_add_remove_mode_removes_asns(self):
+        """In add/remove mode, specifying 'remove_asns' drops those assignments."""
+        site = Site.objects.create(name='Site 5', slug='site-5')
+        site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
+        remove_pks = [asn.pk for asn in self.asns[:5]]
+        form = SiteForm(
+            data=self._site_data(name='Site 5', slug='site-5', remove_asns=remove_pks),
+            instance=site
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        site = form.save()
+        self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD - 5)
+        self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())
+
+    def test_add_remove_mode_simultaneous_add_and_remove(self):
+        """In add/remove mode, add and remove operations are applied together."""
+        site = Site.objects.create(name='Site 6', slug='site-6')
+        site.asns.set(self.asns[:M2MAddRemoveFields.THRESHOLD])
+        add_pks = [asn.pk for asn in self.asns[M2MAddRemoveFields.THRESHOLD:M2MAddRemoveFields.THRESHOLD + 3]]
+        remove_pks = [asn.pk for asn in self.asns[:3]]
+        form = SiteForm(
+            data=self._site_data(name='Site 6', slug='site-6', add_asns=add_pks, remove_asns=remove_pks),
+            instance=site
+        )
+        self.assertTrue(form.is_valid(), form.errors)
+        site = form.save()
+        self.assertEqual(site.asns.count(), M2MAddRemoveFields.THRESHOLD)
+        self.assertTrue(site.asns.filter(pk__in=add_pks).count() == 3)
+        self.assertFalse(site.asns.filter(pk__in=remove_pks).exists())

+ 44 - 0
netbox/dcim/tests/test_models.py

@@ -955,6 +955,50 @@ class ModuleBayTestCase(TestCase):
         nested_bay = module.modulebays.get(name='SFP A-21')
         nested_bay = module.modulebays.get(name='SFP A-21')
         self.assertEqual(nested_bay.label, 'A-21')
         self.assertEqual(nested_bay.label, 'A-21')
 
 
+    @tag('regression')  # #20467
+    def test_nested_module_bay_position_resolution(self):
+        """Test that {module} in a module bay template's position field is resolved when the module is installed."""
+        manufacturer = Manufacturer.objects.first()
+        site = Site.objects.first()
+        device_role = DeviceRole.objects.first()
+
+        device_type = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Device with Position Test',
+            slug='device-with-position-test'
+        )
+        ModuleBayTemplate.objects.create(
+            device_type=device_type,
+            name='Slot 1',
+            position='1'
+        )
+
+        module_type = ModuleType.objects.create(
+            manufacturer=manufacturer,
+            model='Module with Position Placeholder'
+        )
+        ModuleBayTemplate.objects.create(
+            module_type=module_type,
+            name='Sub-bay {module}-1',
+            position='{module}-1'
+        )
+
+        device = Device.objects.create(
+            name='Position Test Device',
+            device_type=device_type,
+            role=device_role,
+            site=site
+        )
+        module_bay = device.modulebays.get(name='Slot 1')
+        module = Module.objects.create(
+            device=device,
+            module_bay=module_bay,
+            module_type=module_type
+        )
+
+        nested_bay = module.modulebays.get(name='Sub-bay 1-1')
+        self.assertEqual(nested_bay.position, '1-1')
+
     @tag('regression')  # #20912
     @tag('regression')  # #20912
     def test_module_bay_parent_cleared_when_module_removed(self):
     def test_module_bay_parent_cleared_when_module_removed(self):
         """Test that the parent field is properly cleared when a module bay's module assignment is removed"""
         """Test that the parent field is properly cleared when a module bay's module assignment is removed"""

+ 351 - 1
netbox/dcim/ui/panels.py

@@ -1,6 +1,8 @@
+from django.contrib.contenttypes.models import ContentType
+from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from netbox.ui import attrs, panels
+from netbox.ui import actions, attrs, panels
 
 
 
 
 class SitePanel(panels.ObjectAttributesPanel):
 class SitePanel(panels.ObjectAttributesPanel):
@@ -191,16 +193,261 @@ class PlatformPanel(panels.NestedGroupObjectPanel):
     config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
     config_template = attrs.RelatedObjectAttr('config_template', linkify=True)
 
 
 
 
+class ConsolePortPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    speed = attrs.ChoiceAttr('speed')
+    description = attrs.TextAttr('description')
+
+
+class ConsoleServerPortPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    speed = attrs.ChoiceAttr('speed')
+    description = attrs.TextAttr('description')
+
+
+class PowerPortPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    description = attrs.TextAttr('description')
+    maximum_draw = attrs.TextAttr('maximum_draw')
+    allocated_draw = attrs.TextAttr('allocated_draw')
+
+
+class PowerOutletPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    status = attrs.ChoiceAttr('status')
+    description = attrs.TextAttr('description')
+    color = attrs.ColorAttr('color')
+    power_port = attrs.RelatedObjectAttr('power_port', linkify=True)
+    feed_leg = attrs.ChoiceAttr('feed_leg')
+
+
+class FrontPortPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    color = attrs.ColorAttr('color')
+    positions = attrs.TextAttr('positions')
+    description = attrs.TextAttr('description')
+
+
+class RearPortPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    color = attrs.ColorAttr('color')
+    positions = attrs.TextAttr('positions')
+    description = attrs.TextAttr('description')
+
+
+class ModuleBayPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    position = attrs.TextAttr('position')
+    description = attrs.TextAttr('description')
+
+
+class DeviceBayPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    description = attrs.TextAttr('description')
+
+
+class InventoryItemPanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent item'))
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    status = attrs.ChoiceAttr('status')
+    role = attrs.RelatedObjectAttr('role', linkify=True)
+    component = attrs.GenericForeignKeyAttr('component', linkify=True)
+    manufacturer = attrs.RelatedObjectAttr('manufacturer', linkify=True)
+    part_id = attrs.TextAttr('part_id', label=_('Part ID'))
+    serial = attrs.TextAttr('serial')
+    asset_tag = attrs.TextAttr('asset_tag')
+    description = attrs.TextAttr('description')
+
+
+class InventoryItemRolePanel(panels.OrganizationalObjectPanel):
+    color = attrs.ColorAttr('color')
+
+
+class CablePanel(panels.ObjectAttributesPanel):
+    type = attrs.ChoiceAttr('type')
+    status = attrs.ChoiceAttr('status')
+    profile = attrs.ChoiceAttr('profile')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    bundle = attrs.RelatedObjectAttr('bundle', linkify=True)
+    label = attrs.TextAttr('label')
+    description = attrs.TextAttr('description')
+    color = attrs.ColorAttr('color')
+    length = attrs.NumericAttr('length', unit_accessor='get_length_unit_display')
+
+
+class VirtualChassisPanel(panels.ObjectAttributesPanel):
+    domain = attrs.TextAttr('domain')
+    master = attrs.RelatedObjectAttr('master', linkify=True)
+    description = attrs.TextAttr('description')
+
+
+class PowerPanelPanel(panels.ObjectAttributesPanel):
+    site = attrs.RelatedObjectAttr('site', linkify=True)
+    location = attrs.NestedObjectAttr('location', linkify=True)
+    description = attrs.TextAttr('description')
+
+
+class PowerFeedPanel(panels.ObjectAttributesPanel):
+    power_panel = attrs.RelatedObjectAttr('power_panel', linkify=True)
+    rack = attrs.RelatedObjectAttr('rack', linkify=True)
+    type = attrs.ChoiceAttr('type')
+    status = attrs.ChoiceAttr('status')
+    description = attrs.TextAttr('description')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    connected_device = attrs.TemplatedAttr(
+        'connected_endpoints',
+        label=_('Connected device'),
+        template_name='dcim/powerfeed/attrs/connected_device.html',
+    )
+    utilization = attrs.TemplatedAttr(
+        'connected_endpoints',
+        label=_('Utilization (allocated)'),
+        template_name='dcim/powerfeed/attrs/utilization.html',
+    )
+
+
+class PowerFeedElectricalPanel(panels.ObjectAttributesPanel):
+    title = _('Electrical Characteristics')
+
+    supply = attrs.ChoiceAttr('supply')
+    voltage = attrs.TextAttr('voltage', format_string=_('{}V'))
+    amperage = attrs.TextAttr('amperage', format_string=_('{}A'))
+    phase = attrs.ChoiceAttr('phase')
+    max_utilization = attrs.TextAttr('max_utilization', format_string='{}%')
+
+
+class VirtualDeviceContextPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    identifier = attrs.TextAttr('identifier')
+    status = attrs.ChoiceAttr('status')
+    primary_ip4 = attrs.TemplatedAttr(
+        'primary_ip4',
+        label=_('Primary IPv4'),
+        template_name='dcim/device/attrs/ipaddress.html',
+    )
+    primary_ip6 = attrs.TemplatedAttr(
+        'primary_ip6',
+        label=_('Primary IPv6'),
+        template_name='dcim/device/attrs/ipaddress.html',
+    )
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+
+
+class MACAddressPanel(panels.ObjectAttributesPanel):
+    mac_address = attrs.TextAttr('mac_address', label=_('MAC address'), style='font-monospace', copy_button=True)
+    description = attrs.TextAttr('description')
+    assignment = attrs.RelatedObjectAttr('assigned_object', linkify=True, grouped_by='parent_object')
+    is_primary = attrs.BooleanAttr('is_primary', label=_('Primary for interface'))
+
+
+class ConnectionPanel(panels.ObjectPanel):
+    """
+    A panel which displays connection information for a cabled object.
+    """
+    template_name = 'dcim/panels/connection.html'
+    title = _('Connection')
+
+    def __init__(self, trace_url_name, connect_options=None, show_endpoints=True, **kwargs):
+        super().__init__(**kwargs)
+        self.trace_url_name = trace_url_name
+        self.connect_options = connect_options or []
+        self.show_endpoints = show_endpoints
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'trace_url_name': self.trace_url_name,
+            'connect_options': self.connect_options,
+            'show_endpoints': self.show_endpoints,
+        }
+
+    def render(self, context):
+        ctx = self.get_context(context)
+        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+
+
+class InventoryItemsPanel(panels.ObjectPanel):
+    """
+    A panel which displays inventory items associated with a component.
+    """
+    template_name = 'dcim/panels/component_inventory_items.html'
+    title = _('Inventory Items')
+    actions = [
+        actions.AddObject(
+            'dcim.inventoryitem',
+            url_params={
+                'component_type': lambda ctx: ContentType.objects.get_for_model(ctx['object']).pk,
+                'component_id': lambda ctx: ctx['object'].pk,
+            },
+        ),
+    ]
+
+    def render(self, context):
+        ctx = self.get_context(context)
+        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+
+
 class VirtualChassisMembersPanel(panels.ObjectPanel):
 class VirtualChassisMembersPanel(panels.ObjectPanel):
     """
     """
     A panel which lists all members of a virtual chassis.
     A panel which lists all members of a virtual chassis.
     """
     """
+
     template_name = 'dcim/panels/virtual_chassis_members.html'
     template_name = 'dcim/panels/virtual_chassis_members.html'
     title = _('Virtual Chassis Members')
     title = _('Virtual Chassis Members')
+    actions = [
+        actions.AddObject(
+            'dcim.device',
+            url_params={
+                'site': lambda ctx: (
+                    ctx['virtual_chassis'].master.site_id
+                    if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
+                    else ''
+                ),
+                'rack': lambda ctx: (
+                    ctx['virtual_chassis'].master.rack_id
+                    if ctx['virtual_chassis'] and ctx['virtual_chassis'].master_id
+                    else ''
+                ),
+            },
+        ),
+    ]
 
 
     def get_context(self, context):
     def get_context(self, context):
         return {
         return {
             **super().get_context(context),
             **super().get_context(context),
+            'virtual_chassis': context.get('virtual_chassis'),
             'vc_members': context.get('vc_members'),
             'vc_members': context.get('vc_members'),
         }
         }
 
 
@@ -228,3 +475,106 @@ class PowerUtilizationPanel(panels.ObjectPanel):
         if not obj.powerports.exists() or not obj.poweroutlets.exists():
         if not obj.powerports.exists() or not obj.poweroutlets.exists():
             return ''
             return ''
         return super().render(context)
         return super().render(context)
+
+
+class InterfacePanel(panels.ObjectAttributesPanel):
+    device = attrs.RelatedObjectAttr('device', linkify=True)
+    module = attrs.RelatedObjectAttr('module', linkify=True)
+    name = attrs.TextAttr('name')
+    label = attrs.TextAttr('label')
+    type = attrs.ChoiceAttr('type')
+    speed = attrs.TemplatedAttr('speed', template_name='dcim/interface/attrs/speed.html', label=_('Speed'))
+    duplex = attrs.ChoiceAttr('duplex')
+    mtu = attrs.TextAttr('mtu', label=_('MTU'))
+    enabled = attrs.BooleanAttr('enabled')
+    mgmt_only = attrs.BooleanAttr('mgmt_only', label=_('Management only'))
+    description = attrs.TextAttr('description')
+    poe_mode = attrs.ChoiceAttr('poe_mode', label=_('PoE mode'))
+    poe_type = attrs.ChoiceAttr('poe_type', label=_('PoE type'))
+    mode = attrs.ChoiceAttr('mode', label=_('802.1Q mode'))
+    qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
+    untagged_vlan = attrs.RelatedObjectAttr('untagged_vlan', linkify=True, label=_('Untagged VLAN'))
+    tx_power = attrs.TextAttr('tx_power', label=_('Transmit power (dBm)'))
+    tunnel = attrs.RelatedObjectAttr('tunnel_termination.tunnel', linkify=True, label=_('Tunnel'))
+    l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
+
+
+class RelatedInterfacesPanel(panels.ObjectAttributesPanel):
+    title = _('Related Interfaces')
+
+    parent = attrs.RelatedObjectAttr('parent', linkify=True)
+    bridge = attrs.RelatedObjectAttr('bridge', linkify=True)
+    lag = attrs.RelatedObjectAttr('lag', linkify=True, label=_('LAG'))
+
+
+class InterfaceAddressingPanel(panels.ObjectAttributesPanel):
+    title = _('Addressing')
+
+    mac_address = attrs.TemplatedAttr(
+        'primary_mac_address',
+        template_name='dcim/interface/attrs/mac_address.html',
+        label=_('MAC address'),
+    )
+    wwn = attrs.TextAttr('wwn', style='font-monospace', label=_('WWN'))
+    vrf = attrs.RelatedObjectAttr('vrf', linkify=True, label=_('VRF'))
+    vlan_translation = attrs.RelatedObjectAttr('vlan_translation_policy', linkify=True, label=_('VLAN translation'))
+
+
+class InterfaceConnectionPanel(panels.ObjectPanel):
+    """
+    A connection panel for interfaces, which handles cable, wireless link, and virtual circuit cases.
+    """
+    template_name = 'dcim/panels/interface_connection.html'
+    title = _('Connection')
+
+    def render(self, context):
+        obj = context.get('object')
+        if obj and obj.is_virtual:
+            return ''
+        ctx = self.get_context(context)
+        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+
+
+class VirtualCircuitPanel(panels.ObjectPanel):
+    """
+    A panel which displays virtual circuit information for a virtual interface.
+    """
+    template_name = 'dcim/panels/interface_virtual_circuit.html'
+    title = _('Virtual Circuit')
+
+    def render(self, context):
+        obj = context.get('object')
+        if not obj or not obj.is_virtual or not hasattr(obj, 'virtual_circuit_termination'):
+            return ''
+        ctx = self.get_context(context)
+        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+
+
+class InterfaceWirelessPanel(panels.ObjectPanel):
+    """
+    A panel which displays wireless RF attributes for an interface, comparing local and peer values.
+    """
+    template_name = 'dcim/panels/interface_wireless.html'
+    title = _('Wireless')
+
+    def render(self, context):
+        obj = context.get('object')
+        if not obj or not obj.is_wireless:
+            return ''
+        ctx = self.get_context(context)
+        return render_to_string(self.template_name, ctx, request=ctx.get('request'))
+
+
+class WirelessLANsPanel(panels.ObjectPanel):
+    """
+    A panel which lists the wireless LANs associated with an interface.
+    """
+    template_name = 'dcim/panels/interface_wireless_lans.html'
+    title = _('Wireless LANs')
+
+    def render(self, context):
+        obj = context.get('object')
+        if not obj or not obj.is_wireless:
+            return ''
+        ctx = self.get_context(context)
+        return render_to_string(self.template_name, ctx, request=ctx.get('request'))

+ 321 - 18
netbox/dcim/views.py

@@ -17,10 +17,12 @@ from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
 from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
 from ipam.tables import VLANTranslationRuleTable
 from ipam.tables import VLANTranslationRuleTable
+from ipam.ui.panels import FHRPGroupAssignmentsPanel
 from netbox.object_actions import *
 from netbox.object_actions import *
 from netbox.ui import actions, layout
 from netbox.ui import actions, layout
 from netbox.ui.panels import (
 from netbox.ui.panels import (
     CommentsPanel,
     CommentsPanel,
+    ContextTablePanel,
     JSONPanel,
     JSONPanel,
     NestedGroupObjectPanel,
     NestedGroupObjectPanel,
     ObjectsTablePanel,
     ObjectsTablePanel,
@@ -1664,7 +1666,7 @@ class ModuleTypeProfileListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(ModuleTypeProfile)
 @register_model_view(ModuleTypeProfile)
-class ModuleTypeProfileView(GetRelatedModelsMixin, generic.ObjectView):
+class ModuleTypeProfileView(generic.ObjectView):
     template_name = 'generic/object.html'
     template_name = 'generic/object.html'
     queryset = ModuleTypeProfile.objects.all()
     queryset = ModuleTypeProfile.objects.all()
     layout = layout.SimpleLayout(
     layout = layout.SimpleLayout(
@@ -2642,6 +2644,7 @@ class DeviceView(generic.ObjectView):
             vc_members = []
             vc_members = []
 
 
         return {
         return {
+            'virtual_chassis': instance.virtual_chassis,
             'vc_members': vc_members,
             'vc_members': vc_members,
             'svg_extra': f'highlight=id:{instance.pk}',
             'svg_extra': f'highlight=id:{instance.pk}',
         }
         }
@@ -2994,6 +2997,28 @@ class ConsolePortListView(generic.ObjectListView):
 @register_model_view(ConsolePort)
 @register_model_view(ConsolePort)
 class ConsolePortView(generic.ObjectView):
 class ConsolePortView(generic.ObjectView):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ConsolePortPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:consoleport_trace',
+                connect_options=[
+                    {
+                        'a_type': 'dcim.consoleport',
+                        'b_type': 'dcim.consoleserverport',
+                        'label': _('Console Server Port'),
+                    },
+                    {'a_type': 'dcim.consoleport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
+                    {'a_type': 'dcim.consoleport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
+                ],
+            ),
+            panels.InventoryItemsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(ConsolePort, 'add', detail=False)
 @register_model_view(ConsolePort, 'add', detail=False)
@@ -3065,6 +3090,24 @@ class ConsoleServerPortListView(generic.ObjectListView):
 @register_model_view(ConsoleServerPort)
 @register_model_view(ConsoleServerPort)
 class ConsoleServerPortView(generic.ObjectView):
 class ConsoleServerPortView(generic.ObjectView):
     queryset = ConsoleServerPort.objects.all()
     queryset = ConsoleServerPort.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ConsoleServerPortPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:consoleserverport_trace',
+                connect_options=[
+                    {'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.consoleport', 'label': _('Console Port')},
+                    {'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
+                    {'a_type': 'dcim.consoleserverport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
+                ],
+            ),
+            panels.InventoryItemsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(ConsoleServerPort, 'add', detail=False)
 @register_model_view(ConsoleServerPort, 'add', detail=False)
@@ -3136,6 +3179,23 @@ class PowerPortListView(generic.ObjectListView):
 @register_model_view(PowerPort)
 @register_model_view(PowerPort)
 class PowerPortView(generic.ObjectView):
 class PowerPortView(generic.ObjectView):
     queryset = PowerPort.objects.all()
     queryset = PowerPort.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.PowerPortPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:powerport_trace',
+                connect_options=[
+                    {'a_type': 'dcim.powerport', 'b_type': 'dcim.poweroutlet', 'label': _('Power Outlet')},
+                    {'a_type': 'dcim.powerport', 'b_type': 'dcim.powerfeed', 'label': _('Power Feed')},
+                ],
+            ),
+            panels.InventoryItemsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(PowerPort, 'add', detail=False)
 @register_model_view(PowerPort, 'add', detail=False)
@@ -3207,6 +3267,22 @@ class PowerOutletListView(generic.ObjectListView):
 @register_model_view(PowerOutlet)
 @register_model_view(PowerOutlet)
 class PowerOutletView(generic.ObjectView):
 class PowerOutletView(generic.ObjectView):
     queryset = PowerOutlet.objects.all()
     queryset = PowerOutlet.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.PowerOutletPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:poweroutlet_trace',
+                connect_options=[
+                    {'a_type': 'dcim.poweroutlet', 'b_type': 'dcim.powerport', 'label': _('Power Port')},
+                ],
+            ),
+            panels.InventoryItemsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(PowerOutlet, 'add', detail=False)
 @register_model_view(PowerOutlet, 'add', detail=False)
@@ -3278,6 +3354,45 @@ class InterfaceListView(generic.ObjectListView):
 @register_model_view(Interface)
 @register_model_view(Interface)
 class InterfaceView(generic.ObjectView):
 class InterfaceView(generic.ObjectView):
     queryset = Interface.objects.all()
     queryset = Interface.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.InterfacePanel(),
+            panels.RelatedInterfacesPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            ContextTablePanel('vdc_table', title=_('Virtual Device Contexts')),
+            panels.InterfaceAddressingPanel(),
+            panels.VirtualCircuitPanel(),
+            panels.InterfaceConnectionPanel(),
+            panels.InterfaceWirelessPanel(),
+            panels.WirelessLANsPanel(),
+            FHRPGroupAssignmentsPanel(),
+            panels.InventoryItemsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='ipam.IPAddress',
+                filters={'interface_id': lambda ctx: ctx['object'].pk},
+                title=_('IP Addresses'),
+            ),
+            ObjectsTablePanel(
+                model='dcim.MACAddress',
+                filters={'interface_id': lambda ctx: ctx['object'].pk},
+                title=_('MAC Addresses'),
+            ),
+            ObjectsTablePanel(
+                model='ipam.VLAN',
+                filters={'interface_id': lambda ctx: ctx['object'].pk},
+                title=_('VLANs'),
+            ),
+            ContextTablePanel('lag_interfaces_table', title=_('LAG Members')),
+            ContextTablePanel('vlan_translation_table', title=_('VLAN Translation')),
+            ContextTablePanel('bridge_interfaces_table', title=_('Bridged Interfaces')),
+            ContextTablePanel('child_interfaces_table', title=_('Child Interfaces')),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Get assigned VDCs
         # Get assigned VDCs
@@ -3292,30 +3407,29 @@ class InterfaceView(generic.ObjectView):
         vdc_table.configure(request)
         vdc_table.configure(request)
 
 
         # Get bridge interfaces
         # Get bridge interfaces
-        bridge_interfaces = Interface.objects.restrict(request.user, 'view').filter(bridge=instance)
         bridge_interfaces_table = tables.InterfaceTable(
         bridge_interfaces_table = tables.InterfaceTable(
-            bridge_interfaces,
+            Interface.objects.restrict(request.user, 'view').filter(bridge=instance),
             exclude=('device', 'parent'),
             exclude=('device', 'parent'),
             orderable=False
             orderable=False
         )
         )
         bridge_interfaces_table.configure(request)
         bridge_interfaces_table.configure(request)
 
 
         # Get child interfaces
         # Get child interfaces
-        child_interfaces = Interface.objects.restrict(request.user, 'view').filter(parent=instance)
         child_interfaces_table = tables.InterfaceTable(
         child_interfaces_table = tables.InterfaceTable(
-            child_interfaces,
+            Interface.objects.restrict(request.user, 'view').filter(parent=instance),
             exclude=('device', 'parent'),
             exclude=('device', 'parent'),
             orderable=False
             orderable=False
         )
         )
         child_interfaces_table.configure(request)
         child_interfaces_table.configure(request)
 
 
-        # Get LAG interfaces
-        lag_interfaces = Interface.objects.restrict(request.user, 'view').filter(lag=instance)
-        lag_interfaces_table = tables.InterfaceLAGMemberTable(
-            lag_interfaces,
-            orderable=False
-        )
-        lag_interfaces_table.configure(request)
+        # Get LAG members (only for LAG interfaces)
+        lag_interfaces_table = None
+        if instance.is_lag:
+            lag_interfaces_table = tables.InterfaceLAGMemberTable(
+                Interface.objects.restrict(request.user, 'view').filter(lag=instance),
+                orderable=False
+            )
+            lag_interfaces_table.configure(request)
 
 
         # Get VLAN translation rules
         # Get VLAN translation rules
         vlan_translation_table = None
         vlan_translation_table = None
@@ -3328,7 +3442,6 @@ class InterfaceView(generic.ObjectView):
 
 
         return {
         return {
             'vdc_table': vdc_table,
             'vdc_table': vdc_table,
-            'bridge_interfaces': bridge_interfaces,
             'bridge_interfaces_table': bridge_interfaces_table,
             'bridge_interfaces_table': bridge_interfaces_table,
             'child_interfaces_table': child_interfaces_table,
             'child_interfaces_table': child_interfaces_table,
             'lag_interfaces_table': lag_interfaces_table,
             'lag_interfaces_table': lag_interfaces_table,
@@ -3416,6 +3529,33 @@ class FrontPortListView(generic.ObjectListView):
 @register_model_view(FrontPort)
 @register_model_view(FrontPort)
 class FrontPortView(generic.ObjectView):
 class FrontPortView(generic.ObjectView):
     queryset = FrontPort.objects.all()
     queryset = FrontPort.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.FrontPortPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            panels.InventoryItemsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:frontport_trace',
+                show_endpoints=False,
+                connect_options=[
+                    {'a_type': 'dcim.frontport', 'b_type': 'dcim.interface', 'label': _('Interface')},
+                    {'a_type': 'dcim.frontport', 'b_type': 'dcim.consoleserverport', 'label': _('Console Server Port')},
+                    {'a_type': 'dcim.frontport', 'b_type': 'dcim.consoleport', 'label': _('Console Port')},
+                    {'a_type': 'dcim.frontport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
+                    {'a_type': 'dcim.frontport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
+                    {
+                        'a_type': 'dcim.frontport',
+                        'b_type': 'circuits.circuittermination',
+                        'label': _('Circuit Termination'),
+                    },
+                ],
+            ),
+            TemplatePanel('dcim/panels/front_port_mappings.html'),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -3492,6 +3632,31 @@ class RearPortListView(generic.ObjectListView):
 @register_model_view(RearPort)
 @register_model_view(RearPort)
 class RearPortView(generic.ObjectView):
 class RearPortView(generic.ObjectView):
     queryset = RearPort.objects.all()
     queryset = RearPort.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RearPortPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            panels.InventoryItemsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:rearport_trace',
+                show_endpoints=False,
+                connect_options=[
+                    {'a_type': 'dcim.rearport', 'b_type': 'dcim.interface', 'label': _('Interface')},
+                    {'a_type': 'dcim.rearport', 'b_type': 'dcim.frontport', 'label': _('Front Port')},
+                    {'a_type': 'dcim.rearport', 'b_type': 'dcim.rearport', 'label': _('Rear Port')},
+                    {
+                        'a_type': 'dcim.rearport',
+                        'b_type': 'circuits.circuittermination',
+                        'label': _('Circuit Termination'),
+                    },
+                ],
+            ),
+            TemplatePanel('dcim/panels/rear_port_mappings.html'),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -3568,6 +3733,19 @@ class ModuleBayListView(generic.ObjectListView):
 @register_model_view(ModuleBay)
 @register_model_view(ModuleBay)
 class ModuleBayView(generic.ObjectView):
 class ModuleBayView(generic.ObjectView):
     queryset = ModuleBay.objects.all()
     queryset = ModuleBay.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ModuleBayPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            Panel(
+                title=_('Installed Module'),
+                template_name='dcim/panels/installed_module.html',
+            ),
+        ],
+    )
 
 
 
 
 @register_model_view(ModuleBay, 'add', detail=False)
 @register_model_view(ModuleBay, 'add', detail=False)
@@ -3630,6 +3808,19 @@ class DeviceBayListView(generic.ObjectListView):
 @register_model_view(DeviceBay)
 @register_model_view(DeviceBay)
 class DeviceBayView(generic.ObjectView):
 class DeviceBayView(generic.ObjectView):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.DeviceBayPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            Panel(
+                title=_('Installed Device'),
+                template_name='dcim/panels/installed_device.html',
+            ),
+        ],
+    )
 
 
 
 
 @register_model_view(DeviceBay, 'add', detail=False)
 @register_model_view(DeviceBay, 'add', detail=False)
@@ -3773,6 +3964,13 @@ class InventoryItemListView(generic.ObjectListView):
 @register_model_view(InventoryItem)
 @register_model_view(InventoryItem)
 class InventoryItemView(generic.ObjectView):
 class InventoryItemView(generic.ObjectView):
     queryset = InventoryItem.objects.all()
     queryset = InventoryItem.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.InventoryItemPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(InventoryItem, 'edit')
 @register_model_view(InventoryItem, 'edit')
@@ -3854,12 +4052,23 @@ class InventoryItemRoleListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(InventoryItemRole)
 @register_model_view(InventoryItemRole)
-class InventoryItemRoleView(generic.ObjectView):
+class InventoryItemRoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = InventoryItemRole.objects.all()
     queryset = InventoryItemRole.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.InventoryItemRolePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
-            'inventoryitem_count': InventoryItem.objects.filter(role=instance).count(),
+            'related_models': self.get_related_models(request, instance),
         }
         }
 
 
 
 
@@ -4093,6 +4302,24 @@ class CableListView(generic.ObjectListView):
 @register_model_view(Cable)
 @register_model_view(Cable)
 class CableView(generic.ObjectView):
 class CableView(generic.ObjectView):
     queryset = Cable.objects.all()
     queryset = Cable.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CablePanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            Panel(
+                title=_('Termination A'),
+                template_name='dcim/panels/cable_termination_a.html',
+            ),
+            Panel(
+                title=_('Termination B'),
+                template_name='dcim/panels/cable_termination_b.html',
+            ),
+        ],
+    )
 
 
 
 
 @register_model_view(Cable, 'add', detail=False)
 @register_model_view(Cable, 'add', detail=False)
@@ -4225,12 +4452,23 @@ class VirtualChassisListView(generic.ObjectListView):
 @register_model_view(VirtualChassis)
 @register_model_view(VirtualChassis)
 class VirtualChassisView(generic.ObjectView):
 class VirtualChassisView(generic.ObjectView):
     queryset = VirtualChassis.objects.all()
     queryset = VirtualChassis.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualChassisPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+        ],
+        right_panels=[
+            panels.VirtualChassisMembersPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        members = Device.objects.restrict(request.user).filter(virtual_chassis=instance)
-
+        vc_members = Device.objects.restrict(request.user).filter(virtual_chassis=instance).order_by('vc_position')
         return {
         return {
-            'members': members,
+            'virtual_chassis': instance,
+            'vc_members': vc_members,
         }
         }
 
 
 
 
@@ -4470,6 +4708,27 @@ class PowerPanelListView(generic.ObjectListView):
 @register_model_view(PowerPanel)
 @register_model_view(PowerPanel)
 class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
 class PowerPanelView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = PowerPanel.objects.all()
     queryset = PowerPanel.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.PowerPanelPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            ImageAttachmentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.PowerFeed',
+                filters={'power_panel_id': lambda ctx: ctx['object'].pk},
+                actions=[
+                    actions.AddObject('dcim.PowerFeed', url_params={'power_panel': lambda ctx: ctx['object'].pk}),
+                ],
+            ),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -4533,6 +4792,23 @@ class PowerFeedListView(generic.ObjectListView):
 @register_model_view(PowerFeed)
 @register_model_view(PowerFeed)
 class PowerFeedView(generic.ObjectView):
 class PowerFeedView(generic.ObjectView):
     queryset = PowerFeed.objects.all()
     queryset = PowerFeed.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.PowerFeedPanel(),
+            panels.PowerFeedElectricalPanel(),
+            CustomFieldsPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            panels.ConnectionPanel(
+                trace_url_name='dcim:powerfeed_trace',
+                connect_options=[
+                    {'a_type': 'dcim.powerfeed', 'b_type': 'dcim.powerport', 'label': _('Power Port')},
+                ],
+            ),
+            CommentsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(PowerFeed, 'add', detail=False)
 @register_model_view(PowerFeed, 'add', detail=False)
@@ -4601,6 +4877,23 @@ class VirtualDeviceContextListView(generic.ObjectListView):
 @register_model_view(VirtualDeviceContext)
 @register_model_view(VirtualDeviceContext)
 class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
 class VirtualDeviceContextView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VirtualDeviceContext.objects.all()
     queryset = VirtualDeviceContext.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VirtualDeviceContextPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                model='dcim.Interface',
+                filters={'vdc_id': lambda ctx: ctx['object'].pk},
+            ),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -4669,6 +4962,16 @@ class MACAddressListView(generic.ObjectListView):
 @register_model_view(MACAddress)
 @register_model_view(MACAddress)
 class MACAddressView(generic.ObjectView):
 class MACAddressView(generic.ObjectView):
     queryset = MACAddress.objects.all()
     queryset = MACAddress.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.MACAddressPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+        ],
+        right_panels=[
+            CommentsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(MACAddress, 'add', detail=False)
 @register_model_view(MACAddress, 'add', detail=False)

+ 48 - 2
netbox/extras/api/customfields.py

@@ -2,7 +2,7 @@ from django.utils.translation import gettext as _
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.types import OpenApiTypes
 from drf_spectacular.utils import extend_schema_field
 from drf_spectacular.utils import extend_schema_field
 from rest_framework.fields import Field
 from rest_framework.fields import Field
-from rest_framework.serializers import ValidationError
+from rest_framework.serializers import ListSerializer, ValidationError
 
 
 from extras.choices import CustomFieldTypeChoices
 from extras.choices import CustomFieldTypeChoices
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
@@ -49,8 +49,25 @@ class CustomFieldsDataField(Field):
         # TODO: Fix circular import
         # TODO: Fix circular import
         from utilities.api import get_serializer_for_model
         from utilities.api import get_serializer_for_model
         data = {}
         data = {}
+        cache = self.parent.context.get('cf_object_cache')
+
         for cf in self._get_custom_fields():
         for cf in self._get_custom_fields():
-            value = cf.deserialize(obj.get(cf.name))
+            if cache is not None and cf.type in (
+                CustomFieldTypeChoices.TYPE_OBJECT,
+                CustomFieldTypeChoices.TYPE_MULTIOBJECT,
+            ):
+                raw = obj.get(cf.name)
+                if raw is None:
+                    value = None
+                elif cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
+                    model = cf.related_object_type.model_class()
+                    value = cache.get((model, raw))
+                else:
+                    model = cf.related_object_type.model_class()
+                    value = [cache[(model, pk)] for pk in raw if (model, pk) in cache] or None
+            else:
+                value = cf.deserialize(obj.get(cf.name))
+
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
             if value is not None and cf.type == CustomFieldTypeChoices.TYPE_OBJECT:
                 serializer = get_serializer_for_model(cf.related_object_type.model_class())
                 serializer = get_serializer_for_model(cf.related_object_type.model_class())
                 value = serializer(value, nested=True, context=self.parent.context).data
                 value = serializer(value, nested=True, context=self.parent.context).data
@@ -87,3 +104,32 @@ class CustomFieldsDataField(Field):
             data = {**self.parent.instance.custom_field_data, **data}
             data = {**self.parent.instance.custom_field_data, **data}
 
 
         return data
         return data
+
+
+class CustomFieldListSerializer(ListSerializer):
+    """
+    ListSerializer that pre-fetches all OBJECT/MULTIOBJECT custom field related objects
+    in bulk before per-item serialization.
+    """
+    def to_representation(self, data):
+        cf_field = self.child.fields.get('custom_fields')
+        if isinstance(cf_field, CustomFieldsDataField):
+            object_type_cfs = [
+                cf for cf in cf_field._get_custom_fields()
+                if cf.type in (CustomFieldTypeChoices.TYPE_OBJECT, CustomFieldTypeChoices.TYPE_MULTIOBJECT)
+            ]
+            cache = {}
+            for cf in object_type_cfs:
+                model = cf.related_object_type.model_class()
+                pks = set()
+                for item in data:
+                    raw = item.custom_field_data.get(cf.name)
+                    if raw is not None:
+                        if cf.type == CustomFieldTypeChoices.TYPE_MULTIOBJECT:
+                            pks.update(raw)
+                        else:
+                            pks.add(raw)
+                for obj in model.objects.filter(pk__in=pks):
+                    cache[(model, obj.pk)] = obj
+            self.child.context['cf_object_cache'] = cache
+        return super().to_representation(data)

+ 1 - 1
netbox/extras/models/customfields.py

@@ -77,7 +77,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)):
                 return custom_fields
                 return custom_fields
 
 
         content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
         content_type = ObjectType.objects.get_for_model(model._meta.concrete_model)
-        custom_fields = self.get_queryset().filter(object_types=content_type)
+        custom_fields = self.get_queryset().filter(object_types=content_type).select_related('related_object_type')
 
 
         # Populate the request cache to avoid redundant lookups
         # Populate the request cache to avoid redundant lookups
         if cache is not None:
         if cache is not None:

+ 2 - 1
netbox/extras/tables/tables.py

@@ -514,8 +514,9 @@ class EventRuleTable(NetBoxTable):
         verbose_name=_('Type'),
         verbose_name=_('Type'),
     )
     )
     action_object = tables.Column(
     action_object = tables.Column(
-        linkify=True,
         verbose_name=_('Object'),
         verbose_name=_('Object'),
+        orderable=False,
+        linkify=True,
     )
     )
     object_types = columns.ContentTypesColumn(
     object_types = columns.ContentTypesColumn(
         verbose_name=_('Object Types'),
         verbose_name=_('Object Types'),

+ 24 - 0
netbox/extras/tests/test_tables.py

@@ -0,0 +1,24 @@
+from django.test import RequestFactory, TestCase, tag
+
+from extras.models import EventRule
+from extras.tables import EventRuleTable
+
+
+@tag('regression')
+class EventRuleTableTest(TestCase):
+    def test_every_orderable_field_does_not_throw_exception(self):
+        rule = EventRule.objects.all()
+        disallowed = {
+            'actions',
+        }
+
+        orderable_columns = [
+            column.name for column in EventRuleTable(rule).columns if column.orderable and column.name not in disallowed
+        ]
+        fake_request = RequestFactory().get('/')
+
+        for col in orderable_columns:
+            for direction in ('-', ''):
+                table = EventRuleTable(rule)
+                table.order_by = f'{direction}{col}'
+                table.as_html(fake_request)

+ 441 - 1
netbox/extras/ui/panels.py

@@ -2,16 +2,55 @@ from django.contrib.contenttypes.models import ContentType
 from django.template.loader import render_to_string
 from django.template.loader import render_to_string
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from netbox.ui import actions, panels
+from netbox.ui import actions, attrs, panels
 from utilities.data import resolve_attr_path
 from utilities.data import resolve_attr_path
 
 
 __all__ = (
 __all__ = (
+    'ConfigContextAssignmentPanel',
+    'ConfigContextPanel',
+    'ConfigContextProfilePanel',
+    'ConfigTemplatePanel',
+    'CustomFieldBehaviorPanel',
+    'CustomFieldChoiceSetChoicesPanel',
+    'CustomFieldChoiceSetPanel',
+    'CustomFieldObjectTypesPanel',
+    'CustomFieldPanel',
+    'CustomFieldRelatedObjectsPanel',
+    'CustomFieldValidationPanel',
     'CustomFieldsPanel',
     'CustomFieldsPanel',
+    'CustomLinkPanel',
+    'EventRuleActionPanel',
+    'EventRuleEventTypesPanel',
+    'EventRulePanel',
+    'ExportTemplatePanel',
+    'ImageAttachmentFilePanel',
+    'ImageAttachmentImagePanel',
+    'ImageAttachmentPanel',
     'ImageAttachmentsPanel',
     'ImageAttachmentsPanel',
+    'JournalEntryPanel',
+    'NotificationGroupGroupsPanel',
+    'NotificationGroupPanel',
+    'NotificationGroupUsersPanel',
+    'ObjectTypesPanel',
+    'SavedFilterObjectTypesPanel',
+    'SavedFilterPanel',
+    'TableConfigColumnsPanel',
+    'TableConfigOrderingPanel',
+    'TableConfigPanel',
+    'TagItemTypesPanel',
+    'TagObjectTypesPanel',
+    'TagPanel',
     'TagsPanel',
     'TagsPanel',
+    'WebhookHTTPPanel',
+    'WebhookPanel',
+    'WebhookSSLPanel',
 )
 )
 
 
 
 
+#
+# Generic panels
+#
+
 class CustomFieldsPanel(panels.ObjectPanel):
 class CustomFieldsPanel(panels.ObjectPanel):
     """
     """
     A panel showing the value of all custom fields defined on an object.
     A panel showing the value of all custom fields defined on an object.
@@ -73,3 +112,404 @@ class TagsPanel(panels.ObjectPanel):
             **super().get_context(context),
             **super().get_context(context),
             'object': resolve_attr_path(context, self.accessor),
             'object': resolve_attr_path(context, self.accessor),
         }
         }
+
+
+class ObjectTypesPanel(panels.ObjectPanel):
+    """
+    A panel listing the object types assigned to the object.
+    """
+    template_name = 'extras/panels/object_types.html'
+    title = _('Object Types')
+
+
+#
+# CustomField panels
+#
+
+class CustomFieldPanel(panels.ObjectAttributesPanel):
+    title = _('Custom Field')
+
+    name = attrs.TextAttr('name')
+    type = attrs.TemplatedAttr('type', label=_('Type'), template_name='extras/customfield/attrs/type.html')
+    label = attrs.TextAttr('label')
+    group_name = attrs.TextAttr('group_name', label=_('Group name'))
+    description = attrs.TextAttr('description')
+    required = attrs.BooleanAttr('required')
+    unique = attrs.BooleanAttr('unique', label=_('Must be unique'))
+    is_cloneable = attrs.BooleanAttr('is_cloneable', label=_('Cloneable'))
+    choice_set = attrs.TemplatedAttr(
+        'choice_set',
+        template_name='extras/customfield/attrs/choice_set.html',
+    )
+    default = attrs.TextAttr('default', label=_('Default value'))
+    related_object_filter = attrs.TemplatedAttr(
+        'related_object_filter',
+        template_name='extras/customfield/attrs/related_object_filter.html',
+    )
+
+
+class CustomFieldBehaviorPanel(panels.ObjectAttributesPanel):
+    title = _('Behavior')
+
+    search_weight = attrs.TemplatedAttr(
+        'search_weight',
+        template_name='extras/customfield/attrs/search_weight.html',
+    )
+    filter_logic = attrs.ChoiceAttr('filter_logic')
+    weight = attrs.NumericAttr('weight', label=_('Display weight'))
+    ui_visible = attrs.ChoiceAttr('ui_visible', label=_('UI visible'))
+    ui_editable = attrs.ChoiceAttr('ui_editable', label=_('UI editable'))
+
+
+class CustomFieldValidationPanel(panels.ObjectAttributesPanel):
+    title = _('Validation Rules')
+
+    validation_minimum = attrs.NumericAttr('validation_minimum', label=_('Minimum value'))
+    validation_maximum = attrs.NumericAttr('validation_maximum', label=_('Maximum value'))
+    validation_regex = attrs.TextAttr(
+        'validation_regex',
+        label=_('Regular expression'),
+        style='font-monospace',
+    )
+
+
+class CustomFieldObjectTypesPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/object_types.html'
+    title = _('Object Types')
+
+
+class CustomFieldRelatedObjectsPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/customfield_related_objects.html'
+    title = _('Related Objects')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'related_models': context.get('related_models'),
+        }
+
+
+#
+# CustomFieldChoiceSet panels
+#
+
+class CustomFieldChoiceSetPanel(panels.ObjectAttributesPanel):
+    title = _('Custom Field Choice Set')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    base_choices = attrs.ChoiceAttr('base_choices')
+    order_alphabetically = attrs.BooleanAttr('order_alphabetically')
+    choices_for = attrs.RelatedObjectListAttr('choices_for', linkify=True, label=_('Used by'))
+
+
+class CustomFieldChoiceSetChoicesPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/customfieldchoiceset_choices.html'
+
+    def get_context(self, context):
+        obj = context.get('object')
+        total = len(obj.choices) if obj else 0
+        return {
+            **super().get_context(context),
+            'title': f'{_("Choices")} ({total})',
+            'choices': context.get('choices'),
+        }
+
+
+#
+# CustomLink panels
+#
+
+class CustomLinkPanel(panels.ObjectAttributesPanel):
+    title = _('Custom Link')
+
+    name = attrs.TextAttr('name')
+    enabled = attrs.BooleanAttr('enabled')
+    group_name = attrs.TextAttr('group_name')
+    weight = attrs.NumericAttr('weight')
+    button_class = attrs.ChoiceAttr('button_class')
+    new_window = attrs.BooleanAttr('new_window')
+
+
+#
+# ExportTemplate panels
+#
+
+class ExportTemplatePanel(panels.ObjectAttributesPanel):
+    title = _('Export Template')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
+    file_name = attrs.TextAttr('file_name')
+    file_extension = attrs.TextAttr('file_extension')
+    as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
+
+
+#
+# SavedFilter panels
+#
+
+class SavedFilterPanel(panels.ObjectAttributesPanel):
+    title = _('Saved Filter')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    user = attrs.TextAttr('user')
+    enabled = attrs.BooleanAttr('enabled')
+    shared = attrs.BooleanAttr('shared')
+    weight = attrs.NumericAttr('weight')
+
+
+class SavedFilterObjectTypesPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/savedfilter_object_types.html'
+    title = _('Assigned Models')
+
+
+#
+# TableConfig panels
+#
+
+class TableConfigPanel(panels.ObjectAttributesPanel):
+    title = _('Table Config')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    object_type = attrs.TextAttr('object_type')
+    table = attrs.TextAttr('table')
+    user = attrs.TextAttr('user')
+    enabled = attrs.BooleanAttr('enabled')
+    shared = attrs.BooleanAttr('shared')
+    weight = attrs.NumericAttr('weight')
+
+
+class TableConfigColumnsPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/tableconfig_columns.html'
+    title = _('Columns Displayed')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'columns': context.get('columns'),
+        }
+
+
+class TableConfigOrderingPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/tableconfig_ordering.html'
+    title = _('Ordering')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'columns': context.get('columns'),
+        }
+
+
+#
+# NotificationGroup panels
+#
+
+class NotificationGroupPanel(panels.ObjectAttributesPanel):
+    title = _('Notification Group')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+
+
+class NotificationGroupGroupsPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/notificationgroup_groups.html'
+    title = _('Groups')
+
+
+class NotificationGroupUsersPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/notificationgroup_users.html'
+    title = _('Users')
+
+
+#
+# Webhook panels
+#
+
+class WebhookPanel(panels.ObjectAttributesPanel):
+    title = _('Webhook')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+
+
+class WebhookHTTPPanel(panels.ObjectAttributesPanel):
+    title = _('HTTP Request')
+
+    http_method = attrs.ChoiceAttr('http_method', label=_('HTTP method'))
+    payload_url = attrs.TextAttr('payload_url', label=_('Payload URL'), style='font-monospace')
+    http_content_type = attrs.TextAttr('http_content_type', label=_('HTTP content type'))
+    secret = attrs.TextAttr('secret')
+
+
+class WebhookSSLPanel(panels.ObjectAttributesPanel):
+    title = _('SSL')
+
+    ssl_verification = attrs.BooleanAttr('ssl_verification', label=_('SSL verification'))
+    ca_file_path = attrs.TextAttr('ca_file_path', label=_('CA file path'))
+
+
+#
+# EventRule panels
+#
+
+class EventRulePanel(panels.ObjectAttributesPanel):
+    title = _('Event Rule')
+
+    name = attrs.TextAttr('name')
+    enabled = attrs.BooleanAttr('enabled')
+    description = attrs.TextAttr('description')
+
+
+class EventRuleEventTypesPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/eventrule_event_types.html'
+    title = _('Event Types')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'registry': context.get('registry'),
+        }
+
+
+class EventRuleActionPanel(panels.ObjectAttributesPanel):
+    title = _('Action')
+
+    action_type = attrs.ChoiceAttr('action_type', label=_('Type'))
+    action_object = attrs.RelatedObjectAttr('action_object', linkify=True, label=_('Object'))
+    action_data = attrs.TemplatedAttr(
+        'action_data',
+        label=_('Data'),
+        template_name='extras/eventrule/attrs/action_data.html',
+    )
+
+
+#
+# Tag panels
+#
+
+class TagPanel(panels.ObjectAttributesPanel):
+    title = _('Tag')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    color = attrs.ColorAttr('color')
+    weight = attrs.NumericAttr('weight')
+    tagged_items = attrs.TemplatedAttr(
+        'extras_taggeditem_items',
+        template_name='extras/tag/attrs/tagged_item_count.html',
+    )
+
+
+class TagObjectTypesPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/tag_object_types.html'
+    title = _('Allowed Object Types')
+
+
+class TagItemTypesPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/tag_item_types.html'
+    title = _('Tagged Item Types')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'object_types': context.get('object_types'),
+        }
+
+
+#
+# ConfigContextProfile panels
+#
+
+class ConfigContextProfilePanel(panels.ObjectAttributesPanel):
+    title = _('Config Context Profile')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+
+
+#
+# ConfigContext panels
+#
+
+class ConfigContextPanel(panels.ObjectAttributesPanel):
+    title = _('Config Context')
+
+    name = attrs.TextAttr('name')
+    weight = attrs.NumericAttr('weight')
+    profile = attrs.RelatedObjectAttr('profile', linkify=True)
+    description = attrs.TextAttr('description')
+    is_active = attrs.BooleanAttr('is_active', label=_('Active'))
+
+
+class ConfigContextAssignmentPanel(panels.ObjectPanel):
+    template_name = 'extras/panels/configcontext_assignment.html'
+    title = _('Assignment')
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'assigned_objects': context.get('assigned_objects'),
+        }
+
+
+#
+# ConfigTemplate panels
+#
+
+class ConfigTemplatePanel(panels.ObjectAttributesPanel):
+    title = _('Config Template')
+
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    mime_type = attrs.TextAttr('mime_type', label=_('MIME type'))
+    file_name = attrs.TextAttr('file_name')
+    file_extension = attrs.TextAttr('file_extension')
+    as_attachment = attrs.BooleanAttr('as_attachment', label=_('Attachment'))
+    debug = attrs.BooleanAttr('debug')
+    data_source = attrs.RelatedObjectAttr('data_source', linkify=True)
+    data_file = attrs.TemplatedAttr(
+        'data_path',
+        template_name='extras/configtemplate/attrs/data_file.html',
+    )
+    data_synced = attrs.DateTimeAttr('data_synced')
+    auto_sync_enabled = attrs.BooleanAttr('auto_sync_enabled')
+
+
+#
+# ImageAttachment panels
+#
+
+class ImageAttachmentPanel(panels.ObjectAttributesPanel):
+    title = _('Image Attachment')
+
+    parent = attrs.RelatedObjectAttr('parent', linkify=True, label=_('Parent object'))
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+
+
+class ImageAttachmentFilePanel(panels.ObjectPanel):
+    template_name = 'extras/panels/imageattachment_file.html'
+    title = _('File')
+
+
+class ImageAttachmentImagePanel(panels.ObjectPanel):
+    template_name = 'extras/panels/imageattachment_image.html'
+    title = _('Image')
+
+
+#
+# JournalEntry panels
+#
+
+class JournalEntryPanel(panels.ObjectAttributesPanel):
+    title = _('Journal Entry')
+
+    assigned_object = attrs.RelatedObjectAttr('assigned_object', linkify=True, label=_('Object'))
+    created = attrs.DateTimeAttr('created', spec='minutes')
+    created_by = attrs.TextAttr('created_by')
+    kind = attrs.ChoiceAttr('kind')

+ 173 - 1
netbox/extras/views.py

@@ -10,7 +10,7 @@ from django.shortcuts import get_object_or_404, redirect, render
 from django.urls import reverse
 from django.urls import reverse
 from django.utils import timezone
 from django.utils import timezone
 from django.utils.module_loading import import_string
 from django.utils.module_loading import import_string
-from django.utils.translation import gettext as _
+from django.utils.translation import gettext_lazy as _
 from django.views.generic import View
 from django.views.generic import View
 
 
 from core.choices import ManagedFileRootPathChoices
 from core.choices import ManagedFileRootPathChoices
@@ -22,6 +22,14 @@ from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from extras.dashboard.utils import get_widget_class
 from extras.utils import SharedObjectViewMixin
 from extras.utils import SharedObjectViewMixin
 from netbox.object_actions import *
 from netbox.object_actions import *
+from netbox.ui import layout
+from netbox.ui.panels import (
+    CommentsPanel,
+    ContextTablePanel,
+    JSONPanel,
+    TemplatePanel,
+    TextCodePanel,
+)
 from netbox.views import generic
 from netbox.views import generic
 from netbox.views.generic.mixins import TableMixin
 from netbox.views.generic.mixins import TableMixin
 from utilities.forms import ConfirmationForm, get_field_value
 from utilities.forms import ConfirmationForm, get_field_value
@@ -39,6 +47,7 @@ from . import filtersets, forms, tables
 from .constants import LOG_LEVEL_RANK
 from .constants import LOG_LEVEL_RANK
 from .models import *
 from .models import *
 from .tables import ReportResultsTable, ScriptJobTable, ScriptResultsTable
 from .tables import ReportResultsTable, ScriptJobTable, ScriptResultsTable
+from .ui import panels
 
 
 #
 #
 # Custom fields
 # Custom fields
@@ -56,6 +65,18 @@ class CustomFieldListView(generic.ObjectListView):
 @register_model_view(CustomField)
 @register_model_view(CustomField)
 class CustomFieldView(generic.ObjectView):
 class CustomFieldView(generic.ObjectView):
     queryset = CustomField.objects.select_related('choice_set')
     queryset = CustomField.objects.select_related('choice_set')
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CustomFieldPanel(),
+            panels.CustomFieldBehaviorPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            panels.CustomFieldObjectTypesPanel(),
+            panels.CustomFieldValidationPanel(),
+            panels.CustomFieldRelatedObjectsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         related_models = ()
         related_models = ()
@@ -127,6 +148,14 @@ class CustomFieldChoiceSetListView(generic.ObjectListView):
 @register_model_view(CustomFieldChoiceSet)
 @register_model_view(CustomFieldChoiceSet)
 class CustomFieldChoiceSetView(generic.ObjectView):
 class CustomFieldChoiceSetView(generic.ObjectView):
     queryset = CustomFieldChoiceSet.objects.all()
     queryset = CustomFieldChoiceSet.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CustomFieldChoiceSetPanel(),
+        ],
+        right_panels=[
+            panels.CustomFieldChoiceSetChoicesPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
 
 
@@ -202,6 +231,16 @@ class CustomLinkListView(generic.ObjectListView):
 @register_model_view(CustomLink)
 @register_model_view(CustomLink)
 class CustomLinkView(generic.ObjectView):
 class CustomLinkView(generic.ObjectView):
     queryset = CustomLink.objects.all()
     queryset = CustomLink.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.CustomLinkPanel(),
+            panels.ObjectTypesPanel(title=_('Assigned Models')),
+        ],
+        right_panels=[
+            TextCodePanel('link_text', title=_('Link Text')),
+            TextCodePanel('link_url', title=_('Link URL')),
+        ],
+    )
 
 
 
 
 @register_model_view(CustomLink, 'add', detail=False)
 @register_model_view(CustomLink, 'add', detail=False)
@@ -259,6 +298,19 @@ class ExportTemplateListView(generic.ObjectListView):
 @register_model_view(ExportTemplate)
 @register_model_view(ExportTemplate)
 class ExportTemplateView(generic.ObjectView):
 class ExportTemplateView(generic.ObjectView):
     queryset = ExportTemplate.objects.all()
     queryset = ExportTemplate.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ExportTemplatePanel(),
+            TemplatePanel('core/inc/datafile_panel.html'),
+        ],
+        right_panels=[
+            panels.ObjectTypesPanel(title=_('Assigned Models')),
+            JSONPanel('environment_params', title=_('Environment Parameters')),
+        ],
+        bottom_panels=[
+            TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
+        ],
+    )
 
 
 
 
 @register_model_view(ExportTemplate, 'add', detail=False)
 @register_model_view(ExportTemplate, 'add', detail=False)
@@ -320,6 +372,15 @@ class SavedFilterListView(SharedObjectViewMixin, generic.ObjectListView):
 @register_model_view(SavedFilter)
 @register_model_view(SavedFilter)
 class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
 class SavedFilterView(SharedObjectViewMixin, generic.ObjectView):
     queryset = SavedFilter.objects.all()
     queryset = SavedFilter.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.SavedFilterPanel(),
+            panels.SavedFilterObjectTypesPanel(),
+        ],
+        right_panels=[
+            JSONPanel('parameters', title=_('Parameters')),
+        ],
+    )
 
 
 
 
 @register_model_view(SavedFilter, 'add', detail=False)
 @register_model_view(SavedFilter, 'add', detail=False)
@@ -382,6 +443,15 @@ class TableConfigListView(SharedObjectViewMixin, generic.ObjectListView):
 @register_model_view(TableConfig)
 @register_model_view(TableConfig)
 class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
 class TableConfigView(SharedObjectViewMixin, generic.ObjectView):
     queryset = TableConfig.objects.all()
     queryset = TableConfig.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.TableConfigPanel(),
+        ],
+        right_panels=[
+            panels.TableConfigColumnsPanel(),
+            panels.TableConfigOrderingPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         table = instance.table_class([])
         table = instance.table_class([])
@@ -475,6 +545,15 @@ class NotificationGroupListView(generic.ObjectListView):
 @register_model_view(NotificationGroup)
 @register_model_view(NotificationGroup)
 class NotificationGroupView(generic.ObjectView):
 class NotificationGroupView(generic.ObjectView):
     queryset = NotificationGroup.objects.all()
     queryset = NotificationGroup.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.NotificationGroupPanel(),
+        ],
+        right_panels=[
+            panels.NotificationGroupGroupsPanel(),
+            panels.NotificationGroupUsersPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(NotificationGroup, 'add', detail=False)
 @register_model_view(NotificationGroup, 'add', detail=False)
@@ -659,6 +738,19 @@ class WebhookListView(generic.ObjectListView):
 @register_model_view(Webhook)
 @register_model_view(Webhook)
 class WebhookView(generic.ObjectView):
 class WebhookView(generic.ObjectView):
     queryset = Webhook.objects.all()
     queryset = Webhook.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.WebhookPanel(),
+            panels.WebhookHTTPPanel(),
+            panels.WebhookSSLPanel(),
+        ],
+        right_panels=[
+            TextCodePanel('additional_headers', title=_('Additional Headers')),
+            TextCodePanel('body_template', title=_('Body Template')),
+            panels.CustomFieldsPanel(),
+            panels.TagsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(Webhook, 'add', detail=False)
 @register_model_view(Webhook, 'add', detail=False)
@@ -715,6 +807,19 @@ class EventRuleListView(generic.ObjectListView):
 @register_model_view(EventRule)
 @register_model_view(EventRule)
 class EventRuleView(generic.ObjectView):
 class EventRuleView(generic.ObjectView):
     queryset = EventRule.objects.all()
     queryset = EventRule.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.EventRulePanel(),
+            panels.ObjectTypesPanel(),
+            panels.EventRuleEventTypesPanel(),
+        ],
+        right_panels=[
+            JSONPanel('conditions', title=_('Conditions')),
+            panels.EventRuleActionPanel(),
+            panels.CustomFieldsPanel(),
+            panels.TagsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(EventRule, 'add', detail=False)
 @register_model_view(EventRule, 'add', detail=False)
@@ -773,6 +878,18 @@ class TagListView(generic.ObjectListView):
 @register_model_view(Tag)
 @register_model_view(Tag)
 class TagView(generic.ObjectView):
 class TagView(generic.ObjectView):
     queryset = Tag.objects.all()
     queryset = Tag.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.TagPanel(),
+        ],
+        right_panels=[
+            panels.TagObjectTypesPanel(),
+            panels.TagItemTypesPanel(),
+        ],
+        bottom_panels=[
+            ContextTablePanel('taggeditem_table', title=_('Tagged Objects')),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         tagged_items = TaggedItem.objects.filter(tag=instance)
         tagged_items = TaggedItem.objects.filter(tag=instance)
@@ -852,6 +969,18 @@ class ConfigContextProfileListView(generic.ObjectListView):
 @register_model_view(ConfigContextProfile)
 @register_model_view(ConfigContextProfile)
 class ConfigContextProfileView(generic.ObjectView):
 class ConfigContextProfileView(generic.ObjectView):
     queryset = ConfigContextProfile.objects.all()
     queryset = ConfigContextProfile.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ConfigContextProfilePanel(),
+            TemplatePanel('core/inc/datafile_panel.html'),
+            panels.CustomFieldsPanel(),
+            panels.TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            JSONPanel('schema', title=_('JSON Schema')),
+        ],
+    )
 
 
 
 
 @register_model_view(ConfigContextProfile, 'add', detail=False)
 @register_model_view(ConfigContextProfile, 'add', detail=False)
@@ -914,6 +1043,16 @@ class ConfigContextListView(generic.ObjectListView):
 @register_model_view(ConfigContext)
 @register_model_view(ConfigContext)
 class ConfigContextView(generic.ObjectView):
 class ConfigContextView(generic.ObjectView):
     queryset = ConfigContext.objects.all()
     queryset = ConfigContext.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ConfigContextPanel(),
+            TemplatePanel('core/inc/datafile_panel.html'),
+            panels.ConfigContextAssignmentPanel(),
+        ],
+        right_panels=[
+            TemplatePanel('extras/panels/configcontext_data.html'),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Gather assigned objects for parsing in the template
         # Gather assigned objects for parsing in the template
@@ -1033,6 +1172,18 @@ class ConfigTemplateListView(generic.ObjectListView):
 @register_model_view(ConfigTemplate)
 @register_model_view(ConfigTemplate)
 class ConfigTemplateView(generic.ObjectView):
 class ConfigTemplateView(generic.ObjectView):
     queryset = ConfigTemplate.objects.all()
     queryset = ConfigTemplate.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ConfigTemplatePanel(),
+            panels.TagsPanel(),
+        ],
+        right_panels=[
+            JSONPanel('environment_params', title=_('Environment Parameters')),
+        ],
+        bottom_panels=[
+            TextCodePanel('template_code', title=_('Template'), show_sync_warning=True),
+        ],
+    )
 
 
 
 
 @register_model_view(ConfigTemplate, 'add', detail=False)
 @register_model_view(ConfigTemplate, 'add', detail=False)
@@ -1151,6 +1302,17 @@ class ImageAttachmentListView(generic.ObjectListView):
 @register_model_view(ImageAttachment)
 @register_model_view(ImageAttachment)
 class ImageAttachmentView(generic.ObjectView):
 class ImageAttachmentView(generic.ObjectView):
     queryset = ImageAttachment.objects.all()
     queryset = ImageAttachment.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ImageAttachmentPanel(),
+        ],
+        right_panels=[
+            panels.ImageAttachmentFilePanel(),
+        ],
+        bottom_panels=[
+            panels.ImageAttachmentImagePanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(ImageAttachment, 'add', detail=False)
 @register_model_view(ImageAttachment, 'add', detail=False)
@@ -1215,6 +1377,16 @@ class JournalEntryListView(generic.ObjectListView):
 @register_model_view(JournalEntry)
 @register_model_view(JournalEntry)
 class JournalEntryView(generic.ObjectView):
 class JournalEntryView(generic.ObjectView):
     queryset = JournalEntry.objects.all()
     queryset = JournalEntry.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.JournalEntryPanel(),
+            panels.CustomFieldsPanel(),
+            panels.TagsPanel(),
+        ],
+        right_panels=[
+            CommentsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(JournalEntry, 'add', detail=False)
 @register_model_view(JournalEntry, 'add', detail=False)

+ 35 - 9
netbox/ipam/models/ip.py

@@ -159,9 +159,11 @@ class Aggregate(ContactsMixin, GetAvailablePrefixesMixin, PrimaryModel):
 
 
     @property
     @property
     def family(self):
     def family(self):
-        if self.prefix:
-            return self.prefix.version
-        return None
+        if not self.prefix:
+            return None
+        if isinstance(self.prefix, str):
+            return netaddr.IPNetwork(self.prefix).version
+        return self.prefix.version
 
 
     @property
     @property
     def ipv6_full(self):
     def ipv6_full(self):
@@ -335,11 +337,19 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
 
 
     @property
     @property
     def family(self):
     def family(self):
-        return self.prefix.version if self.prefix else None
+        if not self.prefix:
+            return None
+        if isinstance(self.prefix, str):
+            return netaddr.IPNetwork(self.prefix).version
+        return self.prefix.version
 
 
     @property
     @property
     def mask_length(self):
     def mask_length(self):
-        return self.prefix.prefixlen if self.prefix else None
+        if not self.prefix:
+            return None
+        if isinstance(self.prefix, str):
+            return netaddr.IPNetwork(self.prefix).prefixlen
+        return self.prefix.prefixlen
 
 
     @property
     @property
     def ipv6_full(self):
     def ipv6_full(self):
@@ -367,6 +377,16 @@ class Prefix(ContactsMixin, GetAvailablePrefixesMixin, CachedScopeMixin, Primary
     def get_status_color(self):
     def get_status_color(self):
         return PrefixStatusChoices.colors.get(self.status)
         return PrefixStatusChoices.colors.get(self.status)
 
 
+    @cached_property
+    def aggregate(self):
+        """
+        Return the containing Aggregate for this Prefix, if any.
+        """
+        try:
+            return Aggregate.objects.get(prefix__net_contains_or_equals=str(self.prefix))
+        except Aggregate.DoesNotExist:
+            return None
+
     def get_parents(self, include_self=False):
     def get_parents(self, include_self=False):
         """
         """
         Return all containing Prefixes in the hierarchy.
         Return all containing Prefixes in the hierarchy.
@@ -632,7 +652,11 @@ class IPRange(ContactsMixin, PrimaryModel):
 
 
     @property
     @property
     def family(self):
     def family(self):
-        return self.start_address.version if self.start_address else None
+        if not self.start_address:
+            return None
+        if isinstance(self.start_address, str):
+            return netaddr.IPAddress(self.start_address.split('/')[0]).version
+        return self.start_address.version
 
 
     @property
     @property
     def range(self):
     def range(self):
@@ -980,9 +1004,11 @@ class IPAddress(ContactsMixin, PrimaryModel):
 
 
     @property
     @property
     def family(self):
     def family(self):
-        if self.address:
-            return self.address.version
-        return None
+        if not self.address:
+            return None
+        if isinstance(self.address, str):
+            return netaddr.IPNetwork(self.address).version
+        return self.address.version
 
 
     @property
     @property
     def is_oob_ip(self):
     def is_oob_ip(self):

+ 35 - 0
netbox/ipam/tests/test_models.py

@@ -11,6 +11,13 @@ from utilities.data import string_to_ranges
 
 
 class TestAggregate(TestCase):
 class TestAggregate(TestCase):
 
 
+    def test_family_string(self):
+        # Test property when prefix is a string
+        agg = Aggregate(prefix='10.0.0.0/8')
+        self.assertEqual(agg.family, 4)
+        agg_v6 = Aggregate(prefix='2001:db8::/32')
+        self.assertEqual(agg_v6.family, 6)
+
     def test_get_utilization(self):
     def test_get_utilization(self):
         rir = RIR.objects.create(name='RIR 1', slug='rir-1')
         rir = RIR.objects.create(name='RIR 1', slug='rir-1')
         aggregate = Aggregate(prefix=IPNetwork('10.0.0.0/8'), rir=rir)
         aggregate = Aggregate(prefix=IPNetwork('10.0.0.0/8'), rir=rir)
@@ -40,6 +47,13 @@ class TestAggregate(TestCase):
 
 
 class TestIPRange(TestCase):
 class TestIPRange(TestCase):
 
 
+    def test_family_string(self):
+        # Test property when start_address is a string
+        ip_range = IPRange(start_address='10.0.0.1/24', end_address='10.0.0.254/24')
+        self.assertEqual(ip_range.family, 4)
+        ip_range_v6 = IPRange(start_address='2001:db8::1/64', end_address='2001:db8::ffff/64')
+        self.assertEqual(ip_range_v6.family, 6)
+
     def test_overlapping_range(self):
     def test_overlapping_range(self):
         iprange_192_168 = IPRange.objects.create(
         iprange_192_168 = IPRange.objects.create(
             start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22')
             start_address=IPNetwork('192.168.0.1/22'), end_address=IPNetwork('192.168.0.49/22')
@@ -90,6 +104,20 @@ class TestIPRange(TestCase):
 
 
 class TestPrefix(TestCase):
 class TestPrefix(TestCase):
 
 
+    def test_family_string(self):
+        # Test property when prefix is a string
+        prefix = Prefix(prefix='10.0.0.0/8')
+        self.assertEqual(prefix.family, 4)
+        prefix_v6 = Prefix(prefix='2001:db8::/32')
+        self.assertEqual(prefix_v6.family, 6)
+
+    def test_mask_length_string(self):
+        # Test property when prefix is a string
+        prefix = Prefix(prefix='10.0.0.0/8')
+        self.assertEqual(prefix.mask_length, 8)
+        prefix_v6 = Prefix(prefix='2001:db8::/32')
+        self.assertEqual(prefix_v6.mask_length, 32)
+
     def test_get_duplicates(self):
     def test_get_duplicates(self):
         prefixes = Prefix.objects.bulk_create((
         prefixes = Prefix.objects.bulk_create((
             Prefix(prefix=IPNetwork('192.0.2.0/24')),
             Prefix(prefix=IPNetwork('192.0.2.0/24')),
@@ -533,6 +561,13 @@ class TestPrefixHierarchy(TestCase):
 
 
 class TestIPAddress(TestCase):
 class TestIPAddress(TestCase):
 
 
+    def test_family_string(self):
+        # Test property when address is a string
+        ip = IPAddress(address='10.0.0.1/24')
+        self.assertEqual(ip.family, 4)
+        ip_v6 = IPAddress(address='2001:db8::1/64')
+        self.assertEqual(ip_v6.family, 6)
+
     def test_get_duplicates(self):
     def test_get_duplicates(self):
         ips = IPAddress.objects.bulk_create((
         ips = IPAddress.objects.bulk_create((
             IPAddress(address=IPNetwork('192.0.2.1/24')),
             IPAddress(address=IPNetwork('192.0.2.1/24')),

+ 24 - 0
netbox/ipam/ui/attrs.py

@@ -0,0 +1,24 @@
+from django.template.loader import render_to_string
+
+from netbox.ui import attrs
+
+
+class VRFDisplayAttr(attrs.ObjectAttribute):
+    """
+    Renders a VRF reference, displaying 'Global' when no VRF is assigned. Optionally includes
+    the route distinguisher (RD).
+    """
+    template_name = 'ipam/attrs/vrf.html'
+
+    def __init__(self, *args, show_rd=False, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.show_rd = show_rd
+
+    def render(self, obj, context):
+        value = self.get_value(obj)
+        return render_to_string(self.template_name, {
+            **self.get_context(obj, context),
+            'name': context['name'],
+            'value': value,
+            'show_rd': self.show_rd,
+        })

+ 221 - 2
netbox/ipam/ui/panels.py

@@ -2,14 +2,15 @@ from django.contrib.contenttypes.models import ContentType
 from django.urls import reverse
 from django.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
-from netbox.ui import actions, panels
+from netbox.ui import actions, attrs, panels
+
+from .attrs import VRFDisplayAttr
 
 
 
 
 class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
 class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
     """
     """
     A panel which lists all FHRP group assignments for a given object.
     A panel which lists all FHRP group assignments for a given object.
     """
     """
-
     template_name = 'ipam/panels/fhrp_groups.html'
     template_name = 'ipam/panels/fhrp_groups.html'
     title = _('FHRP Groups')
     title = _('FHRP Groups')
     actions = [
     actions = [
@@ -35,3 +36,221 @@ class FHRPGroupAssignmentsPanel(panels.ObjectPanel):
             label=_('Assign Group'),
             label=_('Assign Group'),
         ),
         ),
     ]
     ]
+
+
+class VRFPanel(panels.ObjectAttributesPanel):
+    rd = attrs.TextAttr('rd', label=_('Route Distinguisher'), style='font-monospace')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    enforce_unique = attrs.BooleanAttr('enforce_unique', label=_('Unique IP Space'))
+    description = attrs.TextAttr('description')
+
+
+class RouteTargetPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name', style='font-monospace')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    description = attrs.TextAttr('description')
+
+
+class RIRPanel(panels.OrganizationalObjectPanel):
+    is_private = attrs.BooleanAttr('is_private', label=_('Private'))
+
+
+class ASNRangePanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
+    range = attrs.TextAttr('range_as_string_with_asdot', label=_('Range'))
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    description = attrs.TextAttr('description')
+
+
+class ASNPanel(panels.ObjectAttributesPanel):
+    asn = attrs.TextAttr('asn_with_asdot', label=_('AS Number'))
+    rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
+    role = attrs.RelatedObjectAttr('role', linkify=True)
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    description = attrs.TextAttr('description')
+
+
+class AggregatePanel(panels.ObjectAttributesPanel):
+    family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
+    rir = attrs.RelatedObjectAttr('rir', linkify=True, label=_('RIR'))
+    utilization = attrs.TemplatedAttr(
+        'prefix',
+        template_name='ipam/aggregate/attrs/utilization.html',
+        label=_('Utilization'),
+    )
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    date_added = attrs.DateTimeAttr('date_added', spec='date', label=_('Date Added'))
+    description = attrs.TextAttr('description')
+
+
+class RolePanel(panels.OrganizationalObjectPanel):
+    weight = attrs.NumericAttr('weight')
+
+
+class IPRangePanel(panels.ObjectAttributesPanel):
+    family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
+    start_address = attrs.TextAttr('start_address', label=_('Starting Address'))
+    end_address = attrs.TextAttr('end_address', label=_('Ending Address'))
+    size = attrs.NumericAttr('size')
+    mark_populated = attrs.BooleanAttr('mark_populated', label=_('Marked Populated'))
+    mark_utilized = attrs.BooleanAttr('mark_utilized', label=_('Marked Utilized'))
+    utilization = attrs.TemplatedAttr(
+        'utilization',
+        template_name='ipam/iprange/attrs/utilization.html',
+        label=_('Utilization'),
+    )
+    vrf = VRFDisplayAttr('vrf', label=_('VRF'), show_rd=True)
+    role = attrs.RelatedObjectAttr('role', linkify=True)
+    status = attrs.ChoiceAttr('status')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    description = attrs.TextAttr('description')
+
+
+class IPAddressPanel(panels.ObjectAttributesPanel):
+    family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
+    vrf = VRFDisplayAttr('vrf', label=_('VRF'))
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    status = attrs.ChoiceAttr('status')
+    role = attrs.ChoiceAttr('role')
+    dns_name = attrs.TextAttr('dns_name', label=_('DNS Name'))
+    description = attrs.TextAttr('description')
+    assigned_object = attrs.RelatedObjectAttr(
+        'assigned_object',
+        linkify=True,
+        grouped_by='parent_object',
+        label=_('Assignment'),
+    )
+    nat_inside = attrs.TemplatedAttr(
+        'nat_inside',
+        template_name='ipam/ipaddress/attrs/nat_inside.html',
+        label=_('NAT (inside)'),
+    )
+    nat_outside = attrs.TemplatedAttr(
+        'nat_outside',
+        template_name='ipam/ipaddress/attrs/nat_outside.html',
+        label=_('NAT (outside)'),
+    )
+    is_primary_ip = attrs.BooleanAttr('is_primary_ip', label=_('Primary IP'))
+    is_oob_ip = attrs.BooleanAttr('is_oob_ip', label=_('OOB IP'))
+
+
+class PrefixPanel(panels.ObjectAttributesPanel):
+    family = attrs.TextAttr('family', format_string='IPv{}', label=_('Family'))
+    vrf = VRFDisplayAttr('vrf', label=_('VRF'))
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    aggregate = attrs.TemplatedAttr(
+        'aggregate',
+        template_name='ipam/prefix/attrs/aggregate.html',
+        label=_('Aggregate'),
+    )
+    scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
+    vlan = attrs.RelatedObjectAttr('vlan', linkify=True, label=_('VLAN'), grouped_by='group')
+    status = attrs.ChoiceAttr('status')
+    role = attrs.RelatedObjectAttr('role', linkify=True)
+    description = attrs.TextAttr('description')
+    is_pool = attrs.BooleanAttr('is_pool', label=_('Is a pool'))
+
+
+class VLANGroupPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    scope = attrs.GenericForeignKeyAttr('scope', linkify=True)
+    vid_ranges = attrs.TemplatedAttr(
+        'vid_ranges_items',
+        template_name='ipam/vlangroup/attrs/vid_ranges.html',
+        label=_('VLAN IDs'),
+    )
+    utilization = attrs.UtilizationAttr('utilization')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+
+
+class VLANTranslationPolicyPanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+
+
+class VLANTranslationRulePanel(panels.ObjectAttributesPanel):
+    policy = attrs.RelatedObjectAttr('policy', linkify=True)
+    local_vid = attrs.NumericAttr('local_vid', label=_('Local VID'))
+    remote_vid = attrs.NumericAttr('remote_vid', label=_('Remote VID'))
+    description = attrs.TextAttr('description')
+
+
+class FHRPGroupPanel(panels.ObjectAttributesPanel):
+    protocol = attrs.ChoiceAttr('protocol')
+    group_id = attrs.NumericAttr('group_id', label=_('Group ID'))
+    name = attrs.TextAttr('name')
+    description = attrs.TextAttr('description')
+    member_count = attrs.NumericAttr('member_count', label=_('Members'))
+
+
+class FHRPGroupAuthPanel(panels.ObjectAttributesPanel):
+    title = _('Authentication')
+
+    auth_type = attrs.ChoiceAttr('auth_type', label=_('Authentication Type'))
+    auth_key = attrs.TextAttr('auth_key', label=_('Authentication Key'))
+
+
+class VLANPanel(panels.ObjectAttributesPanel):
+    region = attrs.NestedObjectAttr('site.region', linkify=True, label=_('Region'))
+    site = attrs.RelatedObjectAttr('site', linkify=True)
+    group = attrs.RelatedObjectAttr('group', linkify=True)
+    vid = attrs.NumericAttr('vid', label=_('VLAN ID'))
+    name = attrs.TextAttr('name')
+    tenant = attrs.RelatedObjectAttr('tenant', linkify=True, grouped_by='group')
+    status = attrs.ChoiceAttr('status')
+    role = attrs.RelatedObjectAttr('role', linkify=True)
+    description = attrs.TextAttr('description')
+    qinq_role = attrs.ChoiceAttr('qinq_role', label=_('Q-in-Q Role'))
+    qinq_svlan = attrs.RelatedObjectAttr('qinq_svlan', linkify=True, label=_('Q-in-Q SVLAN'))
+    l2vpn = attrs.RelatedObjectAttr('l2vpn_termination.l2vpn', linkify=True, label=_('L2VPN'))
+
+
+class VLANCustomerVLANsPanel(panels.ObjectsTablePanel):
+    """
+    A panel listing customer VLANs (C-VLANs) for an S-VLAN. Only renders when the VLAN has Q-in-Q
+    role 'svlan'.
+    """
+    def __init__(self):
+        super().__init__(
+            'ipam.vlan',
+            filters={'qinq_svlan_id': lambda ctx: ctx['object'].pk},
+            title=_('Customer VLANs'),
+            actions=[
+                actions.AddObject(
+                    'ipam.vlan',
+                    url_params={
+                        'qinq_role': 'cvlan',
+                        'qinq_svlan': lambda ctx: ctx['object'].pk,
+                    },
+                    label=_('Add a VLAN'),
+                ),
+            ],
+        )
+
+    def render(self, context):
+        obj = context.get('object')
+        if not obj or obj.qinq_role != 'svlan':
+            return ''
+        return super().render(context)
+
+
+class ServiceTemplatePanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    protocol = attrs.ChoiceAttr('protocol')
+    ports = attrs.TextAttr('port_list', label=_('Ports'))
+    description = attrs.TextAttr('description')
+
+
+class ServicePanel(panels.ObjectAttributesPanel):
+    name = attrs.TextAttr('name')
+    parent = attrs.RelatedObjectAttr('parent', linkify=True)
+    protocol = attrs.ChoiceAttr('protocol')
+    ports = attrs.TextAttr('port_list', label=_('Ports'))
+    ip_addresses = attrs.TemplatedAttr(
+        'ipaddresses',
+        template_name='ipam/service/attrs/ip_addresses.html',
+        label=_('IP Addresses'),
+    )
+    description = attrs.TextAttr('description')

+ 306 - 44
netbox/ipam/views.py

@@ -9,8 +9,16 @@ from circuits.models import Provider
 from dcim.filtersets import InterfaceFilterSet
 from dcim.filtersets import InterfaceFilterSet
 from dcim.forms import InterfaceFilterForm
 from dcim.forms import InterfaceFilterForm
 from dcim.models import Device, Interface, Site
 from dcim.models import Device, Interface, Site
-from ipam.tables import VLANTranslationRuleTable
+from extras.ui.panels import CustomFieldsPanel, TagsPanel
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
 from netbox.object_actions import AddObject, BulkDelete, BulkEdit, BulkExport, BulkImport
+from netbox.ui import actions, layout
+from netbox.ui.panels import (
+    CommentsPanel,
+    ContextTablePanel,
+    ObjectsTablePanel,
+    RelatedObjectsPanel,
+    TemplatePanel,
+)
 from netbox.views import generic
 from netbox.views import generic
 from utilities.query import count_related
 from utilities.query import count_related
 from utilities.tables import get_table_ordering
 from utilities.tables import get_table_ordering
@@ -23,6 +31,7 @@ from . import filtersets, forms, tables
 from .choices import PrefixStatusChoices
 from .choices import PrefixStatusChoices
 from .constants import *
 from .constants import *
 from .models import *
 from .models import *
+from .ui import panels
 from .utils import add_available_vlans, add_requested_prefixes, annotate_ip_space
 from .utils import add_available_vlans, add_requested_prefixes, annotate_ip_space
 
 
 #
 #
@@ -41,6 +50,27 @@ class VRFListView(generic.ObjectListView):
 @register_model_view(VRF)
 @register_model_view(VRF)
 class VRFView(GetRelatedModelsMixin, generic.ObjectView):
 class VRFView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VRF.objects.all()
     queryset = VRF.objects.all()
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(
+                panels.VRFPanel(),
+                TagsPanel(),
+            ),
+            layout.Column(
+                RelatedObjectsPanel(),
+                CustomFieldsPanel(),
+                CommentsPanel(),
+            ),
+        ),
+        layout.Row(
+            layout.Column(
+                ContextTablePanel('import_targets_table', title=_('Import route targets')),
+            ),
+            layout.Column(
+                ContextTablePanel('export_targets_table', title=_('Export route targets')),
+            ),
+        ),
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         import_targets_table = tables.RouteTargetTable(
         import_targets_table = tables.RouteTargetTable(
@@ -134,6 +164,50 @@ class RouteTargetListView(generic.ObjectListView):
 @register_model_view(RouteTarget)
 @register_model_view(RouteTarget)
 class RouteTargetView(generic.ObjectView):
 class RouteTargetView(generic.ObjectView):
     queryset = RouteTarget.objects.all()
     queryset = RouteTarget.objects.all()
+    layout = layout.Layout(
+        layout.Row(
+            layout.Column(
+                panels.RouteTargetPanel(),
+                TagsPanel(),
+            ),
+            layout.Column(
+                CustomFieldsPanel(),
+                CommentsPanel(),
+            ),
+        ),
+        layout.Row(
+            layout.Column(
+                ObjectsTablePanel(
+                    'ipam.vrf',
+                    filters={'import_target_id': lambda ctx: ctx['object'].pk},
+                    title=_('Importing VRFs'),
+                ),
+            ),
+            layout.Column(
+                ObjectsTablePanel(
+                    'ipam.vrf',
+                    filters={'export_target_id': lambda ctx: ctx['object'].pk},
+                    title=_('Exporting VRFs'),
+                ),
+            ),
+        ),
+        layout.Row(
+            layout.Column(
+                ObjectsTablePanel(
+                    'vpn.l2vpn',
+                    filters={'import_target_id': lambda ctx: ctx['object'].pk},
+                    title=_('Importing L2VPNs'),
+                ),
+            ),
+            layout.Column(
+                ObjectsTablePanel(
+                    'vpn.l2vpn',
+                    filters={'export_target_id': lambda ctx: ctx['object'].pk},
+                    title=_('Exporting L2VPNs'),
+                ),
+            ),
+        ),
+    )
 
 
 
 
 @register_model_view(RouteTarget, 'add', detail=False)
 @register_model_view(RouteTarget, 'add', detail=False)
@@ -192,6 +266,17 @@ class RIRListView(generic.ObjectListView):
 @register_model_view(RIR)
 @register_model_view(RIR)
 class RIRView(GetRelatedModelsMixin, generic.ObjectView):
 class RIRView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = RIR.objects.all()
     queryset = RIR.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RIRPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -257,6 +342,16 @@ class ASNRangeListView(generic.ObjectListView):
 @register_model_view(ASNRange)
 @register_model_view(ASNRange)
 class ASNRangeView(generic.ObjectView):
 class ASNRangeView(generic.ObjectView):
     queryset = ASNRange.objects.all()
     queryset = ASNRange.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ASNRangePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(ASNRange, 'asns')
 @register_model_view(ASNRange, 'asns')
@@ -337,6 +432,17 @@ class ASNListView(generic.ObjectListView):
 @register_model_view(ASN)
 @register_model_view(ASN)
 class ASNView(GetRelatedModelsMixin, generic.ObjectView):
 class ASNView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = ASN.objects.all()
     queryset = ASN.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ASNPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -412,6 +518,16 @@ class AggregateListView(generic.ObjectListView):
 @register_model_view(Aggregate)
 @register_model_view(Aggregate)
 class AggregateView(generic.ObjectView):
 class AggregateView(generic.ObjectView):
     queryset = Aggregate.objects.all()
     queryset = Aggregate.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.AggregatePanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(Aggregate, 'prefixes')
 @register_model_view(Aggregate, 'prefixes')
@@ -507,6 +623,17 @@ class RoleListView(generic.ObjectListView):
 @register_model_view(Role)
 @register_model_view(Role)
 class RoleView(GetRelatedModelsMixin, generic.ObjectView):
 class RoleView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = Role.objects.all()
     queryset = Role.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.RolePanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -570,15 +697,23 @@ class PrefixListView(generic.ObjectListView):
 @register_model_view(Prefix)
 @register_model_view(Prefix)
 class PrefixView(generic.ObjectView):
 class PrefixView(generic.ObjectView):
     queryset = Prefix.objects.all()
     queryset = Prefix.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.PrefixPanel(),
+        ],
+        right_panels=[
+            TemplatePanel('ipam/panels/prefix_addressing.html'),
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        bottom_panels=[
+            ContextTablePanel('duplicate_prefix_table', title=_('Duplicate prefixes')),
+            ContextTablePanel('parent_prefix_table', title=_('Parent prefixes')),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
-        try:
-            aggregate = Aggregate.objects.restrict(request.user, 'view').get(
-                prefix__net_contains_or_equals=str(instance.prefix)
-            )
-        except Aggregate.DoesNotExist:
-            aggregate = None
-
         # Parent prefixes table
         # Parent prefixes table
         parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
         parent_prefixes = Prefix.objects.restrict(request.user, 'view').filter(
             Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER)
             Q(vrf=instance.vrf) | Q(vrf__isnull=True, status=PrefixStatusChoices.STATUS_CONTAINER)
@@ -609,11 +744,12 @@ class PrefixView(generic.ObjectView):
         )
         )
         duplicate_prefix_table.configure(request)
         duplicate_prefix_table.configure(request)
 
 
-        return {
-            'aggregate': aggregate,
+        context = {
             'parent_prefix_table': parent_prefix_table,
             'parent_prefix_table': parent_prefix_table,
-            'duplicate_prefix_table': duplicate_prefix_table,
         }
         }
+        if duplicate_prefixes.exists():
+            context['duplicate_prefix_table'] = duplicate_prefix_table
+        return context
 
 
 
 
 @register_model_view(Prefix, 'prefixes')
 @register_model_view(Prefix, 'prefixes')
@@ -767,6 +903,19 @@ class IPRangeListView(generic.ObjectListView):
 @register_model_view(IPRange)
 @register_model_view(IPRange)
 class IPRangeView(generic.ObjectView):
 class IPRangeView(generic.ObjectView):
     queryset = IPRange.objects.all()
     queryset = IPRange.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.IPRangePanel(),
+        ],
+        right_panels=[
+            TagsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+        bottom_panels=[
+            ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
 
 
@@ -864,6 +1013,23 @@ class IPAddressListView(generic.ObjectListView):
 @register_model_view(IPAddress)
 @register_model_view(IPAddress)
 class IPAddressView(generic.ObjectView):
 class IPAddressView(generic.ObjectView):
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
     queryset = IPAddress.objects.prefetch_related('vrf__tenant', 'tenant')
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.IPAddressPanel(),
+            TagsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            ContextTablePanel('parent_prefixes_table', title=_('Parent prefixes')),
+            ContextTablePanel('duplicate_ips_table', title=_('Duplicate IPs')),
+            ObjectsTablePanel(
+                'ipam.service',
+                filters={'ip_address_id': lambda ctx: ctx['object'].pk},
+                title=_('Application services'),
+            ),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Parent prefixes table
         # Parent prefixes table
@@ -896,10 +1062,12 @@ class IPAddressView(generic.ObjectView):
         duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
         duplicate_ips_table = tables.IPAddressTable(duplicate_ips[:10], orderable=False)
         duplicate_ips_table.configure(request)
         duplicate_ips_table.configure(request)
 
 
-        return {
+        context = {
             'parent_prefixes_table': parent_prefixes_table,
             'parent_prefixes_table': parent_prefixes_table,
-            'duplicate_ips_table': duplicate_ips_table,
         }
         }
+        if duplicate_ips.exists():
+            context['duplicate_ips_table'] = duplicate_ips_table
+        return context
 
 
 
 
 @register_model_view(IPAddress, 'add', detail=False)
 @register_model_view(IPAddress, 'add', detail=False)
@@ -1049,6 +1217,17 @@ class VLANGroupListView(generic.ObjectListView):
 @register_model_view(VLANGroup)
 @register_model_view(VLANGroup)
 class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
 class VLANGroupView(GetRelatedModelsMixin, generic.ObjectView):
     queryset = VLANGroup.objects.annotate_utilization()
     queryset = VLANGroup.objects.annotate_utilization()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VLANGroupPanel(),
+            TagsPanel(),
+        ],
+        right_panels=[
+            RelatedObjectsPanel(),
+            CommentsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         return {
         return {
@@ -1136,19 +1315,32 @@ class VLANTranslationPolicyListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(VLANTranslationPolicy)
 @register_model_view(VLANTranslationPolicy)
-class VLANTranslationPolicyView(GetRelatedModelsMixin, generic.ObjectView):
+class VLANTranslationPolicyView(generic.ObjectView):
     queryset = VLANTranslationPolicy.objects.all()
     queryset = VLANTranslationPolicy.objects.all()
-
-    def get_extra_context(self, request, instance):
-        vlan_translation_table = VLANTranslationRuleTable(
-            data=instance.rules.all(),
-            orderable=False
-        )
-        vlan_translation_table.configure(request)
-
-        return {
-            'vlan_translation_table': vlan_translation_table,
-        }
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VLANTranslationPolicyPanel(),
+        ],
+        right_panels=[
+            TagsPanel(),
+            CustomFieldsPanel(),
+            CommentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                'ipam.vlantranslationrule',
+                filters={'policy_id': lambda ctx: ctx['object'].pk},
+                title=_('VLAN translation rules'),
+                actions=[
+                    actions.AddObject(
+                        'ipam.vlantranslationrule',
+                        url_params={'policy': lambda ctx: ctx['object'].pk},
+                        label=_('Add Rule'),
+                    ),
+                ],
+            ),
+        ],
+    )
 
 
 
 
 @register_model_view(VLANTranslationPolicy, 'add', detail=False)
 @register_model_view(VLANTranslationPolicy, 'add', detail=False)
@@ -1204,13 +1396,17 @@ class VLANTranslationRuleListView(generic.ObjectListView):
 
 
 
 
 @register_model_view(VLANTranslationRule)
 @register_model_view(VLANTranslationRule)
-class VLANTranslationRuleView(GetRelatedModelsMixin, generic.ObjectView):
+class VLANTranslationRuleView(generic.ObjectView):
     queryset = VLANTranslationRule.objects.all()
     queryset = VLANTranslationRule.objects.all()
-
-    def get_extra_context(self, request, instance):
-        return {
-            'related_models': self.get_related_models(request, instance),
-        }
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VLANTranslationRulePanel(),
+        ],
+        right_panels=[
+            TagsPanel(),
+            CustomFieldsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(VLANTranslationRule, 'add', detail=False)
 @register_model_view(VLANTranslationRule, 'add', detail=False)
@@ -1262,7 +1458,36 @@ class FHRPGroupListView(generic.ObjectListView):
 
 
 @register_model_view(FHRPGroup)
 @register_model_view(FHRPGroup)
 class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
 class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
-    queryset = FHRPGroup.objects.all()
+    queryset = FHRPGroup.objects.annotate(
+        member_count=count_related(FHRPGroupAssignment, 'group')
+    )
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.FHRPGroupPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        right_panels=[
+            panels.FHRPGroupAuthPanel(),
+            RelatedObjectsPanel(),
+            CustomFieldsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                'ipam.ipaddress',
+                filters={'fhrpgroup_id': lambda ctx: ctx['object'].pk},
+                title=_('Virtual IP addresses'),
+                actions=[
+                    actions.AddObject(
+                        'ipam.ipaddress',
+                        url_params={'fhrpgroup': lambda ctx: ctx['object'].pk},
+                        label=_('Add IP Address'),
+                    ),
+                ],
+            ),
+            ContextTablePanel('members_table', title=_('Members')),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         # Get assigned interfaces
         # Get assigned interfaces
@@ -1287,7 +1512,6 @@ class FHRPGroupView(GetRelatedModelsMixin, generic.ObjectView):
                 ),
                 ),
             ),
             ),
             'members_table': members_table,
             'members_table': members_table,
-            'member_count': FHRPGroupAssignment.objects.filter(group=instance).count(),
         }
         }
 
 
 
 
@@ -1390,17 +1614,35 @@ class VLANListView(generic.ObjectListView):
 @register_model_view(VLAN)
 @register_model_view(VLAN)
 class VLANView(generic.ObjectView):
 class VLANView(generic.ObjectView):
     queryset = VLAN.objects.all()
     queryset = VLAN.objects.all()
-
-    def get_extra_context(self, request, instance):
-        prefixes = Prefix.objects.restrict(request.user, 'view').filter(vlan=instance).prefetch_related(
-            'vrf', 'scope', 'role', 'tenant'
-        )
-        prefix_table = tables.PrefixTable(list(prefixes), exclude=('vlan', 'utilization'), orderable=False)
-        prefix_table.configure(request)
-
-        return {
-            'prefix_table': prefix_table,
-        }
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.VLANPanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+        bottom_panels=[
+            ObjectsTablePanel(
+                'ipam.prefix',
+                filters={'vlan_id': lambda ctx: ctx['object'].pk},
+                title=_('Prefixes'),
+                actions=[
+                    actions.AddObject(
+                        'ipam.prefix',
+                        url_params={
+                            'tenant': lambda ctx: ctx['object'].tenant.pk if ctx['object'].tenant else None,
+                            'site': lambda ctx: ctx['object'].site.pk if ctx['object'].site else None,
+                            'vlan': lambda ctx: ctx['object'].pk,
+                        },
+                        label=_('Add a Prefix'),
+                    ),
+                ],
+            ),
+            panels.VLANCustomerVLANsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(VLAN, 'interfaces')
 @register_model_view(VLAN, 'interfaces')
@@ -1494,6 +1736,16 @@ class ServiceTemplateListView(generic.ObjectListView):
 @register_model_view(ServiceTemplate)
 @register_model_view(ServiceTemplate)
 class ServiceTemplateView(generic.ObjectView):
 class ServiceTemplateView(generic.ObjectView):
     queryset = ServiceTemplate.objects.all()
     queryset = ServiceTemplate.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ServiceTemplatePanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
 
 
 @register_model_view(ServiceTemplate, 'add', detail=False)
 @register_model_view(ServiceTemplate, 'add', detail=False)
@@ -1550,6 +1802,16 @@ class ServiceListView(generic.ObjectListView):
 @register_model_view(Service)
 @register_model_view(Service)
 class ServiceView(generic.ObjectView):
 class ServiceView(generic.ObjectView):
     queryset = Service.objects.all()
     queryset = Service.objects.all()
+    layout = layout.SimpleLayout(
+        left_panels=[
+            panels.ServicePanel(),
+        ],
+        right_panels=[
+            CustomFieldsPanel(),
+            TagsPanel(),
+            CommentsPanel(),
+        ],
+    )
 
 
     def get_extra_context(self, request, instance):
     def get_extra_context(self, request, instance):
         context = {}
         context = {}

+ 24 - 1
netbox/netbox/api/serializers/features.py

@@ -1,7 +1,7 @@
 from rest_framework import serializers
 from rest_framework import serializers
 from rest_framework.fields import CreateOnlyDefault
 from rest_framework.fields import CreateOnlyDefault
 
 
-from extras.api.customfields import CustomFieldDefaultValues, CustomFieldsDataField
+from extras.api.customfields import CustomFieldDefaultValues, CustomFieldListSerializer, CustomFieldsDataField
 
 
 from .base import ValidatedModelSerializer
 from .base import ValidatedModelSerializer
 from .nested import NestedTagSerializer
 from .nested import NestedTagSerializer
@@ -23,6 +23,29 @@ class CustomFieldModelSerializer(serializers.Serializer):
         default=CreateOnlyDefault(CustomFieldDefaultValues())
         default=CreateOnlyDefault(CustomFieldDefaultValues())
     )
     )
 
 
+    @classmethod
+    def many_init(cls, *args, **kwargs):
+        """
+        We can't call super().many_init() and change the outcome because by the time it returns,
+        the plain ListSerializer is already instantiated.
+        Because every NetBox serializer defines its own Meta which doesn't inherit from a parent Meta,
+        this would silently not apply to any real serializer.
+        Thats why this method replicates many_init from parent and changed the default value for list_serializer_class.
+        """
+        list_kwargs = {}
+        for key in serializers.LIST_SERIALIZER_KWARGS_REMOVE:
+            value = kwargs.pop(key, None)
+            if value is not None:
+                list_kwargs[key] = value
+        list_kwargs['child'] = cls(*args, **kwargs)
+        list_kwargs.update({
+            key: value for key, value in kwargs.items()
+            if key in serializers.LIST_SERIALIZER_KWARGS
+        })
+        meta = getattr(cls, 'Meta', None)
+        list_serializer_class = getattr(meta, 'list_serializer_class', CustomFieldListSerializer)
+        return list_serializer_class(*args, **list_kwargs)
+
 
 
 class TaggableModelSerializer(serializers.Serializer):
 class TaggableModelSerializer(serializers.Serializer):
     """
     """

+ 39 - 3
netbox/netbox/forms/model_forms.py

@@ -2,6 +2,7 @@ import json
 
 
 from django import forms
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.contenttypes.models import ContentType
+from django.db.models.fields.related import ManyToManyRel
 
 
 from extras.choices import *
 from extras.choices import *
 from utilities.forms.fields import CommentField, SlugField
 from utilities.forms.fields import CommentField, SlugField
@@ -71,14 +72,49 @@ class NetBoxModelForm(
     def _post_clean(self):
     def _post_clean(self):
         """
         """
         Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
         Override BaseModelForm's _post_clean() to store many-to-many field values on the model instance.
+        Handles both forward and reverse M2M relationships, and supports both simple (single field)
+        and add/remove (dual field) modes.
         """
         """
         self.instance._m2m_values = {}
         self.instance._m2m_values = {}
-        for field in self.instance._meta.local_many_to_many:
-            if field.name in self.cleaned_data:
-                self.instance._m2m_values[field.name] = list(self.cleaned_data[field.name])
+
+        # Collect names to process: local M2M fields (includes TaggableManager from django-taggit)
+        # plus reverse M2M relations (ManyToManyRel).
+        names = [field.name for field in self.instance._meta.local_many_to_many]
+        names += [
+            field.get_accessor_name()
+            for field in self.instance._meta.get_fields()
+            if isinstance(field, ManyToManyRel)
+        ]
+
+        for name in names:
+            if name in self.cleaned_data:
+                # Simple mode: single multi-select field
+                self.instance._m2m_values[name] = list(self.cleaned_data[name])
+            elif f'add_{name}' in self.cleaned_data or f'remove_{name}' in self.cleaned_data:
+                # Add/remove mode: compute the effective set
+                current = set(getattr(self.instance, name).values_list('pk', flat=True)) \
+                    if self.instance.pk else set()
+                add_values = set(
+                    v.pk for v in self.cleaned_data.get(f'add_{name}', [])
+                )
+                remove_values = set(
+                    v.pk for v in self.cleaned_data.get(f'remove_{name}', [])
+                )
+                self.instance._m2m_values[name] = list((current | add_values) - remove_values)
 
 
         return super()._post_clean()
         return super()._post_clean()
 
 
+    def _save_m2m(self):
+        """
+        Save many-to-many field values that were computed in _post_clean(). This handles M2M fields
+        not included in Meta.fields (e.g. those managed via M2MAddRemoveFields).
+        """
+        super()._save_m2m()
+        meta_fields = self._meta.fields
+        for field_name, values in self.instance._m2m_values.items():
+            if not meta_fields or field_name not in meta_fields:
+                getattr(self.instance, field_name).set(values)
+
 
 
 class PrimaryModelForm(OwnerMixin, NetBoxModelForm):
 class PrimaryModelForm(OwnerMixin, NetBoxModelForm):
     """
     """

+ 6 - 4
netbox/netbox/graphql/scalars.py

@@ -1,10 +1,12 @@
-from typing import Union
+from typing import NewType
 
 
 import strawberry
 import strawberry
 
 
-BigInt = strawberry.scalar(
-    Union[int, str],  # type: ignore
+BigInt = NewType('BigInt', int)
+
+BigIntScalar = strawberry.scalar(
+    name='BigInt',
     serialize=lambda v: int(v),
     serialize=lambda v: int(v),
     parse_value=lambda v: str(v),
     parse_value=lambda v: str(v),
-    description="BigInt field",
+    description='BigInt field',
 )
 )

+ 9 - 2
netbox/netbox/graphql/schema.py

@@ -16,6 +16,8 @@ from virtualization.graphql.schema import VirtualizationQuery
 from vpn.graphql.schema import VPNQuery
 from vpn.graphql.schema import VPNQuery
 from wireless.graphql.schema import WirelessQuery
 from wireless.graphql.schema import WirelessQuery
 
 
+from .scalars import BigInt, BigIntScalar
+
 
 
 @strawberry.type
 @strawberry.type
 class Query(
 class Query(
@@ -36,9 +38,14 @@ class Query(
 
 
 schema = strawberry.Schema(
 schema = strawberry.Schema(
     query=Query,
     query=Query,
-    config=StrawberryConfig(auto_camel_case=False),
+    config=StrawberryConfig(
+        auto_camel_case=False,
+        scalar_map={
+            BigInt: BigIntScalar,
+        },
+    ),
     extensions=[
     extensions=[
         DjangoOptimizerExtension(prefetch_custom_queryset=True),
         DjangoOptimizerExtension(prefetch_custom_queryset=True),
         MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
         MaxAliasesLimiter(max_alias_count=settings.GRAPHQL_MAX_ALIASES),
-    ]
+    ],
 )
 )

+ 1 - 1
netbox/netbox/tables/tables.py

@@ -159,7 +159,7 @@ class BaseTable(tables.Table):
         columns = None
         columns = None
         ordering = None
         ordering = None
 
 
-        if self.prefixed_order_by_field in request.GET:
+        if request.user.is_authenticated and self.prefixed_order_by_field in request.GET:
             if request.GET[self.prefixed_order_by_field]:
             if request.GET[self.prefixed_order_by_field]:
                 # If an ordering has been specified as a query parameter, save it as the
                 # If an ordering has been specified as a query parameter, save it as the
                 # user's preferred ordering for this table.
                 # user's preferred ordering for this table.

+ 11 - 0
netbox/netbox/tests/test_tables.py

@@ -1,3 +1,4 @@
+from django.contrib.auth.models import AnonymousUser
 from django.template import Context, Template
 from django.template import Context, Template
 from django.test import RequestFactory, TestCase
 from django.test import RequestFactory, TestCase
 
 
@@ -46,6 +47,16 @@ class BaseTableTest(TestCase):
         prefetch_lookups = table.data.data._prefetch_related_lookups
         prefetch_lookups = table.data.data._prefetch_related_lookups
         self.assertEqual(prefetch_lookups, tuple())
         self.assertEqual(prefetch_lookups, tuple())
 
 
+    def test_configure_anonymous_user_with_ordering(self):
+        """
+        Verify that table.configure() does not raise an error when an anonymous
+        user sorts a table column.
+        """
+        request = RequestFactory().get('/?sort=name')
+        request.user = AnonymousUser()
+        table = DeviceTable(Device.objects.all())
+        table.configure(request)
+
 
 
 class TagColumnTable(NetBoxTable):
 class TagColumnTable(NetBoxTable):
     tags = columns.TagColumn(url_name='dcim:site_list')
     tags = columns.TagColumn(url_name='dcim:site_list')

+ 215 - 0
netbox/netbox/tests/test_ui.py

@@ -0,0 +1,215 @@
+from django.test import TestCase
+
+from circuits.choices import CircuitStatusChoices, VirtualCircuitTerminationRoleChoices
+from circuits.models import (
+    Provider,
+    ProviderNetwork,
+    VirtualCircuit,
+    VirtualCircuitTermination,
+    VirtualCircuitType,
+)
+from dcim.choices import InterfaceTypeChoices
+from dcim.models import Interface
+from netbox.ui import attrs
+from utilities.testing import create_test_device
+from vpn.choices import (
+    AuthenticationAlgorithmChoices,
+    AuthenticationMethodChoices,
+    DHGroupChoices,
+    EncryptionAlgorithmChoices,
+    IKEModeChoices,
+    IKEVersionChoices,
+    IPSecModeChoices,
+)
+from vpn.models import IKEPolicy, IKEProposal, IPSecPolicy, IPSecProfile
+
+
+class ChoiceAttrTest(TestCase):
+    """
+    Test class for validating the behavior of ChoiceAttr attribute accessor.
+
+    This test class verifies that the ChoiceAttr class correctly handles
+    choice field attributes on Django model instances, including both direct
+    field access and related object field access. It tests the retrieval of
+    display values and associated context information such as color values
+    for choice fields. The test data includes a network topology with devices,
+    interfaces, providers, and virtual circuits to cover various scenarios of
+    choice field access patterns.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+        interface = Interface.objects.create(
+            device=device,
+            name='vlan.100',
+            type=InterfaceTypeChoices.TYPE_VIRTUAL,
+        )
+
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        provider_network = ProviderNetwork.objects.create(
+            provider=provider,
+            name='Provider Network 1',
+        )
+        virtual_circuit_type = VirtualCircuitType.objects.create(
+            name='Virtual Circuit Type 1',
+            slug='virtual-circuit-type-1',
+        )
+        virtual_circuit = VirtualCircuit.objects.create(
+            cid='VC-100',
+            provider_network=provider_network,
+            type=virtual_circuit_type,
+            status=CircuitStatusChoices.STATUS_ACTIVE,
+        )
+
+        cls.termination = VirtualCircuitTermination.objects.create(
+            virtual_circuit=virtual_circuit,
+            role=VirtualCircuitTerminationRoleChoices.ROLE_PEER,
+            interface=interface,
+        )
+
+    def test_choice_attr_direct_accessor(self):
+        attr = attrs.ChoiceAttr('role')
+
+        self.assertEqual(
+            attr.get_value(self.termination),
+            self.termination.get_role_display(),
+        )
+        self.assertEqual(
+            attr.get_context(self.termination, {}),
+            {'bg_color': self.termination.get_role_color()},
+        )
+
+    def test_choice_attr_related_accessor(self):
+        attr = attrs.ChoiceAttr('interface.type')
+
+        self.assertEqual(
+            attr.get_value(self.termination),
+            self.termination.interface.get_type_display(),
+        )
+        self.assertEqual(
+            attr.get_context(self.termination, {}),
+            {'bg_color': None},
+        )
+
+    def test_choice_attr_related_accessor_with_color(self):
+        attr = attrs.ChoiceAttr('virtual_circuit.status')
+
+        self.assertEqual(
+            attr.get_value(self.termination),
+            self.termination.virtual_circuit.get_status_display(),
+        )
+        self.assertEqual(
+            attr.get_context(self.termination, {}),
+            {'bg_color': self.termination.virtual_circuit.get_status_color()},
+        )
+
+
+class RelatedObjectListAttrTest(TestCase):
+    """
+    Test suite for RelatedObjectListAttr functionality.
+
+    This test class validates the behavior of the RelatedObjectListAttr class,
+    which is used to render related objects as HTML lists. It tests various
+    scenarios including direct accessor access, related accessor access through
+    foreign keys, empty related object sets, and rendering with maximum item
+    limits and overflow indicators. The tests use IKE and IPSec VPN policy
+    models to verify proper rendering of one-to-many and many-to-many
+    relationships between objects.
+    """
+
+    @classmethod
+    def setUpTestData(cls):
+        cls.proposals = (
+            IKEProposal.objects.create(
+                name='IKE Proposal 1',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14,
+            ),
+            IKEProposal.objects.create(
+                name='IKE Proposal 2',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14,
+            ),
+            IKEProposal.objects.create(
+                name='IKE Proposal 3',
+                authentication_method=AuthenticationMethodChoices.PRESHARED_KEYS,
+                encryption_algorithm=EncryptionAlgorithmChoices.ENCRYPTION_AES128_CBC,
+                authentication_algorithm=AuthenticationAlgorithmChoices.AUTH_HMAC_SHA1,
+                group=DHGroupChoices.GROUP_14,
+            ),
+        )
+
+        cls.ike_policy = IKEPolicy.objects.create(
+            name='IKE Policy 1',
+            version=IKEVersionChoices.VERSION_1,
+            mode=IKEModeChoices.MAIN,
+        )
+        cls.ike_policy.proposals.set(cls.proposals)
+
+        cls.empty_ike_policy = IKEPolicy.objects.create(
+            name='IKE Policy 2',
+            version=IKEVersionChoices.VERSION_1,
+            mode=IKEModeChoices.MAIN,
+        )
+
+        cls.ipsec_policy = IPSecPolicy.objects.create(name='IPSec Policy 1')
+
+        cls.profile = IPSecProfile.objects.create(
+            name='IPSec Profile 1',
+            mode=IPSecModeChoices.ESP,
+            ike_policy=cls.ike_policy,
+            ipsec_policy=cls.ipsec_policy,
+        )
+        cls.empty_profile = IPSecProfile.objects.create(
+            name='IPSec Profile 2',
+            mode=IPSecModeChoices.ESP,
+            ike_policy=cls.empty_ike_policy,
+            ipsec_policy=cls.ipsec_policy,
+        )
+
+    def test_related_object_list_attr_direct_accessor(self):
+        attr = attrs.RelatedObjectListAttr('proposals', linkify=False)
+        rendered = attr.render(self.ike_policy, {'name': 'proposals'})
+
+        self.assertIn('list-unstyled mb-0', rendered)
+        self.assertInHTML('<li>IKE Proposal 1</li>', rendered)
+        self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
+        self.assertInHTML('<li>IKE Proposal 3</li>', rendered)
+        self.assertEqual(rendered.count('<li'), 3)
+
+    def test_related_object_list_attr_related_accessor(self):
+        attr = attrs.RelatedObjectListAttr('ike_policy.proposals', linkify=False)
+        rendered = attr.render(self.profile, {'name': 'proposals'})
+
+        self.assertIn('list-unstyled mb-0', rendered)
+        self.assertInHTML('<li>IKE Proposal 1</li>', rendered)
+        self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
+        self.assertInHTML('<li>IKE Proposal 3</li>', rendered)
+        self.assertEqual(rendered.count('<li'), 3)
+
+    def test_related_object_list_attr_empty_related_accessor(self):
+        attr = attrs.RelatedObjectListAttr('ike_policy.proposals', linkify=False)
+
+        self.assertEqual(
+            attr.render(self.empty_profile, {'name': 'proposals'}),
+            attr.placeholder,
+        )
+
+    def test_related_object_list_attr_max_items(self):
+        attr = attrs.RelatedObjectListAttr(
+            'ike_policy.proposals',
+            linkify=False,
+            max_items=2,
+            overflow_indicator='…',
+        )
+        rendered = attr.render(self.profile, {'name': 'proposals'})
+
+        self.assertInHTML('<li>IKE Proposal 1</li>', rendered)
+        self.assertInHTML('<li>IKE Proposal 2</li>', rendered)
+        self.assertNotIn('IKE Proposal 3', rendered)
+        self.assertIn('…', rendered)

+ 106 - 10
netbox/netbox/ui/attrs.py

@@ -18,6 +18,7 @@ __all__ = (
     'NumericAttr',
     'NumericAttr',
     'ObjectAttribute',
     'ObjectAttribute',
     'RelatedObjectAttr',
     'RelatedObjectAttr',
+    'RelatedObjectListAttr',
     'TemplatedAttr',
     'TemplatedAttr',
     'TextAttr',
     'TextAttr',
     'TimezoneAttr',
     'TimezoneAttr',
@@ -145,22 +146,40 @@ class ChoiceAttr(ObjectAttribute):
     """
     """
     A selection from a set of choices.
     A selection from a set of choices.
 
 
-    The class calls get_FOO_display() on the object to retrieve the human-friendly choice label. If a get_FOO_color()
-    method exists on the object, it will be used to render a background color for the attribute value.
+    The class calls get_FOO_display() on the terminal object resolved by the accessor
+    to retrieve the human-friendly choice label. For example, accessor="interface.type"
+    will call interface.get_type_display().
+    If a get_FOO_color() method exists on that object, it will be used to render a
+    background color for the attribute value.
     """
     """
     template_name = 'ui/attrs/choice.html'
     template_name = 'ui/attrs/choice.html'
 
 
+    def _resolve_target(self, obj):
+        if not self.accessor or '.' not in self.accessor:
+            return obj, self.accessor
+
+        object_accessor, field_name = self.accessor.rsplit('.', 1)
+        return resolve_attr_path(obj, object_accessor), field_name
+
     def get_value(self, obj):
     def get_value(self, obj):
-        try:
-            return getattr(obj, f'get_{self.accessor}_display')()
-        except AttributeError:
-            return resolve_attr_path(obj, self.accessor)
+        target, field_name = self._resolve_target(obj)
+        if target is None:
+            return None
+
+        display = getattr(target, f'get_{field_name}_display', None)
+        if callable(display):
+            return display()
+
+        return resolve_attr_path(target, field_name)
 
 
     def get_context(self, obj, context):
     def get_context(self, obj, context):
-        try:
-            bg_color = getattr(obj, f'get_{self.accessor}_color')()
-        except AttributeError:
-            bg_color = None
+        target, field_name = self._resolve_target(obj)
+        if target is None:
+            return {'bg_color': None}
+
+        get_color = getattr(target, f'get_{field_name}_color', None)
+        bg_color = get_color() if callable(get_color) else None
+
         return {
         return {
             'bg_color': bg_color,
             'bg_color': bg_color,
         }
         }
@@ -254,6 +273,83 @@ class RelatedObjectAttr(ObjectAttribute):
         }
         }
 
 
 
 
+class RelatedObjectListAttr(RelatedObjectAttr):
+    """
+    An attribute representing a list of related objects.
+
+    The accessor may resolve to a related manager or queryset.
+
+    Parameters:
+        max_items (int): Maximum number of items to display
+        overflow_indicator (str | None): Marker rendered as a final list item when
+            additional objects exist beyond `max_items`; set to None to suppress it
+    """
+
+    template_name = 'ui/attrs/object_list.html'
+
+    def __init__(self, *args, max_items=None, overflow_indicator='…', **kwargs):
+        super().__init__(*args, **kwargs)
+
+        if max_items is not None and (type(max_items) is not int or max_items < 1):
+            raise ValueError(
+                _('Invalid max_items value: {max_items}! Must be a positive integer or None.').format(
+                    max_items=max_items
+                )
+            )
+
+        self.max_items = max_items
+        self.overflow_indicator = overflow_indicator
+
+    def _get_items(self, obj):
+        """
+        Retrieve items from the given object using the accessor path.
+
+        Returns a tuple of (items, has_more) where items is a list of resolved objects
+        and has_more indicates whether additional items exist beyond the max_items limit.
+        """
+        items = resolve_attr_path(obj, self.accessor)
+        if items is None:
+            return [], False
+
+        if hasattr(items, 'all'):
+            items = items.all()
+
+        if self.max_items is None:
+            return list(items), False
+
+        items = list(items[:self.max_items + 1])
+        has_more = len(items) > self.max_items
+
+        return items[:self.max_items], has_more
+
+    def get_context(self, obj, context):
+        items, has_more = self._get_items(obj)
+
+        return {
+            'linkify': self.linkify,
+            'items': [
+                {
+                    'value': item,
+                    'group': getattr(item, self.grouped_by, None) if self.grouped_by else None,
+                }
+                for item in items
+            ],
+            'overflow_indicator': self.overflow_indicator if has_more else None,
+        }
+
+    def render(self, obj, context):
+        context = context or {}
+        context_data = self.get_context(obj, context)
+
+        if not context_data['items']:
+            return self.placeholder
+
+        return render_to_string(self.template_name, {
+            'name': context.get('name'),
+            **context_data,
+        })
+
+
 class NestedObjectAttr(ObjectAttribute):
 class NestedObjectAttr(ObjectAttribute):
     """
     """
     An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the
     An attribute representing a related nested object. Similar to `RelatedObjectAttr`, but includes the ancestors of the

+ 21 - 0
netbox/netbox/ui/panels.py

@@ -23,6 +23,7 @@ __all__ = (
     'PluginContentPanel',
     'PluginContentPanel',
     'RelatedObjectsPanel',
     'RelatedObjectsPanel',
     'TemplatePanel',
     'TemplatePanel',
+    'TextCodePanel',
 )
 )
 
 
 
 
@@ -67,6 +68,7 @@ class Panel:
         return {
         return {
             'request': context.get('request'),
             'request': context.get('request'),
             'object': context.get('object'),
             'object': context.get('object'),
+            'perms': context.get('perms'),
             'title': self.title,
             'title': self.title,
             'actions': self.actions,
             'actions': self.actions,
             'panel_class': self.__class__.__name__,
             'panel_class': self.__class__.__name__,
@@ -328,6 +330,25 @@ class TemplatePanel(Panel):
         return render_to_string(self.template_name, context.flatten())
         return render_to_string(self.template_name, context.flatten())
 
 
 
 
+class TextCodePanel(ObjectPanel):
+    """
+    A panel displaying a text field as a pre-formatted code block.
+    """
+    template_name = 'ui/panels/text_code.html'
+
+    def __init__(self, field_name, show_sync_warning=False, **kwargs):
+        super().__init__(**kwargs)
+        self.field_name = field_name
+        self.show_sync_warning = show_sync_warning
+
+    def get_context(self, context):
+        return {
+            **super().get_context(context),
+            'show_sync_warning': self.show_sync_warning,
+            'value': getattr(context.get('object'), self.field_name, None),
+        }
+
+
 class PluginContentPanel(Panel):
 class PluginContentPanel(Panel):
     """
     """
     A panel which displays embedded plugin content.
     A panel which displays embedded plugin content.

Failā izmaiņas netiks attēlotas, jo tās ir par lielu
+ 0 - 0
netbox/project-static/dist/netbox.css


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

@@ -60,7 +60,9 @@
     "@types/bootstrap/**/@popperjs/core": "^2.11.6",
     "@types/bootstrap/**/@popperjs/core": "^2.11.6",
     "eslint/**/minimatch": "^3.1.3",
     "eslint/**/minimatch": "^3.1.3",
     "eslint-plugin-import/**/minimatch": "^3.1.3",
     "eslint-plugin-import/**/minimatch": "^3.1.3",
-    "**/markdown-it": "^14.1.1"
+    "**/markdown-it": "^14.1.1",
+    "micromatch/picomatch": "2.3.2",
+    "tinyglobby/picomatch": "4.0.4"
   },
   },
   "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
   "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
 }
 }

+ 17 - 13
netbox/project-static/styles/custom/_code.scss

@@ -1,46 +1,50 @@
+@use 'sass:map';
+
 // Serialized data from change records
 // Serialized data from change records
 pre.change-data {
 pre.change-data {
   border-radius: 0;
   border-radius: 0;
   padding: 0;
   padding: 0;
+  // Remove card-body padding
+  margin-inline: -0.75rem;
 
 
   // Display each line individually for highlighting
   // Display each line individually for highlighting
   > span {
   > span {
     display: block;
     display: block;
-    padding-right: $spacer;
-    padding-left: $spacer;
-    width: 100%;
+    padding-inline: map.get($spacers, 2);
+    max-width: 100%;
     min-width: fit-content;
     min-width: fit-content;
+    border-left: map.get($spacers, 1) solid transparent;
 
 
     &.added {
     &.added {
-      color: var(--tblr-dark);
-      background-color: $green-300;
+      background-color: var(--tblr-green-200);
+      border-left-color: var(--tblr-green-darken);
     }
     }
 
 
     &.removed {
     &.removed {
-      color: var(--tblr-dark);
-      background-color: $red-300;
+      background-color: var(--tblr-red-200);
+      border-left-color: var(--tblr-red-darken);
     }
     }
   }
   }
 }
 }
 
 
 // Change data diff w/added & removed data
 // Change data diff w/added & removed data
 pre.change-diff {
 pre.change-diff {
-  border-color: transparent;
+  border: var(--tblr-border-width) solid transparent;
 
 
   &.change-added {
   &.change-added {
-    color: var(--tblr-dark);
-    background-color: $green-300;
+    background-color: var(--tblr-green-lt);
+    border-color: var(--tblr-green);
   }
   }
 
 
   &.change-removed {
   &.change-removed {
-    color: var(--tblr-dark);
-    background-color: $red-300;
+    background-color: var(--tblr-red-lt);
+    border-color: var(--tblr-red);
   }
   }
 }
 }
 
 
 // <pre> elements displayed with a border
 // <pre> elements displayed with a border
 pre.block {
 pre.block {
   padding: $spacer;
   padding: $spacer;
-  border: 1px solid $border-color;
+  border: var(--tblr-border-width) solid $border-color;
   border-radius: $border-radius;
   border-radius: $border-radius;
 }
 }

+ 19 - 9
netbox/project-static/yarn.lock

@@ -2076,9 +2076,9 @@ flatpickr@4.6.13:
   integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
   integrity sha512-97PMG/aywoYpB4IvbvUJi0RQi8vearvU0oov1WW3k0WZPBMrTQVqekSX5CjSG/M4Q3i6A/0FKXC7RyAoAUUSPw==
 
 
 flatted@^3.2.9:
 flatted@^3.2.9:
-  version "3.3.3"
-  resolved "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz"
-  integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==
+  version "3.4.2"
+  resolved "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz"
+  integrity sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==
 
 
 for-each@^0.3.3:
 for-each@^0.3.3:
   version "0.3.3"
   version "0.3.3"
@@ -2993,15 +2993,25 @@ path-parse@^1.0.7:
   resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
   resolved "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz"
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
   integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==
 
 
+picomatch@2.3.2:
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
+  integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
+
+picomatch@4.0.4:
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
+  integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
+
 picomatch@^2.3.1:
 picomatch@^2.3.1:
-  version "2.3.1"
-  resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz"
-  integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==
+  version "2.3.2"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.2.tgz#5a942915e26b372dc0f0e6753149a16e6b1c5601"
+  integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==
 
 
 picomatch@^4.0.3:
 picomatch@^4.0.3:
-  version "4.0.3"
-  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.3.tgz#796c76136d1eead715db1e7bad785dedd695a042"
-  integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==
+  version "4.0.4"
+  resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
+  integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
 
 
 possible-typed-array-names@^1.0.0:
 possible-typed-array-names@^1.0.0:
   version "1.0.0"
   version "1.0.0"

+ 2 - 2
netbox/release.yaml

@@ -1,3 +1,3 @@
-version: "4.5.5"
+version: "4.5.6"
 edition: "Community"
 edition: "Community"
-published: "2026-03-17"
+published: "2026-03-31"

+ 0 - 98
netbox/templates/circuits/circuit.html

@@ -1,104 +1,6 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.provider.pk }}">{{ object.provider }}</a></li>
   <li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.provider.pk }}">{{ object.provider }}</a></li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Circuit" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Provider" %}</th>
-            <td>{{ object.provider|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Account" %}</th>
-            <td>{{ object.provider_account|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Circuit ID" %}</th>
-            <td>{{ object.cid }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Type" %}</th>
-            <td>{{ object.type|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Status" %}</th>
-            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Distance" %}</th>
-            <td>
-              {% if object.distance is not None %}
-                {{ object.distance|floatformat }} {{ object.get_distance_unit_display }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Tenant" %}</th>
-            <td>
-              {% if object.tenant.group %}
-                {{ object.tenant.group|linkify }} /
-              {% endif %}
-              {{ object.tenant|linkify|placeholder }}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Install Date" %}</th>
-            <td>{{ object.install_date|isodate|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Termination Date" %}</th>
-            <td>{{ object.termination_date|isodate|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Commit Rate" %}</th>
-            <td>{{ object.commit_rate|humanize_speed|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">
-          {% trans "Group Assignments" %}
-          {% if perms.circuits.add_circuitgroupassignment %}
-            <div class="card-actions">
-              <a href="{% url 'circuits:circuitgroupassignment_add' %}?member_type={{ object|content_type_id }}&member={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
-              </a>
-            </div>
-          {% endif %}
-        </h2>
-        {% htmx_table 'circuits:circuitgroupassignment_list' circuit_id=object.pk %}
-      </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-12 col-md-6">
-      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_a side='A' %}
-      {% include 'circuits/inc/circuit_termination.html' with termination=object.termination_z side='Z' %}
-      {% include 'inc/panels/image_attachments.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 2 - 0
netbox/templates/circuits/circuit/attrs/commit_rate.html

@@ -0,0 +1,2 @@
+{% load helpers %}
+{{ value|humanize_speed }}

+ 0 - 30
netbox/templates/circuits/circuit_terminations_swap.html

@@ -1,30 +0,0 @@
-{% extends 'generic/confirmation_form.html' %}
-{% load i18n %}
-
-{% block title %}{% trans "Swap Circuit Terminations" %}{% endblock %}
-
-{% block message %}
-    <p>
-      {% blocktrans trimmed %}
-        Swap these terminations for circuit {{ circuit }}?
-      {% endblocktrans %}
-    </p>
-    <ul>
-        <li>
-            <strong>{% trans "A side" %}:</strong>
-            {% if termination_a %}
-                {{ termination_a.site }} {% if termination_a.interface %}- {{ termination_a.interface.device }} {{ termination_a.interface }}{% endif %}
-            {% else %}
-                {% trans "None" %}
-            {% endif %}
-        </li>
-        <li>
-            <strong>{% trans "Z side" %}:</strong>
-            {% if termination_z %}
-                {{ termination_z.site }} {% if termination_z.interface %}- {{ termination_z.interface.device }} {{ termination_z.interface }}{% endif %}
-            {% else %}
-                {% trans "None" %}
-            {% endif %}
-        </li>
-    </ul>
-{% endblock %}

+ 0 - 41
netbox/templates/circuits/circuitgroup.html

@@ -1,8 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -17,40 +13,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% endblock extra_controls %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Circuit Group" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Tenant" %}</th>
-            <td>
-              {% if object.tenant.group %}
-                {{ object.tenant.group|linkify }} /
-              {% endif %}
-              {{ object.tenant|linkify|placeholder }}
-            </td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/related_objects.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 44
netbox/templates/circuits/circuitgroupassignment.html

@@ -1,9 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
@@ -11,42 +6,3 @@
     <a href="{% url 'circuits:circuitgroupassignment_list' %}?group_id={{ object.group_id }}">{{ object.group }}</a>
     <a href="{% url 'circuits:circuitgroupassignment_list' %}?group_id={{ object.group_id }}">{{ object.group }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Circuit Group Assignment" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Group" %}</th>
-            <td>{{ object.group|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Provider" %}</th>
-            <td>{{ object.member.provider|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Circuit" %}</th>
-            <td>{{ object.member|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Priority" %}</th>
-            <td>{{ object.get_priority_display }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 42
netbox/templates/circuits/circuittermination.html

@@ -7,45 +7,3 @@
   {{ block.super }}
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
   <li class="breadcrumb-item"><a href="{% url 'circuits:circuit_list' %}?provider_id={{ object.circuit.provider.pk }}">{{ object.circuit.provider }}</a></li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-12 col-md-6">
-
-      <div class="card">
-            {% if object %}
-              <table class="table table-hover attr-table">
-                  <tr>
-                    <th scope="row">{% trans "Circuit" %}</th>
-                    <td>
-                      {{ object.circuit|linkify }}
-                    </td>
-                  </tr>
-                  <tr>
-                    <th scope="row">{% trans "Provider" %}</th>
-                    <td>
-                      {{ object.circuit.provider|linkify }}
-                    </td>
-                  </tr>
-                  {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
-              </table>
-          {% else %}
-            <div class="card-body">
-              <span class="text-muted">{% trans "None" %}</span>
-            </div>
-          {% endif %}
-      </div>
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 46
netbox/templates/circuits/circuittype.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
@@ -11,46 +8,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Circuit Type" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Color" %}</th>
-          <td>
-            {% if object.color %}
-              <span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
-            {% else %}
-              {{ ''|placeholder }}
-            {% endif %}
-          </td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-  </div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-	</div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 16 - 14
netbox/templates/circuits/inc/circuit_termination_fields.html

@@ -55,31 +55,33 @@
             <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
             <li><a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=circuits.circuittermination&a_terminations={{ termination.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a></li>
           </ul>
           </ul>
         </div>
         </div>
-      {% endif %}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
     </td>
     </td>
   </tr>
   </tr>
   <tr>
   <tr>
-      <th scope="row">{% trans "Speed" %}</th>
-      <td>
+    <th scope="row">{% trans "Speed" %}</th>
+    <td>
       {% if termination.port_speed and termination.upstream_speed %}
       {% if termination.port_speed and termination.upstream_speed %}
-          <i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
-          <i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
+        <i class="mdi mdi-arrow-down-bold" title="{% trans "Downstream" %}"></i> {{ termination.port_speed|humanize_speed }} &nbsp;
+        <i class="mdi mdi-arrow-up-bold" title="{% trans "Upstream" %}"></i> {{ termination.upstream_speed|humanize_speed }}
       {% elif termination.port_speed %}
       {% elif termination.port_speed %}
-          {{ termination.port_speed|humanize_speed }}
+        {{ termination.port_speed|humanize_speed }}
       {% else %}
       {% else %}
-          {{ ''|placeholder }}
+        {{ ''|placeholder }}
       {% endif %}
       {% endif %}
-      </td>
+    </td>
   </tr>
   </tr>
   <tr>
   <tr>
-      <th scope="row">{% trans "Cross-Connect" %}</th>
-      <td>{{ termination.xconnect_id|placeholder }}</td>
+    <th scope="row">{% trans "Cross-Connect" %}</th>
+    <td>{{ termination.xconnect_id|placeholder }}</td>
   </tr>
   </tr>
   <tr>
   <tr>
-      <th scope="row">{% trans "Patch Panel/Port" %}</th>
-      <td>{{ termination.pp_info|placeholder }}</td>
+    <th scope="row">{% trans "Patch Panel/Port" %}</th>
+    <td>{{ termination.pp_info|placeholder }}</td>
   </tr>
   </tr>
   <tr>
   <tr>
-      <th scope="row">{% trans "Description" %}</th>
-      <td>{{ termination.description|placeholder }}</td>
+    <th scope="row">{% trans "Description" %}</th>
+    <td>{{ termination.description|placeholder }}</td>
   </tr>
   </tr>

+ 69 - 0
netbox/templates/circuits/panels/circuit_circuit_termination.html

@@ -0,0 +1,69 @@
+{% load helpers %}
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header d-flex justify-content-between">
+    {% blocktrans %}Termination{% endblocktrans %} {{ side }}
+    <div class="card-actions">
+      {% if not termination and perms.circuits.add_circuittermination %}
+        <a href="{% url 'circuits:circuittermination_add' %}?circuit={{ object.pk }}&term_side={{ side }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-primary">
+          <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add" %}
+        </a>
+      {% endif %}
+      {% if termination and perms.circuits.change_circuittermination %}
+       <a href="{% url 'circuits:circuittermination_edit' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-warning">
+         <span class="mdi mdi-pencil" aria-hidden="true"></span> {% trans "Edit" %}
+       </a>
+      {% endif %}
+      {% if termination and perms.circuits.delete_circuittermination %}
+        <a href="{% url 'circuits:circuittermination_delete' pk=termination.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-ghost-danger">
+          <span class="mdi mdi-trash-can-outline" aria-hidden="true"></span> {% trans "Delete" %}
+        </a>
+      {% endif %}
+    </div>
+  </h2>
+  {% if termination %}
+    <table class="table table-hover attr-table">
+      {% include 'circuits/inc/circuit_termination_fields.html' with termination=termination %}
+      <tr>
+        <th scope="row">{% trans "Tags" %}</th>
+        <td>
+          {% for tag in termination.tags.all %}
+            {% tag tag %}
+          {% empty %}
+            {{ ''|placeholder }}
+          {% endfor %}
+        </td>
+      </tr>
+      {% for group_name, fields in termination.get_custom_fields_by_group.items %}
+        <tr>
+          <td colspan="2">
+            {% trans "Custom Fields" as default_group_label %}
+            <strong>{{ group_name|default:default_group_label }}</strong>
+          </td>
+        </tr>
+        {% for field, value in fields.items %}
+          <tr>
+            <th scope="row">{{ field }}
+              {% if field.description %}
+                <i
+                  class="mdi mdi-information text-primary"
+                  data-bs-toggle="tooltip"
+                  data-bs-placement="right"
+                  title="{{ field.description|escape }}"
+                ></i>
+             {% endif %}
+            </th>
+            <td>
+              {% customfield_value field value %}
+            </td>
+          </tr>
+        {% endfor %}
+      {% endfor %}
+    </table>
+  {% else %}
+    <div class="card-body">
+      <span class="text-muted">{% trans "None" %}</span>
+    </div>
+  {% endif %}
+</div>

+ 16 - 0
netbox/templates/circuits/panels/circuit_termination.html

@@ -0,0 +1,16 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers i18n %}
+
+{% block panel_content %}
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row">{% trans "Circuit" %}</th>
+      <td>{{ object.circuit|linkify|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Provider" %}</th>
+      <td>{{ object.circuit.provider|linkify|placeholder }}</td>
+    </tr>
+    {% include 'circuits/inc/circuit_termination_fields.html' with termination=object %}
+  </table>
+{% endblock panel_content %}

+ 0 - 49
netbox/templates/circuits/provider.html

@@ -12,52 +12,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	  <div class="col col-12 col-md-6">
-        <div class="card">
-          <h2 class="card-header">{% trans "Provider" %}</h2>
-          <table class="table table-hover attr-table">
-            <tr>
-              <th scope="row">{% trans "ASNs" %}</th>
-              <td>
-                {% for asn in object.asns.all %}
-                  {{ asn|linkify }}{% if not forloop.last %}, {% endif %}
-                {% empty %}
-                  {{ ''|placeholder }}
-                {% endfor %}
-              </td>
-            </tr>
-            <tr>
-              <th scope="row">{% trans "Description" %}</th>
-              <td>{{ object.description|placeholder }}</td>
-            </tr>
-          </table>
-        </div>
-        {% include 'inc/panels/tags.html' %}
-        {% include 'inc/panels/comments.html' %}
-        {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-        {% include 'inc/panels/related_objects.html' %}
-        {% include 'inc/panels/custom_fields.html' %}
-        {% plugin_right_page object %}
-    </div>
-</div>
-<div class="row mb-3">
-  <div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">{% trans "Provider Accounts" %}</h2>
-      {% htmx_table 'circuits:provideraccount_list' provider_id=object.pk %}
-    </div>
-  </div>
-  <div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">{% trans "Circuits" %}</h2>
-      {% htmx_table 'circuits:circuit_list' provider_id=object.pk %}
-    </div>
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 48
netbox/templates/circuits/provideraccount.html

@@ -1,54 +1,6 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'circuits:provideraccount_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
   <li class="breadcrumb-item"><a href="{% url 'circuits:provideraccount_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-	  <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Provider Account" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Provider" %}</th>
-            <td>{{ object.provider|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Account" %}</th>
-            <td>{{ object.account }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/related_objects.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">{% trans "Circuits" %}</h2>
-        {% htmx_table 'circuits:circuit_list' provider_account_id=object.pk %}
-      </div>
-    {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 63
netbox/templates/circuits/providernetwork.html

@@ -1,69 +1,6 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'circuits:providernetwork_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
   <li class="breadcrumb-item"><a href="{% url 'circuits:providernetwork_list' %}?provider_id={{ object.provider_id }}">{{ object.provider }}</a></li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Provider Network" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Provider" %}</th>
-            <td>{{ object.provider|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Service ID" %}</th>
-            <td>{{ object.service_id|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/related_objects.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row mb-3">
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">{% trans "Circuits" %}</h2>
-        {% htmx_table 'circuits:circuit_list' provider_network_id=object.pk %}
-      </div>
-      <div class="card">
-        <h2 class="card-header">
-          {% trans "Virtual Circuits" %}
-          {% if perms.circuits.add_virtualcircuit %}
-            <div class="card-actions">
-              <a href="{% url 'circuits:virtualcircuit_add' %}?provider_network={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-                <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Virtual Circuit" %}
-              </a>
-            </div>
-          {% endif %}
-        </h2>
-        {% htmx_table 'circuits:virtualcircuit_list' provider_network_id=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 90
netbox/templates/circuits/virtualcircuit.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
@@ -12,90 +9,3 @@
     <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.provider_network.pk }}">{{ object.provider_network }}</a>
     <a href="{% url 'circuits:virtualcircuit_list' %}?provider_network_id={{ object.provider_network.pk }}">{{ object.provider_network }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Virtual circuit" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Provider" %}</th>
-            <td>{{ object.provider|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Provider Network" %}</th>
-            <td>{{ object.provider_network|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Provider account" %}</th>
-            <td>{{ object.provider_account|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Circuit ID" %}</th>
-            <td>{{ object.cid }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Type" %}</th>
-            <td>{{ object.type|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Status" %}</th>
-            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Tenant" %}</th>
-            <td>
-              {% if object.tenant.group %}
-                {{ object.tenant.group|linkify }} /
-              {% endif %}
-              {{ object.tenant|linkify|placeholder }}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/comments.html' %}
-      <div class="card">
-        <h2 class="card-header">
-          {% trans "Group Assignments" %}
-          {% if perms.circuits.add_circuitgroupassignment %}
-            <div class="card-actions">
-              <a href="{% url 'circuits:circuitgroupassignment_add' %}?member_type={{ object|content_type_id }}&member={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Assign Group" %}
-              </a>
-            </div>
-          {% endif %}
-        </h2>
-        {% htmx_table 'circuits:circuitgroupassignment_list' virtual_circuit_id=object.pk %}
-      </div>
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">
-          {% trans "Terminations" %}
-          {% if perms.circuits.add_virtualcircuittermination %}
-            <div class="card-actions">
-              <a href="{% url 'circuits:virtualcircuittermination_add' %}?virtual_circuit={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm">
-                <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Termination" %}
-              </a>
-            </div>
-          {% endif %}
-        </h2>
-        {% htmx_table 'circuits:virtualcircuittermination_list' virtual_circuit_id=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 66
netbox/templates/circuits/virtualcircuittermination.html

@@ -1,6 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -15,67 +13,3 @@
     <a href="{% url 'circuits:virtualcircuittermination_list' %}?virtual_circuit_id={{ object.virtual_circuit.pk }}">{{ object.virtual_circuit }}</a>
     <a href="{% url 'circuits:virtualcircuittermination_list' %}?virtual_circuit_id={{ object.virtual_circuit.pk }}">{{ object.virtual_circuit }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Virtual Circuit Termination" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Provider" %}</th>
-            <td>{{ object.virtual_circuit.provider|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Provider Network" %}</th>
-            <td>{{ object.virtual_circuit.provider_network|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Provider account" %}</th>
-            <td>{{ object.virtual_circuit.provider_account|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Virtual circuit" %}</th>
-            <td>{{ object.virtual_circuit|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Role" %}</th>
-            <td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Interface" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Device" %}</th>
-            <td>{{ object.interface.device|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Interface" %}</th>
-            <td>{{ object.interface|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Type" %}</th>
-            <td>{{ object.interface.get_type_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.interface.description|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 46
netbox/templates/circuits/virtualcircuittype.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
@@ -11,46 +8,3 @@
     </a>
     </a>
   {% endif %}
   {% endif %}
 {% endblock extra_controls %}
 {% endblock extra_controls %}
-
-{% block content %}
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Virtual Circuit Type" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Color" %}</th>
-          <td>
-            {% if object.color %}
-              <span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
-            {% else %}
-              {{ ''|placeholder }}
-            {% endif %}
-          </td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' %}
-    {% plugin_left_page object %}
-  </div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panels/related_objects.html' %}
-    {% include 'inc/panels/comments.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-	</div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 22
netbox/templates/core/configrevision.html

@@ -1,10 +1,7 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
 {% load buttons %}
 {% load buttons %}
-{% load custom_links %}
 {% load helpers %}
 {% load helpers %}
 {% load perms %}
 {% load perms %}
-{% load plugins %}
-{% load static %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -27,22 +24,3 @@
     </div>
     </div>
   {% endif %}
   {% endif %}
 {% endblock subtitle %}
 {% endblock subtitle %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">{% trans "Configuration Data" %}</h2>
-        {% include 'core/inc/config_data.html' %}
-      </div>
-
-      <div class="card">
-        <h2 class="card-header">{% trans "Comment" %}</h2>
-        <div class="card-body">
-          {{ object.comment|placeholder }}
-        </div>
-      </div>
-
-    </div>
-  </div>
-{% endblock %}

+ 0 - 55
netbox/templates/core/datafile.html

@@ -1,62 +1,7 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load buttons %}
-{% load custom_links %}
-{% load helpers %}
-{% load perms %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
   {{ block.super }}
   {{ block.super }}
   <li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
   <li class="breadcrumb-item"><a href="{% url 'core:datafile_list' %}?source_id={{ object.source.pk }}">{{ object.source }}</a></li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col">
-      <div class="card">
-        <h2 class="card-header">{% trans "Data File" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Source" %}</th>
-            <td>{{ object.source|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Path" %}</th>
-            <td>
-              <span class="font-monospace" id="datafile_path">{{ object.path }}</span>
-              {% copy_content "datafile_path" %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Last Updated" %}</th>
-            <td>{{ object.last_updated }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Size" %}</th>
-            <td>{{ object.size }} {% trans "bytes" %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "SHA256 Hash" %}</th>
-            <td>
-            <span class="font-monospace" id="datafile_hash">{{ object.hash }}</span>
-              {% copy_content "datafile_hash" %}
-            </td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Content" %}</h2>
-        <div class="card-body">
-          <pre>{{ object.data_as_string }}</pre>
-        </div>
-      </div>
-      {% plugin_left_page object %}
-    </div>
-  </div>
-  <div class="row mb-3">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 1 - 0
netbox/templates/core/datafile/attrs/size.html

@@ -0,0 +1 @@
+{% load i18n %}{{ value }} {% trans "bytes" %}

+ 0 - 103
netbox/templates/core/datasource.html

@@ -1,8 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load static %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block extra_controls %}
 {% block extra_controls %}
@@ -23,102 +19,3 @@
     {% endif %}
     {% endif %}
   {% endif %}
   {% endif %}
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Data Source" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Type" %}</th>
-            <td>{{ object.get_type_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Enabled" %}</th>
-            <td>{% checkmark object.enabled %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Status" %}</th>
-            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Sync interval" %}</th>
-            <td>{{ object.get_sync_interval_display|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Last synced" %}</th>
-            <td>{{ object.last_synced|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "URL" %}</th>
-            <td>
-              {% if not object.type.is_local %}
-                <a href="{{ object.source_url }}">{{ object.source_url }}</a>
-              {% else %}
-                {{ object.source_url }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Ignore rules" %}</th>
-            <td>
-              {% if object.ignore_rules %}
-                <pre>{{ object.ignore_rules }}</pre>
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Backend" %}</h2>
-          {% with backend=object.backend_class %}
-            <table class="table table-hover attr-table">
-              {% for name, field in backend.parameters.items %}
-                <tr>
-                  <th scope="row">{{ field.label }}</th>
-                  {% if name in backend.sensitive_parameters %}
-                    <td>********</td>
-                  {% else %}
-                    <td>{{ object.parameters|get_key:name|placeholder }}</td>
-                  {% endif %}
-                </tr>
-              {% empty %}
-                <tr>
-                  <td colspan="2" class="text-muted">
-                    {% trans "No parameters defined" %}
-                  </td>
-                </tr>
-              {% endfor %}
-            </table>
-          {% endwith %}
-      </div>
-      {% include 'inc/panels/related_objects.html' %}
-      {% include 'inc/panels/custom_fields.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row mb-3">
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">{% trans "Files" %}</h2>
-        {% htmx_table 'core:datafile_list' source_id=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 1 - 0
netbox/templates/core/datasource/attrs/ignore_rules.html

@@ -0,0 +1 @@
+<pre>{{ value }}</pre>

+ 1 - 0
netbox/templates/core/datasource/attrs/source_url.html

@@ -0,0 +1 @@
+{% if not object.type.is_local %}<a href="{{ value }}">{{ value }}</a>{% else %}{{ value }}{% endif %}

+ 0 - 77
netbox/templates/core/job.html

@@ -1,78 +1 @@
 {% extends 'core/job/base.html' %}
 {% extends 'core/job/base.html' %}
-{% load i18n %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Job" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Object Type" %}</th>
-            <td>
-              <a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ object.object_type }}</a>
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.name|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Status" %}</th>
-            <td>{% badge object.get_status_display object.get_status_color %}</td>
-          </tr>
-          {% if object.error %}
-            <tr>
-              <th scope="row">{% trans "Error" %}</th>
-              <td>{{ object.error }}</td>
-            </tr>
-          {% endif %}
-          <tr>
-            <th scope="row">{% trans "Created By" %}</th>
-            <td>{{ object.user|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-    </div>
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Scheduling" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Created" %}</th>
-            <td>{{ object.created|isodatetime }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Scheduled" %}</th>
-            <td>
-              {{ object.scheduled|isodatetime|placeholder }}
-              {% if object.interval %}
-                ({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %})
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Started" %}</th>
-            <td>{{ object.started|isodatetime|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Completed" %}</th>
-            <td>{{ object.completed|isodatetime|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Queue" %}</th>
-            <td>{{ object.queue_name|placeholder }}</td>
-          </tr>
-        </table>
-      </div>
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-12">
-      <div class="card">
-        <h2 class="card-header">{% trans "Data" %}</h2>
-        <pre class="card-body m-0">{{ object.data|json }}</pre>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 1 - 0
netbox/templates/core/job/attrs/object_type.html

@@ -0,0 +1 @@
+<a href="{% url 'core:job_list' %}?object_type={{ object.object_type_id }}">{{ value }}</a>

+ 3 - 0
netbox/templates/core/job/attrs/scheduled.html

@@ -0,0 +1,3 @@
+{% load helpers %}
+{% load i18n %}
+{{ value|isodatetime }}{% if object.interval %} ({% blocktrans with interval=object.interval %}every {{ interval }} minutes{% endblocktrans %}){% endif %}

+ 0 - 11
netbox/templates/core/job/log.html

@@ -1,12 +1 @@
 {% extends 'core/job/base.html' %}
 {% extends 'core/job/base.html' %}
-{% load render_table from django_tables2 %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col">
-      <div class="card">
-        {% render_table table %}
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 0 - 180
netbox/templates/core/objectchange.html

@@ -1,6 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block title %}{{ object }}{% endblock %}
 {% block title %}{{ object }}{% endblock %}
@@ -21,181 +19,3 @@
 {# ObjectChange does not support the default add/edit/delete controls #}
 {# ObjectChange does not support the default add/edit/delete controls #}
 {% block control-buttons %}{% endblock %}
 {% block control-buttons %}{% endblock %}
 {% block subtitle %}{% endblock %}
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-<div class="row">
-    <div class="col col-12 col-md-5">
-        <div class="card">
-            <h2 class="card-header">{% trans "Change" %}</h2>
-            <table class="table table-hover attr-table">
-                <tr>
-                    <th scope="row">{% trans "Time" %}</th>
-                    <td>{{ object.time|isodatetime }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "User" %}</th>
-                    <td>
-                        {% if object.user.get_full_name %}
-                          {{ object.user.get_full_name }} ({{ object.user_name }})
-                        {% else %}
-                          {{ object.user_name }}
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Action" %}</th>
-                    <td>
-                        {{ object.get_action_display }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Object Type" %}</th>
-                    <td>
-                        {{ object.changed_object_type }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Object" %}</th>
-                    <td>
-                        {% if object.changed_object and object.changed_object.get_absolute_url %}
-                            {{ object.changed_object|linkify }}
-                        {% else %}
-                            {{ object.object_repr }}
-                        {% endif %}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Message" %}</th>
-                    <td>
-                        {{ object.message|placeholder }}
-                    </td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Request ID" %}</th>
-                    <td>
-                        <a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}">{{ object.request_id }}</a>
-                    </td>
-                </tr>
-            </table>
-        </div>
-    </div>
-    <div class="col col-12 col-md-7">
-        <div class="card">
-            <h2 class="card-header d-flex justify-content-between">
-              {% trans "Difference" %}
-              <div class="btn-group btn-group-sm d-print-none">
-                <a {% if prev_change %}href="{% url 'core:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
-                  <i class="mdi mdi-chevron-left" aria-hidden="true"></i> {% trans "Previous" %}
-                </a>
-                <a {% if next_change %}href="{% url 'core:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-outline-secondary">
-                  {% trans "Next" %} <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
-                </a>
-              </div>
-            </h2>
-            <div class="card-body">
-                {% if diff_added == diff_removed %}
-                    <span class="text-muted" style="margin-left: 10px;">
-                        {% if object.action == 'create' %}
-                            {% trans "Object Created" %}
-                        {% elif object.action == 'delete' %}
-                            {% trans "Object Deleted" %}
-                        {% else %}
-                            {% trans "No Changes" %}
-                        {% endif %}
-                    </span>
-                {% else %}
-                    <pre class="change-diff change-removed">{{ diff_removed|json }}</pre>
-                    <pre class="change-diff change-added">{{ diff_added|json }}</pre>
-                {% endif %}
-            </div>
-        </div>
-    </div>
-</div>
-<div class="row">
-    <div class="col col-12 col-md-6">
-        <div class="card">
-            <h2 class="card-header">{% trans "Pre-Change Data" %}</h2>
-            <div class="card-body">
-            {% if object.prechange_data %}
-              {% spaceless %}
-                <pre class="change-data">
-                  {% for k, v in object.prechange_data_clean.items %}
-                    {% with subdiff=diff_removed|get_key:k %}
-                      {% if subdiff.items %}
-                        <span>{{ k }}: {</span>
-                        {% for sub_k, sub_v in v.items %}
-                          <span class="ps-4{% if sub_k in subdiff %} removed{% endif %}">{{ sub_k }}: {{ sub_v|json }}</span>
-                        {% endfor %}
-                        <span>}</span>
-                      {% else %}
-                        <span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
-                      {% endif %}
-                    {% endwith %}
-                  {% endfor %}
-                </pre>
-              {% endspaceless %}
-            {% elif non_atomic_change %}
-              {% trans "Warning: Comparing non-atomic change to previous change record" %} (<a href="{% url 'core:objectchange' pk=prev_change.pk %}">{{ prev_change.pk }}</a>)
-            {% else %}
-              <span class="text-muted">{% trans "None" %}</span>
-            {% endif %}
-            </div>
-        </div>
-    </div>
-    <div class="col col-12 col-md-6">
-        <div class="card">
-            <h2 class="card-header">{% trans "Post-Change Data" %}</h2>
-            <div class="card-body">
-                {% if object.postchange_data %}
-                  {% spaceless %}
-                    <pre class="change-data">
-                      {% for k, v in object.postchange_data_clean.items %}
-                        {% with subdiff=diff_added|get_key:k %}
-                          {% if subdiff.items %}
-                            <span>{{ k }}: {</span>
-                            {% for sub_k, sub_v in v.items %}
-                              <span class="ps-4{% if sub_k in subdiff %} added{% endif %}">{{ sub_k }}: {{ sub_v|json }}</span>
-                            {% endfor %}
-                            <span>}</span>
-                          {% else %}
-                            <span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
-                          {% endif %}
-                        {% endwith %}
-                      {% endfor %}
-                    </pre>
-                  {% endspaceless %}
-                {% else %}
-                  <span class="text-muted">{% trans "None" %}</span>
-                {% endif %}
-            </div>
-        </div>
-    </div>
-</div>
-<div class="row">
-  <div class="col col-12 col-md-6">
-    {% plugin_left_page object %}
-  </div>
-  <div class="col col-12 col-md-6">
-    {% plugin_right_page object %}
-  </div>
-</div>
-<div class="row">
-    <div class="col col-md-12">
-        {% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
-        {% if related_changes_count > related_changes_table.rows|length %}
-            <div class="float-end">
-                <a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">
-                  {% blocktrans trimmed with count=related_changes_count|add:"1" %}
-                    See All {{ count }} Changes
-                  {% endblocktrans %}
-                </a>
-            </div>
-        {% endif %}
-    </div>
-</div>
-<div class="row">
-  <div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 2 - 0
netbox/templates/core/objectchange/attrs/changed_object.html

@@ -0,0 +1,2 @@
+{% load helpers %}
+{% if object.changed_object and object.changed_object.get_absolute_url %}{{ object.changed_object|linkify }}{% else %}{{ value }}{% endif %}

+ 1 - 0
netbox/templates/core/objectchange/attrs/request_id.html

@@ -0,0 +1 @@
+<a href="{% url 'core:objectchange_list' %}?request_id={{ value }}">{{ value }}</a>

+ 1 - 0
netbox/templates/core/objectchange/attrs/user.html

@@ -0,0 +1 @@
+{% if object.user and object.user.get_full_name %}{{ object.user.get_full_name }} ({{ value }}){% else %}{{ value }}{% endif %}

+ 11 - 0
netbox/templates/core/panels/configrevision_comment.html

@@ -0,0 +1,11 @@
+{% load i18n %}
+<div class="card">
+  <h2 class="card-header">{% trans "Comment" %}</h2>
+  <div class="card-body">
+    {% if object.comment %}
+      {{ object.comment }}
+    {% else %}
+      <span class="text-muted">&mdash;</span>
+    {% endif %}
+  </div>
+</div>

+ 5 - 0
netbox/templates/core/panels/configrevision_data.html

@@ -0,0 +1,5 @@
+{% load i18n %}
+<div class="card">
+  <h2 class="card-header">{% trans "Configuration Data" %}</h2>
+  {% include 'core/inc/config_data.html' %}
+</div>

+ 8 - 0
netbox/templates/core/panels/datafile_content.html

@@ -0,0 +1,8 @@
+{% extends "ui/panels/_base.html" %}
+{% load i18n %}
+
+{% block panel_content %}
+  <div class="card-body">
+    <pre>{{ object.data_as_string }}</pre>
+  </div>
+{% endblock panel_content %}

+ 26 - 0
netbox/templates/core/panels/datasource_backend.html

@@ -0,0 +1,26 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers %}
+{% load i18n %}
+
+{% block panel_content %}
+  {% with backend=object.backend_class %}
+    <table class="table table-hover attr-table">
+      {% for name, field in backend.parameters.items %}
+        <tr>
+          <th scope="row">{{ field.label }}</th>
+          {% if name in backend.sensitive_parameters %}
+            <td>********</td>
+          {% else %}
+            <td>{{ object.parameters|get_key:name|placeholder }}</td>
+          {% endif %}
+        </tr>
+      {% empty %}
+        <tr>
+          <td colspan="2" class="text-muted">
+            {% trans "No parameters defined" %}
+          </td>
+        </tr>
+      {% endfor %}
+    </table>
+  {% endwith %}
+{% endblock panel_content %}

+ 31 - 0
netbox/templates/core/panels/objectchange_difference.html

@@ -0,0 +1,31 @@
+{% load helpers %}
+{% load i18n %}
+<div class="card">
+  <h2 class="card-header d-flex justify-content-between">
+    {% trans "Difference" %}
+    <div class="card-actions">
+      <a {% if prev_change %}href="{% url 'core:objectchange' pk=prev_change.pk %}"{% else %}disabled{% endif %} class="btn btn-ghost-primary btn-sm">
+        <i class="mdi mdi-chevron-left" aria-hidden="true"></i> {% trans "Previous" %}
+      </a>
+      <a {% if next_change %}href="{% url 'core:objectchange' pk=next_change.pk %}"{% else %}disabled{% endif %} class="btn btn-ghost-primary btn-sm">
+        {% trans "Next" %} <i class="mdi mdi-chevron-right" aria-hidden="true"></i>
+      </a>
+    </div>
+  </h2>
+  <div class="card-body">
+    {% if diff_added == diff_removed %}
+      <span class="text-muted" style="margin-left: 10px;">
+        {% if object.action == 'create' %}
+          {% trans "Object Created" %}
+        {% elif object.action == 'delete' %}
+          {% trans "Object Deleted" %}
+        {% else %}
+          {% trans "No Changes" %}
+        {% endif %}
+      </span>
+    {% else %}
+      <pre class="change-diff change-removed">{{ diff_removed|json }}</pre>
+      <pre class="change-diff change-added">{{ diff_added|json }}</pre>
+    {% endif %}
+  </div>
+</div>

+ 18 - 0
netbox/templates/core/panels/objectchange_postchange.html

@@ -0,0 +1,18 @@
+{% load helpers %}
+{% load i18n %}
+<div class="card">
+  <h2 class="card-header">{% trans "Post-Change Data" %}</h2>
+  <div class="card-body">
+    {% if object.postchange_data %}
+      {% spaceless %}
+        <pre class="change-data">
+          {% for k, v in object.postchange_data_clean.items %}
+            <span{% if k in diff_added %} class="added"{% endif %}>{{ k }}: {{ v|json }}</span>
+          {% endfor %}
+        </pre>
+      {% endspaceless %}
+    {% else %}
+      <span class="text-muted">{% trans "None" %}</span>
+    {% endif %}
+  </div>
+</div>

+ 20 - 0
netbox/templates/core/panels/objectchange_prechange.html

@@ -0,0 +1,20 @@
+{% load helpers %}
+{% load i18n %}
+<div class="card">
+  <h2 class="card-header">{% trans "Pre-Change Data" %}</h2>
+  <div class="card-body">
+    {% if object.prechange_data %}
+      {% spaceless %}
+        <pre class="change-data">
+          {% for k, v in object.prechange_data_clean.items %}
+            <span{% if k in diff_removed %} class="removed"{% endif %}>{{ k }}: {{ v|json }}</span>
+          {% endfor %}
+        </pre>
+      {% endspaceless %}
+    {% elif non_atomic_change %}
+      {% trans "Warning: Comparing non-atomic change to previous change record" %} (<a href="{% url 'core:objectchange' pk=prev_change.pk %}">{{ prev_change.pk }}</a>)
+    {% else %}
+      <span class="text-muted">{% trans "None" %}</span>
+    {% endif %}
+  </div>
+</div>

+ 11 - 0
netbox/templates/core/panels/objectchange_related.html

@@ -0,0 +1,11 @@
+{% load i18n %}
+{% include 'inc/panel_table.html' with table=related_changes_table heading='Related Changes' panel_class='default' %}
+{% if related_changes_count > related_changes_table.rows|length %}
+  <div class="float-end">
+    <a href="{% url 'core:objectchange_list' %}?request_id={{ object.request_id }}" class="btn btn-primary">
+      {% blocktrans trimmed with count=related_changes_count|add:"1" %}
+        See All {{ count }} Changes
+      {% endblocktrans %}
+    </a>
+  </div>
+{% endif %}

+ 0 - 90
netbox/templates/dcim/cable.html

@@ -1,91 +1 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load buttons %}
-{% load helpers %}
-{% load perms %}
-{% load plugins %}
-{% load i18n %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Cable" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Type" %}</th>
-            <td>{{ object.get_type_display|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Status" %}</th>
-            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Profile" %}</th>
-            <td>{% badge object.get_profile_display %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Tenant" %}</th>
-            <td>
-              {% if object.tenant.group %}
-                {{ object.tenant.group|linkify }} /
-              {% endif %}
-              {{ object.tenant|linkify|placeholder }}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Bundle" %}</th>
-            <td>{{ object.bundle|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Label" %}</th>
-            <td>{{ object.label|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Color" %}</th>
-            <td>
-              {% if object.color %}
-                <span class="color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Length" %}</th>
-            <td>
-              {% if object.length is not None %}
-                {{ object.length|floatformat }} {{ object.get_length_unit_display }}
-              {% else %}
-                {{ ''|placeholder }}
-              {% endif %}
-            </td>
-          </tr>
-        </table>
-      </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-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Termination" %} A</h2>
-        {% include 'dcim/inc/cable_termination.html' with terminations=object.a_terminations %}
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Termination" %} B</h2>
-        {% include 'dcim/inc/cable_termination.html' with terminations=object.b_terminations %}
-      </div>
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 87
netbox/templates/dcim/consoleport.html

@@ -1,6 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -9,88 +7,3 @@
     <a href="{% url 'dcim:device_consoleports' pk=object.device.pk %}">{{ object.device }}</a>
     <a href="{% url 'dcim:device_consoleports' pk=object.device.pk %}">{{ object.device }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-    <div class="row">
-        <div class="col col-12 col-md-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Console Port" %}</h2>
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">{% trans "Device" %}</th>
-                        <td>{{ object.device|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Module" %}</th>
-                        <td>{{ object.module|linkify|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Name" %}</th>
-                        <td>{{ object.name }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Label" %}</th>
-                        <td>{{ object.label|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Type" %}</th>
-                        <td>{{ object.get_type_display }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Speed" %}</th>
-                        <td>{{ object.get_speed_display }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Description" %}</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' %}
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-12 col-md-6">
-          <div class="card">
-            <h2 class="card-header">{% trans "Connection" %}</h2>
-            {% if object.mark_connected %}
-              <div class="card-body">
-                <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
-                {% trans "Marked as connected" %}
-              </div>
-            {% elif object.cable %}
-              {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleport_trace' %}
-            {% else %}
-              <div class="card-body text-muted">
-                {% trans "Not Connected" %}
-                {% if perms.dcim.add_cable %}
-                  <div class="dropdown float-end">
-                    <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
-                      <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
-                    </button>
-                    <ul class="dropdown-menu dropdown-menu-end">
-                      <li>
-                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Console Server Port" %}</a>
-                      </li>
-                      <li>
-                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Front Port" %}</a>
-                      </li>
-                      <li>
-                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Rear Port" %}</a>
-                      </li>
-                    </ul>
-                  </div>
-                {% endif %}
-              </div>
-            {% endif %}
-          </div>
-          {% include 'dcim/inc/panels/inventory_items.html' %}
-          {% plugin_right_page object %}
-        </div>
-    </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
-        </div>
-    </div>
-{% endblock %}

+ 0 - 87
netbox/templates/dcim/consoleserverport.html

@@ -1,6 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -9,88 +7,3 @@
     <a href="{% url 'dcim:device_consoleserverports' pk=object.device.pk %}">{{ object.device }}</a>
     <a href="{% url 'dcim:device_consoleserverports' pk=object.device.pk %}">{{ object.device }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-    <div class="row">
-        <div class="col col-12 col-md-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Console Server Port" %}</h2>
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">{% trans "Device" %}</th>
-                        <td>{{ object.device|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Module" %}</th>
-                        <td>{{ object.module|linkify|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Name" %}</th>
-                        <td>{{ object.name }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Label" %}</th>
-                        <td>{{ object.label|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Type" %}</th>
-                        <td>{{ object.get_type_display|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Speed" %}</th>
-                        <td>{{ object.get_speed_display|placeholder }}</td>
-                </tr>
-                    <tr>
-                        <th scope="row">{% trans "Description" %}</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' %}
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-12 col-md-6">
-          <div class="card">
-            <h2 class="card-header">{% trans "Connection" %}</h2>
-            {% if object.mark_connected %}
-              <div class="card-body">
-                <span class="text-success"><i class="mdi mdi-check-bold"></i></span>
-                {% trans "Marked as connected" %}
-              </div>
-            {% elif object.cable %}
-              {% include 'dcim/inc/connection_endpoints.html' with trace_url='dcim:consoleserverport_trace' %}
-            {% else %}
-              <div class="card-body text-muted">
-                {% trans "Not Connected" %}
-                {% if perms.dcim.add_cable %}
-                  <div class="dropdown float-end">
-                    <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
-                      <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
-                    </button>
-                    <ul class="dropdown-menu dropdown-menu-end">
-                      <li>
-                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Console Port" %}</a>
-                      </li>
-                      <li>
-                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Front Port" %}</a>
-                      </li>
-                      <li>
-                        <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.consoleserverport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}" class="dropdown-item">{% trans "Rear Port" %}</a>
-                      </li>
-                    </ul>
-                  </div>
-                {% endif %}
-              </div>
-            {% endif %}
-          </div>
-          {% include 'dcim/inc/panels/inventory_items.html' %}
-          {% plugin_right_page object %}
-        </div>
-    </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
-        </div>
-    </div>
-{% endblock %}

+ 0 - 62
netbox/templates/dcim/devicebay.html

@@ -1,6 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -9,63 +7,3 @@
     <a href="{% url 'dcim:device_devicebays' pk=object.device.pk %}">{{ object.device }}</a>
     <a href="{% url 'dcim:device_devicebays' pk=object.device.pk %}">{{ object.device }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-    <div class="row">
-        <div class="col col-12 col-md-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Device Bay" %}</h2>
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">{% trans "Device" %}</th>
-                        <td>{{ object.device|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Name" %}</th>
-                        <td>{{ object.name }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Label" %}</th>
-                        <td>{{ object.label|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Description" %}</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' %}
-        {% plugin_left_page object %}
-        </div>
-        <div class="col col-12 col-md-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Installed Device" %}</h2>
-                {% if object.installed_device %}
-                    {% with device=object.installed_device %}
-                        <table class="table table-hover attr-table">
-                            <tr>
-                                <th scope="row">{% trans "Device" %}</th>
-                                <td>{{ device|linkify }}</td>
-                            </tr>
-                            <tr>
-                                <th scope="row">{% trans "Device Type" %}</th>
-                                <td>{{ device.device_type }}</td>
-                            </tr>
-                        </table>
-                    {% endwith %}
-                {% else %}
-                    <div class="card-body text-muted">
-                        {% trans "None" %}
-                    </div>
-                {% endif %}
-            </div>
-            {% plugin_right_page object %}
-        </div>
-    </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
-        </div>
-    </div>
-{% endblock %}

+ 0 - 148
netbox/templates/dcim/frontport.html

@@ -1,6 +1,4 @@
 {% extends 'generic/object.html' %}
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
 {% load i18n %}
 {% load i18n %}
 
 
 {% block breadcrumbs %}
 {% block breadcrumbs %}
@@ -9,149 +7,3 @@
     <a href="{% url 'dcim:device_frontports' pk=object.device.pk %}">{{ object.device }}</a>
     <a href="{% url 'dcim:device_frontports' pk=object.device.pk %}">{{ object.device }}</a>
   </li>
   </li>
 {% endblock %}
 {% endblock %}
-
-{% block content %}
-    <div class="row">
-        <div class="col col-12 col-md-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Front Port" %}</h2>
-                <table class="table table-hover attr-table">
-                    <tr>
-                        <th scope="row">{% trans "Device" %}</th>
-                        <td>{{ object.device|linkify }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Module" %}</th>
-                        <td>{{ object.module|linkify|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Name" %}</th>
-                        <td>{{ object.name }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Label" %}</th>
-                        <td>{{ object.label|placeholder }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Type" %}</th>
-                        <td>{{ object.get_type_display }}</td>
-                    </tr>
-                    <tr>
-                      <th scope="row">{% trans "Color" %}</th>
-                      <td>
-                        {% if object.color %}
-                          <span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
-                        {% else %}
-                          {{ ''|placeholder }}
-                        {% endif %}
-                      </td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Positions" %}</th>
-                        <td>{{ object.positions }}</td>
-                    </tr>
-                    <tr>
-                        <th scope="row">{% trans "Description" %}</th>
-                        <td>{{ object.description|placeholder }}</td>
-                    </tr>
-                </table>
-            </div>
-            {% include 'inc/panels/custom_fields.html' %}
-            {% include 'inc/panels/tags.html' %}
-            {% include 'dcim/inc/panels/inventory_items.html' %}
-            {% plugin_left_page object %}
-        </div>
-        <div class="col col-12 col-md-6">
-            <div class="card">
-                <h2 class="card-header">{% trans "Connection" %}</h2>
-                {% if object.mark_connected %}
-                    <div class="card-body text-muted">
-                      <span class="text-success"><i class="mdi mdi-check-bold"></i></span> {% trans "Marked as Connected" %}
-                    </div>
-                {% elif object.cable %}
-                    <table class="table table-hover attr-table">
-                        <tr>
-                            <th scope="row">{% trans "Cable" %}</th>
-                            <td>
-                                {{ object.cable|linkify }}
-                                <a href="{% url 'dcim:frontport_trace' pk=object.pk %}" class="btn btn-sm btn-primary" title="{% trans "Trace" %}">
-                                    <i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
-                                </a>
-                            </td>
-                        </tr>
-                        <tr>
-                            <th scope="row">{% trans "Connection Status" %}</th>
-                            <td>
-                                {% if object.cable.status %}
-                                    <span class="badge text-bg-success">{{ object.cable.get_status_display }}</span>
-                                {% else %}
-                                    <span class="badge text-bg-info">{{ object.cable.get_status_display }}</span>
-                                {% endif %}
-                            </td>
-                        </tr>
-                    </table>
-                {% else %}
-                    <div class="card-body text-muted">
-                        {% trans "Not Connected" %}
-                        {% if perms.dcim.add_cable %}
-                            <div class="dropdown float-end">
-                                <button type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
-                                    <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> {% trans "Connect" %}
-                                </button>
-                                <ul class="dropdown-menu dropdown-menu-end">
-                                    <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.interface&return_url={{ object.get_absolute_url }}">{% trans "Interface" %}</a>
-                                    </li>
-                                    <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleserverport&return_url={{ object.get_absolute_url }}">{% trans "Console Server Port" %}</a>
-                                    </li>
-                                    <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.consoleport&return_url={{ object.get_absolute_url }}">{% trans "Console Port" %}</a>
-                                    </li>
-                                    <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.frontport&return_url={{ object.get_absolute_url }}">{% trans "Front Port" %}</a>
-                                    </li>
-                                    <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=dcim.rearport&return_url={{ object.get_absolute_url }}">{% trans "Rear Port" %}</a>
-                                    </li>
-                                    <li>
-                                        <a class="dropdown-item" href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.frontport&a_terminations={{ object.pk }}&b_terminations_type=circuits.circuittermination&return_url={{ object.get_absolute_url }}">{% trans "Circuit Termination" %}</a>
-                                    </li>
-                                </ul>
-                            </div>
-                        {% endif %}
-                    </div>
-                {% endif %}
-            </div>
-            <div class="card">
-              <h2 class="card-header">{% trans "Port Mappings" %}</h2>
-              <table class="table table-hover">
-                {% if rear_port_mappings %}
-                  <thead>
-                    <tr>
-                      <th>{% trans "Position" %}</th>
-                      <th>{% trans "Rear Port" %}</th>
-                    </tr>
-                  </thead>
-                {% endif %}
-                {% for mapping in rear_port_mappings %}
-                  <tr>
-                    <td>{{ mapping.front_port_position }}</td>
-                    <td>
-                      <a href="{{ mapping.rear_port.get_absolute_url }}">{{ mapping.rear_port }}:{{ mapping.rear_port_position }}</a>
-                    </td>
-                  </tr>
-                {% empty %}
-                  {% trans "No mappings defined" %}
-                {% endfor %}
-              </table>
-            </div>
-            {% plugin_right_page object %}
-        </div>
-    </div>
-    <div class="row">
-        <div class="col col-md-12">
-            {% plugin_full_width_page object %}
-        </div>
-    </div>
-{% endblock %}

Daži faili netika attēloti, jo izmaiņu fails ir pārāk liels