فهرست منبع

Merge branch 'main' into feature

Jeremy Stretch 1 روز پیش
والد
کامیت
6030fc383a
100فایلهای تغییر یافته به همراه3100 افزوده شده و 3485 حذف شده
  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. 31 1
      .github/workflows/claude.yml
  5. 4 4
      .github/workflows/lock-threads.yml
  6. 4 2
      CONTRIBUTING.md
  7. 2 1
      base_requirements.txt
  8. 5 5
      contrib/openapi.json
  9. 1 0
      docs/customization/custom-scripts.md
  10. 15 11
      docs/integrations/webhooks.md
  11. 10 7
      docs/models/core/datasource.md
  12. 12 14
      docs/models/extras/webhook.md
  13. 40 0
      docs/release-notes/version-4.5.md
  14. 35 0
      netbox/circuits/models/circuits.py
  15. 0 11
      netbox/circuits/signals.py
  16. 148 0
      netbox/circuits/tests/test_models.py
  17. 1 1
      netbox/core/forms/model_forms.py
  18. 8 7
      netbox/core/models/data.py
  19. 20 0
      netbox/core/tests/test_models.py
  20. 1 0
      netbox/dcim/api/views.py
  21. 2 5
      netbox/dcim/filtersets.py
  22. 5 2
      netbox/dcim/forms/bulk_import.py
  23. 40 1
      netbox/dcim/forms/mixins.py
  24. 87 90
      netbox/dcim/graphql/filters.py
  25. 38 7
      netbox/dcim/models/cables.py
  26. 2 1
      netbox/dcim/tables/devices.py
  27. 2 2
      netbox/dcim/tables/racks.py
  28. 426 0
      netbox/dcim/tests/test_cablepaths2.py
  29. 29 0
      netbox/dcim/tests/test_models.py
  30. 2 18
      netbox/dcim/views.py
  31. 3 1
      netbox/extras/management/commands/runscript.py
  32. 67 0
      netbox/extras/managers.py
  33. 33 14
      netbox/ipam/forms/bulk_import.py
  34. 1 44
      netbox/ipam/tables/vlans.py
  35. 56 1
      netbox/ipam/tests/test_forms.py
  36. 55 13
      netbox/ipam/tests/test_views.py
  37. 3 0
      netbox/netbox/api/serializers/features.py
  38. 12 0
      netbox/netbox/graphql/pagination.py
  39. 18 9
      netbox/netbox/middleware.py
  40. 3 1
      netbox/netbox/models/features.py
  41. 47 0
      netbox/netbox/tests/test_graphql.py
  42. 21 0
      netbox/netbox/ui/attrs.py
  43. 0 0
      netbox/project-static/dist/netbox.css
  44. 0 0
      netbox/project-static/dist/netbox.js
  45. 0 0
      netbox/project-static/dist/netbox.js.map
  46. 7 7
      netbox/project-static/package.json
  47. 1 6
      netbox/project-static/src/colorMode.ts
  48. 18 6
      netbox/project-static/src/select/classes/dynamicTomSelect.ts
  49. 39 0
      netbox/project-static/src/select/classes/netboxTomSelect.ts
  50. 3 3
      netbox/project-static/src/select/static.ts
  51. 1 1
      netbox/project-static/styles/custom/_misc.scss
  52. 5 10
      netbox/project-static/styles/overrides/_tabler.scss
  53. 2 2
      netbox/project-static/styles/transitional/_navigation.scss
  54. 1 1
      netbox/project-static/styles/transitional/_tables.scss
  55. 251 262
      netbox/project-static/yarn.lock
  56. 2 2
      netbox/release.yaml
  57. 1 1
      netbox/templates/core/rq_queue_list.html
  58. 9 1
      netbox/templates/dcim/interface.html
  59. 3 1
      netbox/templates/extras/script_list.html
  60. 1 0
      netbox/templates/ui/attrs/datetime.html
  61. 1 0
      netbox/templates/users/attrs/full_name.html
  62. 0 57
      netbox/templates/users/group.html
  63. 0 88
      netbox/templates/users/objectpermission.html
  64. 0 47
      netbox/templates/users/owner.html
  65. 0 43
      netbox/templates/users/ownergroup.html
  66. 11 0
      netbox/templates/users/panels/object_types.html
  67. 0 82
      netbox/templates/users/user.html
  68. 8 6
      netbox/templates/virtualization/virtualmachine/attrs/ipaddress.html
  69. 15 15
      netbox/templates/virtualization/virtualmachine/base.html
  70. 3 0
      netbox/templates/vpn/attrs/preshared_key.html
  71. 0 62
      netbox/templates/vpn/ikepolicy.html
  72. 0 61
      netbox/templates/vpn/ikeproposal.html
  73. 0 50
      netbox/templates/vpn/ipsecpolicy.html
  74. 0 101
      netbox/templates/vpn/ipsecprofile.html
  75. 0 57
      netbox/templates/vpn/ipsecproposal.html
  76. 0 77
      netbox/templates/vpn/l2vpn.html
  77. 0 27
      netbox/templates/vpn/l2vpntermination.html
  78. 34 0
      netbox/templates/vpn/panels/ipsecprofile_ike_policy.html
  79. 30 0
      netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html
  80. 0 76
      netbox/templates/vpn/tunnel.html
  81. 0 40
      netbox/templates/vpn/tunnelgroup.html
  82. 0 56
      netbox/templates/vpn/tunneltermination.html
  83. 3 0
      netbox/templates/wireless/attrs/auth_psk.html
  84. 0 25
      netbox/templates/wireless/inc/authentication_attrs.html
  85. 0 51
      netbox/templates/wireless/inc/wirelesslink_interface.html
  86. 48 0
      netbox/templates/wireless/panels/wirelesslink_interface.html
  87. 0 73
      netbox/templates/wireless/wirelesslan.html
  88. 0 53
      netbox/templates/wireless/wirelesslangroup.html
  89. 0 67
      netbox/templates/wireless/wirelesslink.html
  90. BIN
      netbox/translations/cs/LC_MESSAGES/django.mo
  91. 219 284
      netbox/translations/cs/LC_MESSAGES/django.po
  92. BIN
      netbox/translations/da/LC_MESSAGES/django.mo
  93. 219 284
      netbox/translations/da/LC_MESSAGES/django.po
  94. BIN
      netbox/translations/de/LC_MESSAGES/django.mo
  95. 218 283
      netbox/translations/de/LC_MESSAGES/django.po
  96. 217 273
      netbox/translations/en/LC_MESSAGES/django.po
  97. BIN
      netbox/translations/es/LC_MESSAGES/django.mo
  98. 219 284
      netbox/translations/es/LC_MESSAGES/django.po
  99. BIN
      netbox/translations/fr/LC_MESSAGES/django.mo
  100. 219 284
      netbox/translations/fr/LC_MESSAGES/django.po

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

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

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

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

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

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

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

@@ -30,9 +30,39 @@ jobs:
         with:
           fetch-depth: 1
 
+      # Workaround for claude-code-action bug with fork PRs: The action fetches by branch name
+      # (git fetch origin --depth=N <branch>), but fork PR branches don't exist on origin.
+      # Fix: redirect origin to the fork's URL so the action can fetch the branch directly.
+      - name: Configure git remote for fork PRs
+        env:
+          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+        run: |
+          # Determine PR number based on event type
+          if [ "${{ github.event_name }}" = "issue_comment" ]; then
+            PR_NUMBER="${{ github.event.issue.number }}"
+          elif [ "${{ github.event_name }}" = "pull_request_review_comment" ] || [ "${{ github.event_name }}" = "pull_request_review" ]; then
+            PR_NUMBER="${{ github.event.pull_request.number }}"
+          else
+            exit 0  # issues event — no PR branch to worry about
+          fi
+
+          # Fetch fork info in one API call; silently skip if this is not a PR
+          PR_INFO=$(gh pr view "${PR_NUMBER}" --json isCrossRepository,headRepositoryOwner,headRepository 2>/dev/null || echo "")
+          if [ -z "$PR_INFO" ]; then
+            exit 0
+          fi
+
+          IS_FORK=$(echo "$PR_INFO" | jq -r '.isCrossRepository')
+          if [ "$IS_FORK" = "true" ]; then
+            FORK_OWNER=$(echo "$PR_INFO" | jq -r '.headRepositoryOwner.login')
+            FORK_REPO=$(echo "$PR_INFO" | jq -r '.headRepository.name')
+            echo "Fork PR detected from ${FORK_OWNER}/${FORK_REPO}: updating origin to fork URL"
+            git remote set-url origin "https://github.com/${FORK_OWNER}/${FORK_REPO}.git"
+          fi
+
       - name: Run Claude Code
         id: claude
-        uses: anthropics/claude-code-action@v1
+        uses: anthropics/claude-code-action@e763fe78de2db7389e04818a00b5ff8ba13d1360 # v1
         with:
           claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
 

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

@@ -11,14 +11,14 @@ permissions:
   pull-requests: write
   discussions: write
 
+concurrency:
+  group: lock-threads
+
 jobs:
   lock:
     if: github.repository == 'netbox-community/netbox'
     runs-on: ubuntu-latest
     steps:
-      - uses: dessant/lock-threads@1bf7ec25051fe7c00bdd17e6a7cf3d7bfb7dc771 # v5.0.1
+      - uses: dessant/lock-threads@v6.0.0
         with:
-          issue-inactive-days: 90
-          pr-inactive-days: 30
           discussion-inactive-days: 180
-          issue-lock-reason: 'resolved'

+ 4 - 2
CONTRIBUTING.md

@@ -84,6 +84,8 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
 
 * It's very important that you not submit a pull request until a relevant issue has been opened **and** assigned to you. Otherwise, you risk wasting time on work that may ultimately not be needed.
 
+* Community members are limited to a maximum of **three open PRs** at any time. This is to avoid the accumulation of too much parallel work and maintain focus on already PRs under review. If you already have three NetBox PRs open, please wait for at least one of them to be merged (or closed) before opening another.
+
 * New pull requests should generally be based off of the `main` branch. This branch, in keeping with the [trunk-based development](https://trunkbaseddevelopment.com/) approach, is used for ongoing development and bug fixes and always represents the newest stable code, from which releases are periodically branched. (If you're developing for an upcoming minor release, use `feature` instead.)
 
 * In most cases, it is not necessary to add a changelog entry: A maintainer will take care of this when the PR is merged. (This helps avoid merge conflicts resulting from multiple PRs being submitted simultaneously.)
@@ -96,10 +98,10 @@ intake policy](https://github.com/netbox-community/netbox/wiki/Issue-Intake-Poli
       greater than 80 characters in length
 
 > [!CAUTION]
-> Any contributions which include AI-generated or reproduced content will be rejected.
+> Any contributions which include solely AI-generated or reproduced content will be rejected. All PRs must be submitted by a human.
 
 * Some other tips to keep in mind:
-  * If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (This will allow the maintainers to assign it to you.)
+  * If you'd like to volunteer for someone else's issue, please post a comment on that issue letting us know. (GitHub allows only people who have commented on an issue to be assigned as its owner.)
   * Check out our [developer docs](https://docs.netbox.dev/en/stable/development/getting-started/) for tips on setting up your development environment.
   * All new functionality must include relevant tests where applicable.
 

+ 2 - 1
base_requirements.txt

@@ -49,7 +49,8 @@ django-rich
 
 # Django integration for RQ (Reqis queuing)
 # https://github.com/rq/django-rq/blob/master/CHANGELOG.md
-django-rq
+# See https://github.com/netbox-community/netbox/issues/21696
+django-rq<4.0
 
 # Provides a variety of storage backends
 # https://github.com/jschneier/django-storages/blob/master/CHANGELOG.rst

+ 5 - 5
contrib/openapi.json

@@ -2,7 +2,7 @@
     "openapi": "3.0.3",
     "info": {
         "title": "NetBox REST API",
-        "version": "4.5.4",
+        "version": "4.5.5",
         "license": {
             "name": "Apache v2 License"
         }
@@ -233620,7 +233620,7 @@
                     },
                     "ignore_rules": {
                         "type": "string",
-                        "description": "Patterns (one per line) matching files to ignore when syncing"
+                        "description": "Patterns (one per line) matching files or paths to ignore when syncing"
                     },
                     "owner": {
                         "allOf": [
@@ -233730,7 +233730,7 @@
                     },
                     "ignore_rules": {
                         "type": "string",
-                        "description": "Patterns (one per line) matching files to ignore when syncing"
+                        "description": "Patterns (one per line) matching files or paths to ignore when syncing"
                     },
                     "owner": {
                         "oneOf": [
@@ -255935,7 +255935,7 @@
                     },
                     "ignore_rules": {
                         "type": "string",
-                        "description": "Patterns (one per line) matching files to ignore when syncing"
+                        "description": "Patterns (one per line) matching files or paths to ignore when syncing"
                     },
                     "owner": {
                         "oneOf": [
@@ -278410,7 +278410,7 @@
                     },
                     "ignore_rules": {
                         "type": "string",
-                        "description": "Patterns (one per line) matching files to ignore when syncing"
+                        "description": "Patterns (one per line) matching files or paths to ignore when syncing"
                     },
                     "owner": {
                         "oneOf": [

+ 1 - 0
docs/customization/custom-scripts.md

@@ -215,6 +215,7 @@ if obj.pk and hasattr(obj, 'snapshot'):
     obj.snapshot()
 
 obj.property = "New Value"
+obj._changelog_message = 'Example Message Text' # Optional
 obj.full_clean()
 obj.save()
 ```

+ 15 - 11
docs/integrations/webhooks.md

@@ -23,9 +23,9 @@ For example, you might create a NetBox webhook to [trigger a Slack message](http
 
 The following data is available as context for Jinja2 templates:
 
-* `event` - The type of event which triggered the webhook: created, updated, or deleted.
-* `model` - The NetBox model which triggered the change.
+* `event` - The type of event which triggered the webhook: `created`, `updated`, or `deleted`.
 * `timestamp` - The time at which the event occurred (in [ISO 8601](https://en.wikipedia.org/wiki/ISO_8601) format).
+* `object_type` - The NetBox model which triggered the change in the form `app_label.model_name`.
 * `username` - The name of the user account associated with the change.
 * `request_id` - The unique request ID. This may be used to correlate multiple changes associated with a single request.
 * `data` - A detailed representation of the object in its current state. This is typically equivalent to the model's representation in NetBox's REST API.
@@ -43,18 +43,20 @@ If no body template is specified, the request body will be populated with a JSON
 ```json
 {
     "event": "created",
-    "timestamp": "2021-03-09 17:55:33.968016+00:00",
-    "model": "site",
+    "timestamp": "2026-03-06T15:11:23.503186+00:00",
+    "object_type": "dcim.site",
     "username": "jstretch",
-    "request_id": "fdbca812-3142-4783-b364-2e2bd5c16c6a",
+    "request_id": "17af32f0-852a-46ca-a7d4-33ecd0c13de6",
     "data": {
-        "id": 19,
+        "id": 4,
+        "url": "/api/dcim/sites/4/",
+        "display_url": "/dcim/sites/4/",
+        "display": "Site 1",
         "name": "Site 1",
         "slug": "site-1",
-        "status": 
+        "status": {
             "value": "active",
-            "label": "Active",
-            "id": 1
+            "label": "Active"
         },
         "region": null,
         ...
@@ -62,8 +64,10 @@ If no body template is specified, the request body will be populated with a JSON
     "snapshots": {
         "prechange": null,
         "postchange": {
-            "created": "2021-03-09",
-            "last_updated": "2021-03-09T17:55:33.851Z",
+            "created": "2026-03-06T15:11:23.484Z",
+            "owner": null,
+            "description": "",
+            "comments": "",
             "name": "Site 1",
             "slug": "site-1",
             "status": "active",

+ 10 - 7
docs/models/core/datasource.md

@@ -36,13 +36,16 @@ If false, synchronization will be disabled.
 
 ### Ignore Rules
 
-A set of rules (one per line) identifying filenames to ignore during synchronization. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
-
-| Rule           | Description                              |
-|----------------|------------------------------------------|
-| `README`       | Ignore any files named `README`          |
-| `*.txt`        | Ignore any files with a `.txt` extension |
-| `data???.json` | Ignore e.g. `data123.json`               |
+A set of rules (one per line) identifying files or paths to ignore during synchronization. Rules are matched against both the full relative path (e.g. `subdir/file.txt`) and the bare filename, so path-based patterns can be used to exclude entire directories. Some examples are provided below. See Python's [`fnmatch()` documentation](https://docs.python.org/3/library/fnmatch.html) for a complete reference.
+
+| Rule                  | Description                                          |
+|-----------------------|------------------------------------------------------|
+| `README`              | Ignore any files named `README`                      |
+| `*.txt`               | Ignore any files with a `.txt` extension             |
+| `data???.json`        | Ignore e.g. `data123.json`                           |
+| `subdir/*`            | Ignore all files within `subdir/`                    |
+| `subdir/*/*`          | Ignore all files one level deep within `subdir/`     |
+| `*/dev/*`             | Ignore files inside any directory named `dev/`       |
 
 ### Sync Interval
 

+ 12 - 14
docs/models/extras/webhook.md

@@ -77,19 +77,17 @@ The file path to a particular certificate authority (CA) file to use when valida
 
 ## Context Data
 
-The following context variables are available in to the text and link templates.
-
-| Variable     | Description                                        |
-|--------------|----------------------------------------------------|
-| `event`      | The event type (`create`, `update`, or `delete`)   |
-| `timestamp`  | The time at which the event occured                |
-| `model`      | The type of object impacted                        |
-| `username`   | The name of the user associated with the change    |
-| `request_id` | The unique request ID                              |
-| `data`       | A complete serialized representation of the object |
-| `snapshots`  | Pre- and post-change snapshots of the object       |
+The following context variables are available to the text and link templates.
+
+| Variable      | Description                                          |
+|---------------|------------------------------------------------------|
+| `event`       | The event type (`create`, `update`, or `delete`)     |
+| `timestamp`   | The time at which the event occurred                 |
+| `object_type` | The type of object impacted (`app_label.model_name`) |
+| `username`    | The name of the user associated with the change      |
+| `request_id`  | The unique request ID                                |
+| `data`        | A complete serialized representation of the object   |
+| `snapshots`   | Pre- and post-change snapshots of the object         |
 
 !!! warning "Deprecation of legacy fields"
-    The "request_id" and "username" fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0.
-
-    Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.
+    The `request_id` and `username` fields in the webhook payload above are deprecated and should no longer be used. Support for them will be removed in NetBox v4.7.0. Use `request.user.username` and `request.request_id` from the `request` object included in the callback context instead.

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

@@ -1,5 +1,45 @@
 # NetBox v4.5
 
+## v4.5.5 (2026-03-17)
+
+### Enhancements
+
+* [#21114](https://github.com/netbox-community/netbox/issues/21114) - Support path exclusions for data source synchronization
+* [#21578](https://github.com/netbox-community/netbox/issues/21578) - Support identifying scope object by name or slug when bulk importing scoped objects
+
+### Performance Improvements
+
+* [#21330](https://github.com/netbox-community/netbox/issues/21330) - Optimize the assignment of tags when saving objects
+* [#21402](https://github.com/netbox-community/netbox/issues/21402) - Avoid excessive database queries when rendering unnamed devices via the REST API
+* [#21611](https://github.com/netbox-community/netbox/issues/21611) - Replace inefficient calls to `.count()` with `.exists()`
+
+### Bug Fixes
+
+* [#19867](https://github.com/netbox-community/netbox/issues/19867) - Preserve the "per page" pagination setting when returning from object edit forms
+* [#20077](https://github.com/netbox-community/netbox/issues/20077) - Fix form field focus bug in Microsoft Edge
+* [#20385](https://github.com/netbox-community/netbox/issues/20385) - Enforce `MAX_PAGE_SIZE` limit for GraphQL API requests
+* [#20468](https://github.com/netbox-community/netbox/issues/20468) - Fix range-based filter lookups for integer fields in GraphQL API
+* [#20915](https://github.com/netbox-community/netbox/issues/20915) - Restore user language preference after login via social authentication
+* [#20934](https://github.com/netbox-community/netbox/issues/20934) - Fix dark mode flicker on page load
+* [#21012](https://github.com/netbox-community/netbox/issues/21012) - Add pagination for VLAN table on interface view to prevent silent truncation at 100 entries
+* [#21380](https://github.com/netbox-community/netbox/issues/21380) - Fix display of the background tasks table on mobile
+* [#21440](https://github.com/netbox-community/netbox/issues/21440) - Avoid erroneously clearing primary/OOB IP assignments during bulk import/update
+* [#21468](https://github.com/netbox-community/netbox/issues/21468) - Preserve safe custom HTTP headers when copying requests for background job processing
+* [#21486](https://github.com/netbox-community/netbox/issues/21486) - Fix `AttributeError` exception caused by missing `COOKIES` attribute on `NetBoxFakeRequest`
+* [#21512](https://github.com/netbox-community/netbox/issues/21512) - Fix GraphQL filter field name mismatch for device component types (e.g. `console_ports`)
+* [#21531](https://github.com/netbox-community/netbox/issues/21531) - Fix search functionality for location when combined with other filters
+* [#21556](https://github.com/netbox-community/netbox/issues/21556) - Avoid clearing the platform field when changing device type in the device edit form
+* [#21579](https://github.com/netbox-community/netbox/issues/21579) - Hide the script "Add" button for users lacking the required permission
+* [#21580](https://github.com/netbox-community/netbox/issues/21580) - Hide the virtual machine "Add components" dropdown for users lacking change permission
+* [#21586](https://github.com/netbox-community/netbox/issues/21586) - Fix broken "Add child group" link in site group view (was pointing to the region endpoint)
+* [#21618](https://github.com/netbox-community/netbox/issues/21618) - Fix cable termination points being lost when bulk-editing the cable profile
+* [#21651](https://github.com/netbox-community/netbox/issues/21651) - Disable sorting by the `is_primary` column in the MAC address list view
+* [#21653](https://github.com/netbox-community/netbox/issues/21653) - Fix profile-based cable tracing when a single origin carries multiple positions
+* [#21673](https://github.com/netbox-community/netbox/issues/21673) - Fix display of primary IP address with associated NAT IP on virtual machine view
+* [#21686](https://github.com/netbox-community/netbox/issues/21686) - Clean up cached circuit attributes when reassigning a circuit termination
+
+---
+
 ## v4.5.4 (2026-03-03)
 
 ### Enhancements

+ 35 - 0
netbox/circuits/models/circuits.py

@@ -347,6 +347,13 @@ class CircuitTermination(
         verbose_name = _('circuit termination')
         verbose_name_plural = _('circuit terminations')
 
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        # Cache original values to detect changes
+        self._orig_circuit_id = self.__dict__.get('circuit_id')
+        self._orig_term_side = self.__dict__.get('term_side')
+
     def __str__(self):
         return f'{self.circuit}: Termination {self.term_side}'
 
@@ -360,11 +367,39 @@ class CircuitTermination(
             raise ValidationError(_("A circuit termination must attach to a terminating object."))
 
     def save(self, *args, **kwargs):
+        is_new = self._state.adding
+        update_fields = kwargs.get('update_fields')
+
+        # Only consider circuit/term_side changes if those fields
+        # are actually being persisted
+        if update_fields is not None:
+            tracking_relevant = 'circuit' in update_fields or 'term_side' in update_fields
+        else:
+            tracking_relevant = True
+
+        circuit_changed = tracking_relevant and self._orig_circuit_id and self._orig_circuit_id != self.circuit_id
+        term_side_changed = tracking_relevant and self._orig_term_side and self._orig_term_side != self.term_side
+
         # Cache objects associated with the terminating object (for filtering)
         self.cache_related_objects()
 
         super().save(*args, **kwargs)
 
+        # Clear the old termination reference if circuit or term_side changed
+        if circuit_changed or term_side_changed:
+            old_termination_name = f'termination_{self._orig_term_side.lower()}'
+            Circuit.objects.filter(pk=self._orig_circuit_id).update(**{old_termination_name: None})
+
+        # Update the cache if this is a new termination or circuit/term_side changed
+        if is_new or circuit_changed or term_side_changed:
+            # Update the new circuit's termination reference
+            termination_name = f'termination_{self.term_side.lower()}'
+            Circuit.objects.filter(pk=self.circuit_id).update(**{termination_name: self.pk})
+
+            # Update cached values for subsequent saves
+            self._orig_circuit_id = self.circuit_id
+            self._orig_term_side = self.term_side
+
     def cache_related_objects(self):
         self._provider_network = self._region = self._site_group = self._site = self._location = None
         if self.termination_type:

+ 0 - 11
netbox/circuits/signals.py

@@ -6,17 +6,6 @@ from dcim.signals import rebuild_paths
 from .models import CircuitTermination
 
 
-@receiver(post_save, sender=CircuitTermination)
-def update_circuit(instance, **kwargs):
-    """
-    When a CircuitTermination has been modified, update its parent Circuit.
-    """
-    termination_name = f'termination_{instance.term_side.lower()}'
-    instance.circuit.refresh_from_db()
-    setattr(instance.circuit, termination_name, instance)
-    instance.circuit.save()
-
-
 @receiver((post_save, post_delete), sender=CircuitTermination)
 def rebuild_cablepaths(instance, raw=False, **kwargs):
     """

+ 148 - 0
netbox/circuits/tests/test_models.py

@@ -0,0 +1,148 @@
+from django.test import TestCase
+
+from circuits.models import Circuit, CircuitTermination, CircuitType, Provider, ProviderNetwork
+from dcim.models import Site
+
+
+class CircuitTerminationTestCase(TestCase):
+
+    @classmethod
+    def setUpTestData(cls):
+        provider = Provider.objects.create(name='Provider 1', slug='provider-1')
+        circuit_type = CircuitType.objects.create(name='Circuit Type 1', slug='circuit-type-1')
+
+        cls.sites = (
+            Site.objects.create(name='Site 1', slug='site-1'),
+            Site.objects.create(name='Site 2', slug='site-2'),
+        )
+
+        cls.circuits = (
+            Circuit.objects.create(cid='Circuit 1', provider=provider, type=circuit_type),
+            Circuit.objects.create(cid='Circuit 2', provider=provider, type=circuit_type),
+        )
+
+        cls.provider_network = ProviderNetwork.objects.create(name='Provider Network 1', provider=provider)
+
+    def test_circuit_termination_creation_populates_circuit_cache(self):
+        """
+        When a CircuitTermination is created, the parent Circuit's termination_a or termination_z
+        cache field should be populated.
+        """
+        # Create A termination
+        termination_a = CircuitTermination.objects.create(
+            circuit=self.circuits[0],
+            term_side='A',
+            termination=self.sites[0],
+        )
+        self.circuits[0].refresh_from_db()
+        self.assertEqual(self.circuits[0].termination_a, termination_a)
+        self.assertIsNone(self.circuits[0].termination_z)
+
+        # Create Z termination
+        termination_z = CircuitTermination.objects.create(
+            circuit=self.circuits[0],
+            term_side='Z',
+            termination=self.sites[1],
+        )
+        self.circuits[0].refresh_from_db()
+        self.assertEqual(self.circuits[0].termination_a, termination_a)
+        self.assertEqual(self.circuits[0].termination_z, termination_z)
+
+    def test_circuit_termination_circuit_change_clears_old_cache(self):
+        """
+        When a CircuitTermination's circuit is changed, the old Circuit's cache should be cleared
+        and the new Circuit's cache should be populated.
+        """
+        # Create termination on self.circuits[0]
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuits[0],
+            term_side='A',
+            termination=self.sites[0],
+        )
+        self.circuits[0].refresh_from_db()
+        self.assertEqual(self.circuits[0].termination_a, termination)
+
+        # Move termination to self.circuits[1]
+        termination.circuit = self.circuits[1]
+        termination.save()
+
+        self.circuits[0].refresh_from_db()
+        self.circuits[1].refresh_from_db()
+
+        # Old circuit's cache should be cleared
+        self.assertIsNone(self.circuits[0].termination_a)
+        # New circuit's cache should be populated
+        self.assertEqual(self.circuits[1].termination_a, termination)
+
+    def test_circuit_termination_term_side_change_clears_old_cache(self):
+        """
+        When a CircuitTermination's term_side is changed, the old side's cache should be cleared
+        and the new side's cache should be populated.
+        """
+        # Create A termination
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuits[0],
+            term_side='A',
+            termination=self.sites[0],
+        )
+        self.circuits[0].refresh_from_db()
+        self.assertEqual(self.circuits[0].termination_a, termination)
+        self.assertIsNone(self.circuits[0].termination_z)
+
+        # Change from A to Z
+        termination.term_side = 'Z'
+        termination.save()
+
+        self.circuits[0].refresh_from_db()
+
+        # A side should be cleared, Z side should be populated
+        self.assertIsNone(self.circuits[0].termination_a)
+        self.assertEqual(self.circuits[0].termination_z, termination)
+
+    def test_circuit_termination_circuit_and_term_side_change(self):
+        """
+        When both circuit and term_side are changed, the old Circuit's old side cache should be
+        cleared and the new Circuit's new side cache should be populated.
+        """
+        # Create A termination on self.circuits[0]
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuits[0],
+            term_side='A',
+            termination=self.sites[0],
+        )
+        self.circuits[0].refresh_from_db()
+        self.assertEqual(self.circuits[0].termination_a, termination)
+
+        # Change to self.circuits[1] Z side
+        termination.circuit = self.circuits[1]
+        termination.term_side = 'Z'
+        termination.save()
+
+        self.circuits[0].refresh_from_db()
+        self.circuits[1].refresh_from_db()
+
+        # Old circuit's A side should be cleared
+        self.assertIsNone(self.circuits[0].termination_a)
+        self.assertIsNone(self.circuits[0].termination_z)
+        # New circuit's Z side should be populated
+        self.assertIsNone(self.circuits[1].termination_a)
+        self.assertEqual(self.circuits[1].termination_z, termination)
+
+    def test_circuit_termination_deletion_clears_cache(self):
+        """
+        When a CircuitTermination is deleted, the parent Circuit's cache should be cleared.
+        """
+        termination = CircuitTermination.objects.create(
+            circuit=self.circuits[0],
+            term_side='A',
+            termination=self.sites[0],
+        )
+        self.circuits[0].refresh_from_db()
+        self.assertEqual(self.circuits[0].termination_a, termination)
+
+        # Delete the termination
+        termination.delete()
+        self.circuits[0].refresh_from_db()
+
+        # Cache should be cleared (SET_NULL behavior)
+        self.assertIsNone(self.circuits[0].termination_a)

+ 1 - 1
netbox/core/forms/model_forms.py

@@ -43,7 +43,7 @@ class DataSourceForm(PrimaryModelForm):
                 attrs={
                     'rows': 5,
                     'class': 'font-monospace',
-                    'placeholder': '.cache\n*.txt'
+                    'placeholder': '.cache\n*.txt\nsubdir/*'
                 }
             ),
         }

+ 8 - 7
netbox/core/models/data.py

@@ -69,7 +69,7 @@ class DataSource(JobsMixin, PrimaryModel):
     ignore_rules = models.TextField(
         verbose_name=_('ignore rules'),
         blank=True,
-        help_text=_("Patterns (one per line) matching files to ignore when syncing")
+        help_text=_("Patterns (one per line) matching files or paths to ignore when syncing")
     )
     parameters = models.JSONField(
         verbose_name=_('parameters'),
@@ -258,21 +258,22 @@ class DataSource(JobsMixin, PrimaryModel):
             if path.startswith('.'):
                 continue
             for file_name in file_names:
-                if not self._ignore(file_name):
-                    paths.add(os.path.join(path, file_name))
+                file_path = os.path.join(path, file_name)
+                if not self._ignore(file_path):
+                    paths.add(file_path)
 
         logger.debug(f"Found {len(paths)} files")
         return paths
 
-    def _ignore(self, filename):
+    def _ignore(self, file_path):
         """
         Returns a boolean indicating whether the file should be ignored per the DataSource's configured
-        ignore rules.
+        ignore rules. file_path is the full relative path (e.g. "subdir/file.txt").
         """
-        if filename.startswith('.'):
+        if os.path.basename(file_path).startswith('.'):
             return True
         for rule in self.ignore_rules.splitlines():
-            if fnmatchcase(filename, rule):
+            if fnmatchcase(file_path, rule) or fnmatchcase(os.path.basename(file_path), rule):
                 return True
         return False
 

+ 20 - 0
netbox/core/tests/test_models.py

@@ -10,6 +10,26 @@ from dcim.models import Device, Location, Site
 from netbox.constants import CENSOR_TOKEN, CENSOR_TOKEN_CHANGED
 
 
+class DataSourceIgnoreRulesTestCase(TestCase):
+
+    def test_no_ignore_rules(self):
+        ds = DataSource(ignore_rules='')
+        self.assertFalse(ds._ignore('README.md'))
+        self.assertFalse(ds._ignore('subdir/file.py'))
+
+    def test_ignore_by_filename(self):
+        ds = DataSource(ignore_rules='*.txt')
+        self.assertTrue(ds._ignore('notes.txt'))
+        self.assertTrue(ds._ignore('subdir/notes.txt'))
+        self.assertFalse(ds._ignore('notes.py'))
+
+    def test_ignore_by_subdirectory(self):
+        ds = DataSource(ignore_rules='dev/*')
+        self.assertTrue(ds._ignore('dev/README.md'))
+        self.assertTrue(ds._ignore('dev/script.py'))
+        self.assertFalse(ds._ignore('prod/script.py'))
+
+
 class DataSourceChangeLoggingTestCase(TestCase):
 
     def test_password_added_on_create(self):

+ 1 - 0
netbox/dcim/api/views.py

@@ -417,6 +417,7 @@ class DeviceViewSet(
     NetBoxModelViewSet
 ):
     queryset = Device.objects.prefetch_related(
+        'device_type__manufacturer',  # Referenced by Device.__str__() for unnamed devices
         'parent_bay',  # Referenced by DeviceSerializer.get_parent_device()
     )
     filterset_class = filtersets.DeviceFilterSet

+ 2 - 5
netbox/dcim/filtersets.py

@@ -308,12 +308,9 @@ class LocationFilterSet(TenancyFilterSet, ContactModelFilterSet, NestedGroupMode
         fields = ('id', 'name', 'slug', 'facility', 'description')
 
     def search(self, queryset, name, value):
-        # extended in order to include querying on Location.facility
-        queryset = super().search(queryset, name, value)
-
+        # Extend `search()` to include querying on Location.facility
         if value.strip():
-            queryset = queryset | queryset.model.objects.filter(facility__icontains=value)
-
+            return super().search(queryset, name, value) | queryset.filter(facility__icontains=value)
         return queryset
 
 

+ 5 - 2
netbox/dcim/forms/bulk_import.py

@@ -1558,8 +1558,11 @@ class CableImportForm(PrimaryModelImportForm):
 
         model = content_type.model_class()
         try:
-            if device.virtual_chassis and device.virtual_chassis.master == device and \
-                    model.objects.filter(device=device, name=name).count() == 0:
+            if (
+                device.virtual_chassis and
+                device.virtual_chassis.master == device and
+                not model.objects.filter(device=device, name=name).exists()
+            ):
                 termination_object = model.objects.get(device__in=device.virtual_chassis.members.all(), name=name)
             else:
                 termination_object = model.objects.get(device=device, name=name)

+ 40 - 1
netbox/dcim/forms/mixins.py

@@ -121,13 +121,52 @@ class ScopedImportForm(forms.Form):
         required=False,
         label=_('Scope type (app & model)')
     )
+    scope_name = forms.CharField(
+        required=False,
+        label=_('Scope name'),
+        help_text=_('Name of the assigned scope object (if not using ID)')
+    )
 
     def clean(self):
         super().clean()
 
         scope_id = self.cleaned_data.get('scope_id')
+        scope_name = self.cleaned_data.get('scope_name')
         scope_type = self.cleaned_data.get('scope_type')
-        if scope_type and not scope_id:
+
+        # Cannot specify both scope_name and scope_id
+        if scope_name and scope_id:
+            raise ValidationError(_("scope_name and scope_id are mutually exclusive."))
+
+        # Must specify scope_type with scope_name or scope_id
+        if scope_name and not scope_type:
+            raise ValidationError(_("scope_type must be specified when using scope_name"))
+        if scope_id and not scope_type:
+            raise ValidationError(_("scope_type must be specified when using scope_id"))
+
+        # Look up the scope object by name
+        if scope_type and scope_name:
+            model = scope_type.model_class()
+            try:
+                scope_obj = model.objects.get(name=scope_name)
+            except model.DoesNotExist:
+                raise ValidationError({
+                    'scope_name': _('{scope_type} "{name}" not found.').format(
+                        scope_type=bettertitle(model._meta.verbose_name),
+                        name=scope_name
+                    )
+                })
+            except model.MultipleObjectsReturned:
+                raise ValidationError({
+                    'scope_name': _(
+                        'Multiple {scope_type} objects match "{name}". Use scope_id to specify the intended object.'
+                    ).format(
+                        scope_type=bettertitle(model._meta.verbose_name),
+                        name=scope_name,
+                    )
+                })
+            self.cleaned_data['scope_id'] = scope_obj.pk
+        elif scope_type and not scope_id:
             raise ValidationError({
                 'scope_id': _(
                     "Please select a {scope_type}."

+ 87 - 90
netbox/dcim/graphql/filters.py

@@ -274,32 +274,32 @@ class DeviceFilter(
     longitude: Annotated['FloatLookup', strawberry.lazy('netbox.graphql.filter_lookups')] | None = (
         strawberry_django.filter_field()
     )
-    console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='console_ports')
     )
-    console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='console_server_ports')
     )
-    power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_outlets')
     )
-    power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_ports')
     )
     interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='front_ports')
     )
-    rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='rear_ports')
     )
-    device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='device_bays')
     )
-    module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='module_bays')
     )
     modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -390,36 +390,36 @@ class DeviceTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     rear_image: Annotated['ImageAttachmentFilter', strawberry.lazy('extras.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    console_port_templates: (
-        Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    console_server_port_templates: (
+    consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='console_port_templates')
+    )
+    consoleserverporttemplates: (
         Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    power_port_templates: (
-        Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    power_outlet_templates: (
-        Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    interface_templates: (
-        Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    front_port_templates: (
-        Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    rear_port_templates: (
-        Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    device_bay_templates: (
-        Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    module_bay_templates: (
-        Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    inventory_item_templates: (
-        Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
+    ) = strawberry_django.filter_field(name='console_server_port_templates')
+    powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_port_templates')
+    )
+    poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_outlet_templates')
+    )
+    interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='interface_templates')
+    )
+    frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='front_port_templates')
+    )
+    rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='rear_port_templates')
+    )
+    devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='device_bay_templates')
+    )
+    modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='module_bay_templates')
+    )
+    inventoryitemtemplates: Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='inventory_item_templates')
+    )
     console_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
     console_server_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
     power_port_template_count: FilterLookup[int] | None = strawberry_django.filter_field()
@@ -703,32 +703,32 @@ class ModuleFilter(ConfigContextFilterMixin, PrimaryModelFilter):
     )
     serial: StrFilterLookup[str] | None = strawberry_django.filter_field()
     asset_tag: StrFilterLookup[str] | None = strawberry_django.filter_field()
-    console_ports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    consoleports: Annotated['ConsolePortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='console_ports')
     )
-    console_server_ports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    consoleserverports: Annotated['ConsoleServerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='console_server_ports')
     )
-    power_outlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    poweroutlets: Annotated['PowerOutletFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_outlets')
     )
-    power_ports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    powerports: Annotated['PowerPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_ports')
     )
     interfaces: Annotated['InterfaceFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
     )
-    front_ports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    frontports: Annotated['FrontPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='front_ports')
     )
-    rear_ports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    rearports: Annotated['RearPortFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='rear_ports')
     )
-    device_bays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    devicebays: Annotated['DeviceBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='device_bays')
     )
-    module_bays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
-        strawberry_django.filter_field()
+    modulebays: Annotated['ModuleBayFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='module_bays')
     )
     modules: Annotated['ModuleFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
         strawberry_django.filter_field()
@@ -772,36 +772,33 @@ class ModuleTypeFilter(ImageAttachmentFilterMixin, WeightFilterMixin, PrimaryMod
     airflow: BaseFilterLookup[Annotated['ModuleAirflowEnum', strawberry.lazy('dcim.graphql.enums')]] | None = (
         strawberry_django.filter_field()
     )
-    console_port_templates: (
-        Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    console_server_port_templates: (
+    consoleporttemplates: Annotated['ConsolePortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='console_port_templates')
+    )
+    consoleserverporttemplates: (
         Annotated['ConsoleServerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    power_port_templates: (
-        Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    power_outlet_templates: (
-        Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    interface_templates: (
-        Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    front_port_templates: (
-        Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    rear_port_templates: (
-        Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    device_bay_templates: (
-        Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    module_bay_templates: (
-        Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
-    inventory_item_templates: (
-        Annotated['InventoryItemTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None
-    ) = strawberry_django.filter_field()
+    ) = strawberry_django.filter_field(name='console_server_port_templates')
+    powerporttemplates: Annotated['PowerPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_port_templates')
+    )
+    poweroutlettemplates: Annotated['PowerOutletTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='power_outlet_templates')
+    )
+    interfacetemplates: Annotated['InterfaceTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='interface_templates')
+    )
+    frontporttemplates: Annotated['FrontPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='front_port_templates')
+    )
+    rearporttemplates: Annotated['RearPortTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='rear_port_templates')
+    )
+    devicebaytemplates: Annotated['DeviceBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='device_bay_templates')
+    )
+    modulebaytemplates: Annotated['ModuleBayTemplateFilter', strawberry.lazy('dcim.graphql.filters')] | None = (
+        strawberry_django.filter_field(name='module_bay_templates')
+    )
     module_count: ComparisonFilterLookup[int] | None = strawberry_django.filter_field()
 
 

+ 38 - 7
netbox/dcim/models/cables.py

@@ -329,7 +329,6 @@ class Cable(PrimaryModel):
             self._pk = self.pk
 
         if self._orig_profile != self.profile:
-            print(f'profile changed from {self._orig_profile} to {self.profile}')
             self.update_terminations(force=True)
         elif self._terminations_modified:
             self.update_terminations()
@@ -439,6 +438,15 @@ class Cable(PrimaryModel):
         """
         a_terminations, b_terminations = self.get_terminations()
 
+        # When force-recreating terminations (e.g. after a profile change), cache the termination objects
+        # from the database before deleting, so they are available for recreation. Without this, the
+        # a_terminations/b_terminations properties would query the DB after deletion and return empty lists.
+        if force:
+            if not hasattr(self, '_a_terminations'):
+                self._a_terminations = list(a_terminations.keys())
+            if not hasattr(self, '_b_terminations'):
+                self._b_terminations = list(b_terminations.keys())
+
         # Delete any stale CableTerminations
         for termination, ct in a_terminations.items():
             if force or (termination.pk and termination not in self.a_terminations):
@@ -848,9 +856,9 @@ class CablePath(models.Model):
             path.append([
                 object_to_path_node(t) for t in terminations
             ])
-            # If not null, push cable position onto the stack
+            # If not null, push cable positions onto the stack
             if isinstance(terminations[0], PathEndpoint) and terminations[0].cable_positions:
-                position_stack.append([terminations[0].cable_positions[0]])
+                position_stack.append(list(terminations[0].cable_positions))
 
             # Step 2: Determine the attached links (Cable or WirelessLink), if any
             links = list(dict.fromkeys(
@@ -891,10 +899,33 @@ class CablePath(models.Model):
                 # Profile-based tracing
                 if links[0].profile:
                     cable_profile = links[0].profile_class()
-                    position = position_stack.pop()[0] if position_stack else None
-                    term, position = cable_profile.get_peer_termination(terminations[0], position)
-                    remote_terminations = [term]
-                    position_stack.append([position])
+                    positions = position_stack.pop() if position_stack else [None]
+                    remote_terminations = []
+                    new_positions = []
+
+                    # Build (termination, position) pairs by matching stacked positions
+                    # to each termination's cable_positions. This correctly handles
+                    # multiple terminations on different connectors of the same cable.
+                    remaining = list(positions)
+                    term_position_pairs = []
+                    for term in terminations:
+                        if term.cable_positions:
+                            for cp in term.cable_positions:
+                                if cp in remaining:
+                                    term_position_pairs.append((term, cp))
+                                    remaining.remove(cp)
+
+                    # Fallback for when positions don't match cable_positions
+                    # (e.g., empty position stack yielding [None])
+                    if not term_position_pairs:
+                        term_position_pairs = [(terminations[0], pos) for pos in positions]
+
+                    for term, pos in term_position_pairs:
+                        peer, new_pos = cable_profile.get_peer_termination(term, pos)
+                        if peer not in remote_terminations:
+                            remote_terminations.append(peer)
+                        new_positions.append(new_pos)
+                    position_stack.append(new_positions)
 
                 # Legacy (positionless) behavior
                 else:

+ 2 - 1
netbox/dcim/tables/devices.py

@@ -1205,7 +1205,8 @@ class MACAddressTable(PrimaryModelTable):
         verbose_name=_('Parent')
     )
     is_primary = columns.BooleanColumn(
-        verbose_name=_('Primary')
+        verbose_name=_('Primary'),
+        orderable=False,
     )
     tags = columns.TagColumn(
         url_name='dcim:macaddress_list'

+ 2 - 2
netbox/dcim/tables/racks.py

@@ -251,7 +251,7 @@ class RackReservationTable(TenancyColumnsMixin, PrimaryModelTable):
     class Meta(PrimaryModelTable.Meta):
         model = RackReservation
         fields = (
-            'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'status', 'user', 'created',
-            'tenant', 'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
+            'pk', 'id', 'reservation', 'site', 'location', 'group', 'rack', 'unit_list', 'status', 'user', 'tenant',
+            'tenant_group', 'description', 'comments', 'tags', 'actions', 'created', 'last_updated',
         )
         default_columns = ('pk', 'reservation', 'site', 'rack', 'unit_list', 'status', 'user', 'description')

+ 426 - 0
netbox/dcim/tests/test_cablepaths2.py

@@ -797,6 +797,432 @@ class CablePathTests(CablePathTestCase):
         # Test SVG generation
         CableTraceSVG(interfaces[0]).render()
 
+    def test_107_duplex_interface_profiled_patch_through_trunk_with_splices(self):
+        """
+        Tests that a duplex interface (cable_positions=[1,2]) traces both positions through
+        profiled cables and splice pass-throughs, producing a single CablePath with both
+        strands visible.
+
+        [IF1] -C1(1C2P)- [FP1(p=2)][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
+        [FP2] -C3- [FP4][RP3(p=2)] -C4(1C2P)- [RP4(p=2)][FP6(p=2)]
+        -C5(1C2P)- [IF2]  /  [FP3] -C6- [FP5]
+
+        Cable profiles: C1=1C2P, C2=1C2P, C3/C6=unprofiled splices, C4=1C2P, C5=1C2P
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+        ]
+        rear_ports = [
+            RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
+        ]
+        front_ports = [
+            FrontPort.objects.create(device=self.device, name='Front Port 1', positions=2),  # Panel A duplex
+            FrontPort.objects.create(device=self.device, name='Front Port 2'),               # Splice A strand 1
+            FrontPort.objects.create(device=self.device, name='Front Port 3'),               # Splice A strand 2
+            FrontPort.objects.create(device=self.device, name='Front Port 4'),               # Splice B strand 1
+            FrontPort.objects.create(device=self.device, name='Front Port 5'),               # Splice B strand 2
+            FrontPort.objects.create(device=self.device, name='Front Port 6', positions=2),  # Panel B duplex
+        ]
+        PortMapping.objects.bulk_create([
+            # Panel A: duplex FP1(pos=2) -> RP1(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[0], front_port_position=1,
+                rear_port=rear_ports[0], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[0], front_port_position=2,
+                rear_port=rear_ports[0], rear_port_position=2,
+            ),
+            # Splice A: FP2, FP3 -> RP2(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[1], front_port_position=1,
+                rear_port=rear_ports[1], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[2], front_port_position=1,
+                rear_port=rear_ports[1], rear_port_position=2,
+            ),
+            # Splice B: FP4, FP5 -> RP3(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[3], front_port_position=1,
+                rear_port=rear_ports[2], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[4], front_port_position=1,
+                rear_port=rear_ports[2], rear_port_position=2,
+            ),
+            # Panel B: duplex FP6(pos=2) -> RP4(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[5], front_port_position=1,
+                rear_port=rear_ports[3], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[5], front_port_position=2,
+                rear_port=rear_ports[3], rear_port_position=2,
+            ),
+        ])
+
+        # Create cables
+        cable1 = Cable(
+            profile=CableProfileChoices.SINGLE_1C2P,
+            a_terminations=[interfaces[0]],
+            b_terminations=[front_ports[0]],
+        )
+        cable1.clean()
+        cable1.save()
+        cable2 = Cable(
+            profile=CableProfileChoices.SINGLE_1C2P,
+            a_terminations=[rear_ports[0]],
+            b_terminations=[rear_ports[1]],
+        )
+        cable2.clean()
+        cable2.save()
+        cable3 = Cable(
+            a_terminations=[front_ports[1]],
+            b_terminations=[front_ports[3]],
+        )
+        cable3.clean()
+        cable3.save()
+        cable4 = Cable(
+            profile=CableProfileChoices.SINGLE_1C2P,
+            a_terminations=[rear_ports[2]],
+            b_terminations=[rear_ports[3]],
+        )
+        cable4.clean()
+        cable4.save()
+        cable5 = Cable(
+            profile=CableProfileChoices.SINGLE_1C2P,
+            a_terminations=[front_ports[5]],
+            b_terminations=[interfaces[1]],
+        )
+        cable5.clean()
+        cable5.save()
+        cable6 = Cable(
+            a_terminations=[front_ports[2]],
+            b_terminations=[front_ports[4]],
+        )
+        cable6.clean()
+        cable6.save()
+
+        # Verify forward path: IF1 -> IF2 (both strands through splice)
+        self.assertPathExists(
+            (
+                interfaces[0], cable1, front_ports[0],
+                rear_ports[0], cable2, rear_ports[1],
+                [front_ports[1], front_ports[2]], [cable3, cable6], [front_ports[3], front_ports[4]],
+                rear_ports[2], cable4, rear_ports[3],
+                front_ports[5], cable5, interfaces[1],
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        # Verify reverse path: IF2 -> IF1
+        self.assertPathExists(
+            (
+                interfaces[1], cable5, front_ports[5],
+                rear_ports[3], cable4, rear_ports[2],
+                [front_ports[3], front_ports[4]], [cable3, cable6], [front_ports[1], front_ports[2]],
+                rear_ports[1], cable2, rear_ports[0],
+                front_ports[0], cable1, interfaces[0],
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        # Verify cable positions on interfaces
+        for iface in interfaces:
+            iface.refresh_from_db()
+        self.assertEqual(interfaces[0].cable_connector, 1)
+        self.assertEqual(interfaces[0].cable_positions, [1, 2])
+        self.assertEqual(interfaces[1].cable_connector, 1)
+        self.assertEqual(interfaces[1].cable_positions, [1, 2])
+
+        # Test SVG generation
+        CableTraceSVG(interfaces[0]).render()
+
+    def test_108_single_interface_two_frontports_unprofiled_through_trunk_with_splices(self):
+        """
+        Tests that positions seeded by PortMapping (not cable_positions) are preserved
+        when crossing profiled cables.
+
+        [IF1] -C1- [FP1,FP2][RP1(p=2)] -C2(1C2P)- [RP2(p=2)]
+        [FP3] -C3- [FP5][RP3(p=2)] -C4(1C2P)- [RP4(p=2)]
+        [FP7,FP8] -C5- [IF2]  /  [FP4] -C6- [FP6]
+
+        PortMappings: FP1->RP1p1, FP2->RP1p2, FP3->RP2p1, FP4->RP2p2,
+                      FP5->RP3p1, FP6->RP3p2, FP7->RP4p1, FP8->RP4p2
+
+        C1 is unprofiled (1 IF -> 2 FPs), C2/C4 are 1C2P trunks,
+        C3/C6 are unprofiled splices, C5 is unprofiled (2 FPs -> 1 IF).
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+        ]
+        rear_ports = [
+            RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
+        ]
+        front_ports = [
+            FrontPort.objects.create(device=self.device, name='Front Port 1'),  # Panel A strand 1
+            FrontPort.objects.create(device=self.device, name='Front Port 2'),  # Panel A strand 2
+            FrontPort.objects.create(device=self.device, name='Front Port 3'),  # Splice A strand 1
+            FrontPort.objects.create(device=self.device, name='Front Port 4'),  # Splice A strand 2
+            FrontPort.objects.create(device=self.device, name='Front Port 5'),  # Splice B strand 1
+            FrontPort.objects.create(device=self.device, name='Front Port 6'),  # Splice B strand 2
+            FrontPort.objects.create(device=self.device, name='Front Port 7'),  # Panel B strand 1
+            FrontPort.objects.create(device=self.device, name='Front Port 8'),  # Panel B strand 2
+        ]
+        PortMapping.objects.bulk_create([
+            # Panel A: FP1, FP2 -> RP1(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[0], front_port_position=1,
+                rear_port=rear_ports[0], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[1], front_port_position=1,
+                rear_port=rear_ports[0], rear_port_position=2,
+            ),
+            # Splice A: FP3, FP4 -> RP2(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[2], front_port_position=1,
+                rear_port=rear_ports[1], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[3], front_port_position=1,
+                rear_port=rear_ports[1], rear_port_position=2,
+            ),
+            # Splice B: FP5, FP6 -> RP3(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[4], front_port_position=1,
+                rear_port=rear_ports[2], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[5], front_port_position=1,
+                rear_port=rear_ports[2], rear_port_position=2,
+            ),
+            # Panel B: FP7, FP8 -> RP4(pos=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[6], front_port_position=1,
+                rear_port=rear_ports[3], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[7], front_port_position=1,
+                rear_port=rear_ports[3], rear_port_position=2,
+            ),
+        ])
+
+        # Create cables
+        cable1 = Cable(
+            a_terminations=[interfaces[0]],
+            b_terminations=[front_ports[0], front_ports[1]],
+        )
+        cable1.clean()
+        cable1.save()
+        cable2 = Cable(
+            profile=CableProfileChoices.SINGLE_1C2P,
+            a_terminations=[rear_ports[0]],
+            b_terminations=[rear_ports[1]],
+        )
+        cable2.clean()
+        cable2.save()
+        cable3 = Cable(
+            a_terminations=[front_ports[2]],
+            b_terminations=[front_ports[4]],
+        )
+        cable3.clean()
+        cable3.save()
+        cable4 = Cable(
+            profile=CableProfileChoices.SINGLE_1C2P,
+            a_terminations=[rear_ports[2]],
+            b_terminations=[rear_ports[3]],
+        )
+        cable4.clean()
+        cable4.save()
+        cable5 = Cable(
+            a_terminations=[front_ports[6], front_ports[7]],
+            b_terminations=[interfaces[1]],
+        )
+        cable5.clean()
+        cable5.save()
+        cable6 = Cable(
+            a_terminations=[front_ports[3]],
+            b_terminations=[front_ports[5]],
+        )
+        cable6.clean()
+        cable6.save()
+
+        # Verify forward path: IF1 -> IF2 (both strands through splice)
+        self.assertPathExists(
+            (
+                interfaces[0], cable1, [front_ports[0], front_ports[1]],
+                rear_ports[0], cable2, rear_ports[1],
+                [front_ports[2], front_ports[3]], [cable3, cable6], [front_ports[4], front_ports[5]],
+                rear_ports[2], cable4, rear_ports[3],
+                [front_ports[6], front_ports[7]], cable5, interfaces[1],
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        # Verify reverse path: IF2 -> IF1
+        self.assertPathExists(
+            (
+                interfaces[1], cable5, [front_ports[6], front_ports[7]],
+                rear_ports[3], cable4, rear_ports[2],
+                [front_ports[4], front_ports[5]], [cable3, cable6], [front_ports[2], front_ports[3]],
+                rear_ports[1], cable2, rear_ports[0],
+                [front_ports[0], front_ports[1]], cable1, interfaces[0],
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        # Verify cable positions are not set (unprofiled patch cables)
+        for iface in interfaces:
+            iface.refresh_from_db()
+        self.assertIsNone(interfaces[0].cable_connector)
+        self.assertIsNone(interfaces[0].cable_positions)
+        self.assertIsNone(interfaces[1].cable_connector)
+        self.assertIsNone(interfaces[1].cable_positions)
+
+    def test_109_multiconnector_trunk_through_patch_panel(self):
+        """
+        Tests that a 4-position interface traces correctly through a patch panel
+        that fans out to both connectors of a Trunk2C2P cable.
+
+        [IF1] --C1(1C4P)-- [FP1(p=4)][RP1(p=2)] --C3(Trunk2C2P)-- [RP3(p=2)][FP5(p=4)] --C5(1C4P)-- [IF2]
+                                      [RP2(p=2)]                    [RP4(p=2)]
+
+        PortMappings (Panel A): FP1p1->RP1p1, FP1p2->RP1p2, FP1p3->RP2p1, FP1p4->RP2p2
+        PortMappings (Panel B): FP5p1->RP3p1, FP5p2->RP3p2, FP5p3->RP4p1, FP5p4->RP4p2
+        """
+        interfaces = [
+            Interface.objects.create(device=self.device, name='Interface 1'),
+            Interface.objects.create(device=self.device, name='Interface 2'),
+        ]
+        rear_ports = [
+            RearPort.objects.create(device=self.device, name='Rear Port 1', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 2', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 3', positions=2),
+            RearPort.objects.create(device=self.device, name='Rear Port 4', positions=2),
+        ]
+        front_ports = [
+            FrontPort.objects.create(device=self.device, name='Front Port 1', positions=4),
+            FrontPort.objects.create(device=self.device, name='Front Port 5', positions=4),
+        ]
+        PortMapping.objects.bulk_create([
+            # Panel A: FP1(p=4) -> RP1(p=2) and RP2(p=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[0], front_port_position=1,
+                rear_port=rear_ports[0], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[0], front_port_position=2,
+                rear_port=rear_ports[0], rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[0], front_port_position=3,
+                rear_port=rear_ports[1], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[0], front_port_position=4,
+                rear_port=rear_ports[1], rear_port_position=2,
+            ),
+            # Panel B: FP5(p=4) -> RP3(p=2) and RP4(p=2)
+            PortMapping(
+                device=self.device, front_port=front_ports[1], front_port_position=1,
+                rear_port=rear_ports[2], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[1], front_port_position=2,
+                rear_port=rear_ports[2], rear_port_position=2,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[1], front_port_position=3,
+                rear_port=rear_ports[3], rear_port_position=1,
+            ),
+            PortMapping(
+                device=self.device, front_port=front_ports[1], front_port_position=4,
+                rear_port=rear_ports[3], rear_port_position=2,
+            ),
+        ])
+
+        # Create cables
+        cable1 = Cable(
+            profile=CableProfileChoices.SINGLE_1C4P,
+            a_terminations=[interfaces[0]],
+            b_terminations=[front_ports[0]],
+        )
+        cable1.clean()
+        cable1.save()
+        cable3 = Cable(
+            profile=CableProfileChoices.TRUNK_2C2P,
+            a_terminations=[rear_ports[0], rear_ports[1]],
+            b_terminations=[rear_ports[2], rear_ports[3]],
+        )
+        cable3.clean()
+        cable3.save()
+        cable5 = Cable(
+            profile=CableProfileChoices.SINGLE_1C4P,
+            a_terminations=[front_ports[1]],
+            b_terminations=[interfaces[1]],
+        )
+        cable5.clean()
+        cable5.save()
+
+        # Verify forward path: IF1 -> IF2 (all 4 positions through trunk)
+        self.assertPathExists(
+            (
+                interfaces[0], cable1, front_ports[0],
+                [rear_ports[0], rear_ports[1]], cable3, [rear_ports[2], rear_ports[3]],
+                front_ports[1], cable5, interfaces[1],
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        # Verify reverse path: IF2 -> IF1
+        self.assertPathExists(
+            (
+                interfaces[1], cable5, front_ports[1],
+                [rear_ports[2], rear_ports[3]], cable3, [rear_ports[0], rear_ports[1]],
+                front_ports[0], cable1, interfaces[0],
+            ),
+            is_complete=True,
+            is_active=True
+        )
+        self.assertEqual(CablePath.objects.count(), 2)
+
+        # Verify cable positions
+        for iface in interfaces:
+            iface.refresh_from_db()
+        self.assertEqual(interfaces[0].cable_connector, 1)
+        self.assertEqual(interfaces[0].cable_positions, [1, 2, 3, 4])
+        self.assertEqual(interfaces[1].cable_connector, 1)
+        self.assertEqual(interfaces[1].cable_positions, [1, 2, 3, 4])
+
+        # Verify rear port connector assignments
+        for rp in rear_ports:
+            rp.refresh_from_db()
+        self.assertEqual(rear_ports[0].cable_connector, 1)
+        self.assertEqual(rear_ports[0].cable_positions, [1, 2])
+        self.assertEqual(rear_ports[1].cable_connector, 2)
+        self.assertEqual(rear_ports[1].cable_positions, [1, 2])
+        self.assertEqual(rear_ports[2].cable_connector, 1)
+        self.assertEqual(rear_ports[2].cable_positions, [1, 2])
+        self.assertEqual(rear_ports[3].cable_connector, 2)
+        self.assertEqual(rear_ports[3].cable_positions, [1, 2])
+
+        # Test SVG generation
+        CableTraceSVG(interfaces[0]).render()
+
     def test_202_single_path_via_pass_through_with_breakouts(self):
         """
         [IF1] --C1-- [FP1] [RP1] --C2-- [IF3]

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

@@ -1201,6 +1201,35 @@ class CableTestCase(TestCase):
         with self.assertRaises(ValidationError):
             cable.clean()
 
+    def test_cable_profile_change_preserves_terminations(self):
+        """
+        When a Cable's profile is changed via save() without explicitly setting terminations (as happens during
+        bulk edit), the existing termination points must be preserved.
+        """
+        cable = Cable.objects.first()
+        interface1 = Interface.objects.get(device__name='TestDevice1', name='eth0')
+        interface2 = Interface.objects.get(device__name='TestDevice2', name='eth0')
+
+        # Verify initial state: cable has terminations and no profile
+        self.assertEqual(cable.profile, '')
+        self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
+
+        # Simulate what bulk edit does: load the cable from DB, set profile via setattr, and save.
+        # Crucially, do NOT set a_terminations or b_terminations on the instance.
+        cable_from_db = Cable.objects.get(pk=cable.pk)
+        cable_from_db.profile = CableProfileChoices.SINGLE_1C1P
+        cable_from_db.save()
+
+        # Verify terminations are preserved
+        self.assertEqual(CableTermination.objects.filter(cable=cable).count(), 2)
+
+        # Verify the correct interfaces are still terminated
+        cable_from_db.refresh_from_db()
+        a_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='A')]
+        b_terms = [ct.termination for ct in CableTermination.objects.filter(cable=cable, cable_end='B')]
+        self.assertEqual(a_terms, [interface1])
+        self.assertEqual(b_terms, [interface2])
+
 
 class VirtualDeviceContextTestCase(TestCase):
 

+ 2 - 18
netbox/dcim/views.py

@@ -16,7 +16,7 @@ from circuits.models import Circuit, CircuitTermination
 from extras.ui.panels import CustomFieldsPanel, ImageAttachmentsPanel, TagsPanel
 from extras.views import ObjectConfigContextView, ObjectRenderConfigView
 from ipam.models import ASN, VLAN, IPAddress, Prefix, VLANGroup
-from ipam.tables import InterfaceVLANTable, VLANTranslationRuleTable
+from ipam.tables import VLANTranslationRuleTable
 from netbox.object_actions import *
 from netbox.ui import actions, layout
 from netbox.ui.panels import (
@@ -389,7 +389,7 @@ class SiteGroupView(GetRelatedModelsMixin, generic.ObjectView):
                 title=_('Child Groups'),
                 filters={'parent_id': lambda ctx: ctx['object'].pk},
                 actions=[
-                    actions.AddObject('dcim.Region', url_params={'parent': lambda ctx: ctx['object'].pk}),
+                    actions.AddObject('dcim.SiteGroup', url_params={'parent': lambda ctx: ctx['object'].pk}),
                 ],
             ),
         ]
@@ -3309,21 +3309,6 @@ class InterfaceView(generic.ObjectView):
         )
         lag_interfaces_table.configure(request)
 
-        # Get assigned VLANs and annotate whether each is tagged or untagged
-        vlans = []
-        if instance.untagged_vlan is not None:
-            vlans.append(instance.untagged_vlan)
-            vlans[0].tagged = False
-        for vlan in instance.tagged_vlans.restrict(request.user).prefetch_related('site', 'group', 'tenant', 'role'):
-            vlan.tagged = True
-            vlans.append(vlan)
-        vlan_table = InterfaceVLANTable(
-            interface=instance,
-            data=vlans,
-            orderable=False
-        )
-        vlan_table.configure(request)
-
         # Get VLAN translation rules
         vlan_translation_table = None
         if instance.vlan_translation_policy:
@@ -3339,7 +3324,6 @@ class InterfaceView(generic.ObjectView):
             'bridge_interfaces_table': bridge_interfaces_table,
             'child_interfaces_table': child_interfaces_table,
             'lag_interfaces_table': lag_interfaces_table,
-            'vlan_table': vlan_table,
             'vlan_translation_table': vlan_translation_table,
         }
 

+ 3 - 1
netbox/extras/management/commands/runscript.py

@@ -81,7 +81,7 @@ class Command(BaseCommand):
                     logger.error(f'\t{field}: {error.get("message")}')
             raise CommandError()
 
-        # Remove extra fields from ScriptForm before passng data to script
+        # Remove extra fields from ScriptForm before passing data to script
         form.cleaned_data.pop('_schedule_at')
         form.cleaned_data.pop('_interval')
         form.cleaned_data.pop('_commit')
@@ -94,10 +94,12 @@ class Command(BaseCommand):
             data=form.cleaned_data,
             request=NetBoxFakeRequest({
                 'META': {},
+                'COOKIES': {},
                 'POST': data,
                 'GET': {},
                 'FILES': {},
                 'user': user,
+                'method': 'POST',
                 'path': '',
                 'id': uuid.uuid4()
             }),

+ 67 - 0
netbox/extras/managers.py

@@ -0,0 +1,67 @@
+from django.db import router
+from django.db.models import signals
+from taggit.managers import _TaggableManager
+from taggit.utils import require_instance_manager
+
+__all__ = (
+    'NetBoxTaggableManager',
+)
+
+
+class NetBoxTaggableManager(_TaggableManager):
+    """
+    Extends taggit's _TaggableManager to replace the per-tag get_or_create loop in add() with a
+    single bulk_create() call, reducing SQL queries from O(N) to O(1) when assigning tags.
+    """
+
+    @require_instance_manager
+    def add(self, *tags, through_defaults=None, tag_kwargs=None, **kwargs):
+        self._remove_prefetched_objects()
+        if tag_kwargs is None:
+            tag_kwargs = {}
+        db = router.db_for_write(self.through, instance=self.instance)
+
+        tag_objs = self._to_tag_model_instances(tags, tag_kwargs)
+        new_ids = {t.pk for t in tag_objs}
+
+        # Determine which tags are not already assigned to this object
+        lookup = self._lookup_kwargs()
+        vals = set(
+            self.through._default_manager.using(db)
+            .values_list("tag_id", flat=True)
+            .filter(**lookup, tag_id__in=new_ids)
+        )
+        new_ids -= vals
+
+        if not new_ids:
+            return
+
+        signals.m2m_changed.send(
+            sender=self.through,
+            action="pre_add",
+            instance=self.instance,
+            reverse=False,
+            model=self.through.tag_model(),
+            pk_set=new_ids,
+            using=db,
+        )
+
+        # Use a single bulk INSERT instead of one get_or_create per tag.
+        self.through._default_manager.using(db).bulk_create(
+            [
+                self.through(tag=tag, **lookup, **(through_defaults or {}))
+                for tag in tag_objs
+                if tag.pk in new_ids
+            ],
+            ignore_conflicts=True,
+        )
+
+        signals.m2m_changed.send(
+            sender=self.through,
+            action="post_add",
+            instance=self.instance,
+            reverse=False,
+            model=self.through.tag_model(),
+            pk_set=new_ids,
+            using=db,
+        )

+ 33 - 14
netbox/ipam/forms/bulk_import.py

@@ -217,8 +217,8 @@ class PrefixImportForm(ScopedImportForm, PrimaryModelImportForm):
     class Meta:
         model = Prefix
         fields = (
-            'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_id',
-            'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
+            'prefix', 'vrf', 'tenant', 'vlan_group', 'vlan_site', 'vlan', 'status', 'role', 'scope_type', 'scope_name',
+            'scope_id', 'is_pool', 'mark_utilized', 'description', 'owner', 'comments', 'tags',
         )
         labels = {
             'scope_id': _('Scope ID'),
@@ -431,19 +431,36 @@ class IPAddressImportForm(PrimaryModelImportForm):
         # Set as primary for device/VM
         if self.cleaned_data.get('is_primary') is not None:
             parent = self.cleaned_data.get('device') or self.cleaned_data.get('virtual_machine')
-            parent.snapshot()
-            if self.instance.address.version == 4:
-                parent.primary_ip4 = ipaddress if self.cleaned_data.get('is_primary') else None
-            elif self.instance.address.version == 6:
-                parent.primary_ip6 = ipaddress if self.cleaned_data.get('is_primary') else None
-            parent.save()
+            if self.cleaned_data.get('is_primary'):
+                parent.snapshot()
+                if self.instance.address.version == 4:
+                    parent.primary_ip4 = ipaddress
+                elif self.instance.address.version == 6:
+                    parent.primary_ip6 = ipaddress
+                parent.save()
+            else:
+                # Only clear the primary IP if this IP is currently set as primary
+                if self.instance.address.version == 4 and parent.primary_ip4 == ipaddress:
+                    parent.snapshot()
+                    parent.primary_ip4 = None
+                    parent.save()
+                elif self.instance.address.version == 6 and parent.primary_ip6 == ipaddress:
+                    parent.snapshot()
+                    parent.primary_ip6 = None
+                    parent.save()
 
         # Set as OOB for device
         if self.cleaned_data.get('is_oob') is not None:
             parent = self.cleaned_data.get('device')
-            parent.snapshot()
-            parent.oob_ip = ipaddress if self.cleaned_data.get('is_oob') else None
-            parent.save()
+            if self.cleaned_data.get('is_oob'):
+                parent.snapshot()
+                parent.oob_ip = ipaddress
+                parent.save()
+            elif parent.oob_ip == ipaddress:
+                # Only clear OOB if this IP is currently set as the OOB IP
+                parent.snapshot()
+                parent.oob_ip = None
+                parent.save()
 
         return ipaddress
 
@@ -464,7 +481,8 @@ class FHRPGroupImportForm(PrimaryModelImportForm):
         fields = ('protocol', 'group_id', 'auth_type', 'auth_key', 'name', 'description', 'owner', 'comments', 'tags')
 
 
-class VLANGroupImportForm(OrganizationalModelImportForm):
+class VLANGroupImportForm(ScopedImportForm, OrganizationalModelImportForm):
+    # Override ScopedImportForm.scope_type to set custom queryset
     scope_type = CSVContentTypeField(
         queryset=ContentType.objects.filter(model__in=VLANGROUP_SCOPE_TYPES),
         required=False,
@@ -484,10 +502,11 @@ class VLANGroupImportForm(OrganizationalModelImportForm):
     class Meta:
         model = VLANGroup
         fields = (
-            'name', 'slug', 'scope_type', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner', 'comments', 'tags'
+            'name', 'slug', 'scope_type', 'scope_name', 'scope_id', 'vid_ranges', 'tenant', 'description', 'owner',
+            'comments', 'tags',
         )
         labels = {
-            'scope_id': 'Scope ID',
+            'scope_id': _('Scope ID'),
         }
 
 

+ 1 - 44
netbox/ipam/tables/vlans.py

@@ -1,19 +1,17 @@
 import django_tables2 as tables
 from django.utils.safestring import mark_safe
 from django.utils.translation import gettext_lazy as _
-from django_tables2.utils import Accessor
 
 from dcim.models import Interface
 from dcim.tables.template_code import INTERFACE_LINKTERMINATION, LINKTERMINATION
 from ipam.models import *
 from netbox.tables import NetBoxTable, OrganizationalModelTable, PrimaryModelTable, columns
-from tenancy.tables import TenancyColumnsMixin, TenantColumn
+from tenancy.tables import TenancyColumnsMixin
 from virtualization.models import VMInterface
 
 from .template_code import *
 
 __all__ = (
-    'InterfaceVLANTable',
     'VLANDevicesTable',
     'VLANGroupTable',
     'VLANMembersTable',
@@ -202,47 +200,6 @@ class VLANVirtualMachinesTable(VLANMembersTable):
         exclude = ('id', )
 
 
-class InterfaceVLANTable(NetBoxTable):
-    """
-    List VLANs assigned to a specific Interface.
-    """
-    vid = tables.Column(
-        linkify=True,
-        verbose_name=_('VID')
-    )
-    tagged = columns.BooleanColumn(
-        verbose_name=_('Tagged'),
-        false_mark=None
-    )
-    site = tables.Column(
-        verbose_name=_('Site'),
-        linkify=True
-    )
-    group = tables.Column(
-        accessor=Accessor('group__name'),
-        verbose_name=_('Group')
-    )
-    tenant = TenantColumn(
-        verbose_name=_('Tenant'),
-    )
-    status = columns.ChoiceFieldColumn(
-        verbose_name=_('Status'),
-    )
-    role = tables.Column(
-        verbose_name=_('Role'),
-        linkify=True
-    )
-
-    class Meta(NetBoxTable.Meta):
-        model = VLAN
-        fields = ('vid', 'tagged', 'site', 'group', 'name', 'tenant', 'status', 'role', 'description')
-        exclude = ('id', )
-
-    def __init__(self, interface, *args, **kwargs):
-        self.interface = interface
-        super().__init__(*args, **kwargs)
-
-
 #
 # VLAN Translation
 #

+ 56 - 1
netbox/ipam/tests/test_forms.py

@@ -1,8 +1,10 @@
 from django.contrib.contenttypes.models import ContentType
 from django.test import TestCase
 
-from dcim.models import Location, Region, Site, SiteGroup
+from dcim.constants import InterfaceTypeChoices
+from dcim.models import Device, DeviceRole, DeviceType, Interface, Location, Manufacturer, Region, Site, SiteGroup
 from ipam.forms import PrefixForm
+from ipam.forms.bulk_import import IPAddressImportForm
 
 
 class PrefixFormTestCase(TestCase):
@@ -41,3 +43,56 @@ class PrefixFormTestCase(TestCase):
             })
 
             assert 'data-dynamic-params' not in form.fields['vlan'].widget.attrs
+
+
+class IPAddressImportFormTestCase(TestCase):
+    """Tests for IPAddressImportForm bulk import behavior."""
+
+    @classmethod
+    def setUpTestData(cls):
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+        cls.device = Device.objects.create(
+            name='Device 1',
+            site=site,
+            device_type=device_type,
+            role=device_role,
+        )
+        cls.interface = Interface.objects.create(
+            device=cls.device,
+            name='eth0',
+            type=InterfaceTypeChoices.TYPE_1GE_FIXED,
+        )
+
+    def test_oob_import_not_cleared_by_subsequent_non_oob_row(self):
+        """
+        Regression test for #21440: importing a second IP with is_oob=False should
+        not clear the OOB IP set by a previous row with is_oob=True.
+        """
+        form1 = IPAddressImportForm(data={
+            'address': '10.10.10.1/24',
+            'status': 'active',
+            'device': 'Device 1',
+            'interface': 'eth0',
+            'is_oob': True,
+        })
+        self.assertTrue(form1.is_valid(), form1.errors)
+        ip1 = form1.save()
+
+        self.device.refresh_from_db()
+        self.assertEqual(self.device.oob_ip, ip1)
+
+        form2 = IPAddressImportForm(data={
+            'address': '2001:db8::1/64',
+            'status': 'active',
+            'device': 'Device 1',
+            'interface': 'eth0',
+            'is_oob': False,
+        })
+        self.assertTrue(form2.is_valid(), form2.errors)
+        form2.save()
+
+        self.device.refresh_from_db()
+        self.assertEqual(self.device.oob_ip, ip1, "OOB IP was incorrectly cleared by a row with is_oob=False")

+ 55 - 13
netbox/ipam/tests/test_views.py

@@ -443,13 +443,21 @@ class PrefixTestCase(ViewTestCases.PrimaryObjectViewTestCase):
             'tags': [t.pk for t in tags],
         }
 
-        site = sites[0].pk
-        cls.csv_data = (
-            "vrf,prefix,status,scope_type,scope_id",
-            f"VRF 1,10.4.0.0/16,active,dcim.site,{site}",
-            f"VRF 1,10.5.0.0/16,active,dcim.site,{site}",
-            f"VRF 1,10.6.0.0/16,active,dcim.site,{site}",
-        )
+        site = sites[0]
+        cls.csv_data = {
+            'default': (
+                "vrf,prefix,status,scope_type,scope_id",
+                f"VRF 1,10.4.0.0/16,active,dcim.site,{site.pk}",
+                f"VRF 1,10.5.0.0/16,active,dcim.site,{site.pk}",
+                f"VRF 1,10.6.0.0/16,active,dcim.site,{site.pk}",
+            ),
+            'scope_name': (
+                "vrf,prefix,status,scope_type,scope_name",
+                f"VRF 1,10.4.0.0/16,active,dcim.site,{site.name}",
+                f"VRF 1,10.5.0.0/16,active,dcim.site,{site.name}",
+                f"VRF 1,10.6.0.0/16,active,dcim.site,{site.name}",
+            ),
+        }
 
         cls.csv_update_data = (
             "id,description,status",
@@ -608,6 +616,32 @@ scope_id: {site.pk}
         self.assertEqual(prefix.vlan.vid, 101)
         self.assertEqual(prefix.scope, site)
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_prefix_import_with_scope_name(self):
+        """
+        Test YAML-based import using scope_name instead of scope_id.
+        """
+        site = Site.objects.get(name='Site 1')
+        IMPORT_DATA = """
+prefix: 10.1.3.0/24
+status: active
+scope_type: dcim.site
+scope_name: Site 1
+"""
+        # Add all required permissions to the test user
+        self.add_permissions('ipam.view_prefix', 'ipam.add_prefix')
+
+        form_data = {
+            'data': IMPORT_DATA,
+            'format': 'yaml'
+        }
+        response = self.client.post(reverse('ipam:prefix_bulk_import'), data=form_data, follow=True)
+        self.assertHttpStatus(response, 200)
+
+        prefix = Prefix.objects.get(prefix='10.1.3.0/24')
+        self.assertEqual(prefix.status, PrefixStatusChoices.STATUS_ACTIVE)
+        self.assertEqual(prefix.scope, site)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_prefix_import_with_vlan_group(self):
         """
@@ -960,12 +994,20 @@ class VLANGroupTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
             'tags': [t.pk for t in tags],
         }
 
-        cls.csv_data = (
-            "name,slug,scope_type,scope_id,description",
-            "VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
-            f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
-            f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
-        )
+        cls.csv_data = {
+            'default': (
+                "name,slug,scope_type,scope_id,description",
+                "VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
+                f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].pk},Fifth VLAN group",
+                f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].pk},Sixth VLAN group",
+            ),
+            'scope_name': (
+                "name,slug,scope_type,scope_name,description",
+                "VLAN Group 4,vlan-group-4,,,Fourth VLAN group",
+                f"VLAN Group 5,vlan-group-5,dcim.site,{sites[0].name},Fifth VLAN group",
+                f"VLAN Group 6,vlan-group-6,dcim.site,{sites[1].name},Sixth VLAN group",
+            ),
+        }
 
         cls.csv_update_data = (
             "id,name,description",

+ 3 - 0
netbox/netbox/api/serializers/features.py

@@ -53,8 +53,11 @@ class TaggableModelSerializer(serializers.Serializer):
 
     def _save_tags(self, instance, tags):
         if tags:
+            # Cache tags on instance so serialize_object() can reuse them without a DB query
+            instance._tags = tags
             instance.tags.set([t.name for t in tags])
         else:
+            instance._tags = []
             instance.tags.clear()
 
         return instance

+ 12 - 0
netbox/netbox/graphql/pagination.py

@@ -2,6 +2,8 @@ import strawberry
 from strawberry.types.unset import UNSET
 from strawberry_django.pagination import _QS, apply
 
+from netbox.config import get_config
+
 __all__ = (
     'OffsetPaginationInfo',
     'OffsetPaginationInput',
@@ -47,4 +49,14 @@ def apply_pagination(
         # Ignore `offset` when `start` is set
         pagination.offset = 0
 
+    # Enforce MAX_PAGE_SIZE on the pagination limit
+    max_page_size = get_config().MAX_PAGE_SIZE
+    if max_page_size:
+        if pagination is None:
+            pagination = OffsetPaginationInput(limit=max_page_size)
+        elif pagination.limit in (None, UNSET) or pagination.limit > max_page_size:
+            pagination.limit = max_page_size
+        elif pagination.limit <= 0:
+            pagination.limit = max_page_size
+
     return apply(pagination, queryset, related_field_id=related_field_id)

+ 18 - 9
netbox/netbox/middleware.py

@@ -40,15 +40,24 @@ class CoreMiddleware:
         with apply_request_processors(request):
             response = self.get_response(request)
 
-        # Check if language cookie should be renewed
-        if request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
-            if language := request.user.config.get('locale.language'):
-                response.set_cookie(
-                    key=settings.LANGUAGE_COOKIE_NAME,
-                    value=language,
-                    max_age=request.session.get_expiry_age(),
-                    secure=settings.SESSION_COOKIE_SECURE,
-                )
+        # Set or renew the language cookie based on the user's preference. This handles two cases:
+        # 1. The user just logged in (via any auth backend): the user_logged_in signal stores the preferred language on
+        #    the request so we set the cookie here on the login response.
+        # 2. SESSION_SAVE_EVERY_REQUEST is enabled: renew the language cookie on every request to keep it in sync with
+        #    the session expiry.
+        if hasattr(request, '_language_cookie'):
+            language = request._language_cookie
+        elif request.user.is_authenticated and settings.SESSION_SAVE_EVERY_REQUEST:
+            language = request.user.config.get('locale.language')
+        else:
+            language = None
+        if language:
+            response.set_cookie(
+                key=settings.LANGUAGE_COOKIE_NAME,
+                value=language,
+                max_age=request.session.get_expiry_age(),
+                secure=settings.SESSION_COOKIE_SECURE,
+            )
 
         # Attach the unique request ID as an HTTP header.
         response['X-Request-ID'] = request.id

+ 3 - 1
netbox/netbox/models/features.py

@@ -15,6 +15,7 @@ from core.choices import JobStatusChoices, ObjectChangeActionChoices
 from core.models import ObjectType
 from extras.choices import *
 from extras.constants import CUSTOMFIELD_EMPTY_VALUES
+from extras.managers import NetBoxTaggableManager
 from extras.utils import is_taggable
 from netbox.config import get_config
 from netbox.constants import CORE_APPS
@@ -487,11 +488,12 @@ class JournalingMixin(models.Model):
 class TagsMixin(models.Model):
     """
     Enables support for tag assignment. Assigned tags can be managed via the `tags` attribute,
-    which is a `TaggableManager` instance.
+    which is a `NetBoxTaggableManager` instance.
     """
     tags = TaggableManager(
         through='extras.TaggedItem',
         ordering=('weight', 'name'),
+        manager=NetBoxTaggableManager,
     )
 
     class Meta:

+ 47 - 0
netbox/netbox/tests/test_graphql.py

@@ -283,6 +283,53 @@ class GraphQLAPITestCase(APITestCase):
         self.assertEqual(len(data['data']['site_list']), 1)
         self.assertEqual(data['data']['site_list'][0]['name'], 'Site 7')
 
+    @override_settings(MAX_PAGE_SIZE=3)
+    def test_max_page_size(self):
+        self.add_permissions('dcim.view_site')
+        url = reverse('graphql')
+
+        # Request without explicit limit should be capped by MAX_PAGE_SIZE
+        query = """
+        {
+            site_list {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+
+        # Request with limit exceeding MAX_PAGE_SIZE should be capped
+        query = """
+        {
+            site_list(pagination: {limit: 100}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 3)
+
+        # Request with limit under MAX_PAGE_SIZE should be respected
+        query = """
+        {
+            site_list(pagination: {limit: 2}) {
+                id name
+            }
+        }
+        """
+        response = self.client.post(url, data={'query': query}, format='json', **self.header)
+        self.assertHttpStatus(response, status.HTTP_200_OK)
+        data = json.loads(response.content)
+        self.assertNotIn('errors', data)
+        self.assertEqual(len(data['data']['site_list']), 2)
+
     def test_pagination_conflict(self):
         url = reverse('graphql')
         query = """

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

@@ -10,6 +10,7 @@ __all__ = (
     'BooleanAttr',
     'ChoiceAttr',
     'ColorAttr',
+    'DateTimeAttr',
     'GPSCoordinatesAttr',
     'GenericForeignKeyAttr',
     'ImageAttr',
@@ -367,6 +368,26 @@ class GPSCoordinatesAttr(ObjectAttribute):
         })
 
 
+class DateTimeAttr(ObjectAttribute):
+    """
+    A date or datetime attribute.
+
+    Parameters:
+        spec (str): Controls the rendering format. Use 'date' for date-only rendering,
+                    or 'seconds'/'minutes' for datetime rendering with the given precision.
+    """
+    template_name = 'ui/attrs/datetime.html'
+
+    def __init__(self, *args, spec='seconds', **kwargs):
+        super().__init__(*args, **kwargs)
+        self.spec = spec
+
+    def get_context(self, obj, context):
+        return {
+            'spec': self.spec,
+        }
+
+
 class TimezoneAttr(ObjectAttribute):
     """
     A timezone value. Includes the numeric offset from UTC.

تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.css


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 7 - 7
netbox/project-static/package.json

@@ -31,22 +31,22 @@
     "gridstack": "12.4.2",
     "htmx.org": "2.0.8",
     "query-string": "9.3.1",
-    "sass": "1.97.3",
+    "sass": "1.98.0",
     "tom-select": "2.5.2",
     "typeface-inter": "3.18.1",
     "typeface-roboto-mono": "1.1.13"
   },
   "devDependencies": {
-    "@eslint/compat": "^2.0.2",
-    "@eslint/eslintrc": "^3.3.4",
+    "@eslint/compat": "^2.0.3",
+    "@eslint/eslintrc": "^3.3.5",
     "@eslint/js": "^9.39.2",
     "@types/bootstrap": "5.2.10",
     "@types/cookie": "^1.0.0",
     "@types/node": "^24.10.1",
-    "@typescript-eslint/eslint-plugin": "^8.56.1",
-    "@typescript-eslint/parser": "^8.56.1",
-    "esbuild": "^0.27.3",
-    "esbuild-sass-plugin": "^3.6.0",
+    "@typescript-eslint/eslint-plugin": "^8.57.0",
+    "@typescript-eslint/parser": "^8.57.0",
+    "esbuild": "^0.27.4",
+    "esbuild-sass-plugin": "^3.7.0",
     "eslint": "^9.39.2",
     "eslint-config-prettier": "^10.1.8",
     "eslint-import-resolver-typescript": "^4.4.4",

+ 1 - 6
netbox/project-static/src/colorMode.ts

@@ -20,12 +20,7 @@ function storeColorMode(mode: ColorMode): void {
 }
 
 function updateElements(targetMode: ColorMode): void {
-  const body = document.querySelector('body');
-  if (body && targetMode == 'dark') {
-    body.setAttribute('data-bs-theme', 'dark');
-  } else if (body) {
-    body.setAttribute('data-bs-theme', 'light');
-  }
+  document.documentElement.setAttribute('data-bs-theme', targetMode);
 
   for (const elevation of getElements<HTMLObjectElement>('.rack_elevation')) {
     const svg = elevation.firstElementChild ?? null;

+ 18 - 6
netbox/project-static/src/select/classes/dynamicTomSelect.ts

@@ -1,16 +1,16 @@
 import type { RecursivePartial, TomOption, TomSettings, TomInput } from 'tom-select/dist/cjs/types';
 import { addClasses } from 'tom-select/src/vanilla.ts';
 import queryString from 'query-string';
-import TomSelect from 'tom-select';
 import type { Stringifiable } from 'query-string';
 import { DynamicParamsMap } from './dynamicParamsMap';
+import { NetBoxTomSelect } from './netboxTomSelect';
 
 // Transitional
 import { QueryFilter, PathFilter } from '../types';
 import { getElement, replaceAll } from '../../util';
 
-// Extends TomSelect to provide enhanced fetching of options via the REST API
-export class DynamicTomSelect extends TomSelect {
+// Extends NetBoxTomSelect to provide enhanced fetching of options via the REST API
+export class DynamicTomSelect extends NetBoxTomSelect {
   public readonly nullOption: Nullable<TomOption> = null;
 
   // Transitional code from APISelect
@@ -71,7 +71,7 @@ export class DynamicTomSelect extends TomSelect {
     this.addEventListeners();
   }
 
-  load(value: string) {
+  load(value: string, preserveValue?: string | string[]) {
     const self = this;
 
     // Automatically clear any cached options. (Only options included
@@ -107,6 +107,14 @@ export class DynamicTomSelect extends TomSelect {
       // Pass the options to the callback function
       .then(options => {
         self.loadCallback(options, []);
+        // Restore the previous selection if it is still valid under the new filter.
+        if (preserveValue !== undefined) {
+          const values = Array.isArray(preserveValue) ? preserveValue : [preserveValue];
+          const validValues = values.filter(v => v !== '' && v in self.options);
+          if (validValues.length > 0) {
+            self.setValue(validValues.length === 1 ? validValues[0] : validValues, true);
+          }
+        }
       })
       .catch(() => {
         self.loadCallback([], []);
@@ -338,6 +346,9 @@ export class DynamicTomSelect extends TomSelect {
   private handleEvent(event: Event): void {
     const target = event.target as HTMLSelectElement;
 
+    // Save the current selection so we can restore it after loading if it remains valid.
+    const previousValue = this.getValue();
+
     // Update the element's URL after any changes to a dependency.
     this.updateQueryParams(target.name);
     this.updatePathValues(target.name);
@@ -345,7 +356,8 @@ export class DynamicTomSelect extends TomSelect {
     // Clear any previous selection(s) as the parent filter has changed
     this.clear();
 
-    // Load new data.
-    this.load(this.lastValue);
+    // Load new data, restoring the previous selection if it is still valid under the new filter.
+    const preserve = previousValue !== '' && previousValue !== null ? previousValue : undefined;
+    this.load(this.lastValue, preserve);
   }
 }

+ 39 - 0
netbox/project-static/src/select/classes/netboxTomSelect.ts

@@ -0,0 +1,39 @@
+import TomSelect from 'tom-select';
+
+/**
+ * Extends TomSelect to work around a browser autofill bug where Edge's "last used" autofill
+ * simultaneously focuses multiple inputs, triggering a cascading focus/open/blur loop between
+ * TomSelect instances.
+ *
+ * Root cause: TomSelect's open() method calls focus(), which synchronously moves browser focus
+ * to this instance's control input, then schedules setTimeout(onFocus, 0). When Edge autofill
+ * has moved focus to a *different* select before the timeout fires, the delayed onFocus() call
+ * re-steals browser focus back, causing the other instance to blur and close. Each instance's
+ * deferred callback then repeats this, creating an infinite ping-pong loop.
+ *
+ * Fix: in the setTimeout callback, only proceed with onFocus() if this instance's element is
+ * still the active element. If focus has already moved elsewhere, skip the call.
+ *
+ * Upstream bug: https://github.com/orchidjs/tom-select/issues/806
+ * NetBox issue:  https://github.com/netbox-community/netbox/issues/20077
+ */
+export class NetBoxTomSelect extends TomSelect {
+  focus(): void {
+    if (this.isDisabled || this.isReadOnly) return;
+
+    this.ignoreFocus = true;
+
+    const focusTarget = this.control_input.offsetWidth ? this.control_input : this.focus_node;
+    focusTarget.focus();
+
+    setTimeout(() => {
+      this.ignoreFocus = false;
+      // Only proceed if this instance's element is still the active element. If Edge autofill
+      // (or anything else) has moved focus to a different element in the interim, calling
+      // onFocus() here would steal focus back and restart the cascade loop.
+      if (document.activeElement === focusTarget || this.control.contains(document.activeElement)) {
+        this.onFocus();
+      }
+    }, 0);
+  }
+}

+ 3 - 3
netbox/project-static/src/select/static.ts

@@ -1,6 +1,6 @@
 import { TomOption } from 'tom-select/src/types';
-import TomSelect from 'tom-select';
 import { escape_html } from 'tom-select/src/utils';
+import { NetBoxTomSelect } from './classes/netboxTomSelect';
 import { getPlugins } from './config';
 import { getElements } from '../util';
 
@@ -9,7 +9,7 @@ export function initStaticSelects(): void {
   for (const select of getElements<HTMLSelectElement>(
     'select:not(.tomselected):not(.no-ts):not([size]):not(.api-select):not(.color-select)',
   )) {
-    new TomSelect(select, {
+    new NetBoxTomSelect(select, {
       ...getPlugins(select),
       maxOptions: undefined,
     });
@@ -25,7 +25,7 @@ export function initColorSelects(): void {
   }
 
   for (const select of getElements<HTMLSelectElement>('select.color-select:not(.tomselected)')) {
-    new TomSelect(select, {
+    new NetBoxTomSelect(select, {
       ...getPlugins(select),
       maxOptions: undefined,
       render: {

+ 1 - 1
netbox/project-static/styles/custom/_misc.scss

@@ -112,7 +112,7 @@ img.plugin-icon {
 }
 
 
-body[data-bs-theme=dark] {
+html[data-bs-theme=dark] {
   // Assuming icon is black/white line art, invert it and tone down brightness
   img.plugin-icon {
     filter: grayscale(100%) invert(100%) brightness(80%);

+ 5 - 10
netbox/project-static/styles/overrides/_tabler.scss

@@ -93,7 +93,7 @@ pre {
 }
 
 // Dark mode overrides
-body[data-bs-theme=dark] {
+html[data-bs-theme=dark] {
   // Override background color alpha value
   ::selection {
     background-color: rgba(var(--tblr-primary-rgb),.48);
@@ -174,16 +174,11 @@ pre code {
 }
 
 // Theme-based visibility utilities
-// Tabler's .hide-theme-* utilities expect data-bs-theme on :root, but NetBox applies
-// it to body. These overrides use higher specificity selectors to ensure theme-based
-// visibility works correctly. The :root:not(.dummy) pattern provides the additional
-// specificity needed to override Tabler's :root:not() rules.
-:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-light,
-:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-dark {
+:root:not(.dummy)[data-bs-theme='light'] .hide-theme-light,
+:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-dark {
   display: none !important;
 }
-
-:root:not(.dummy) body[data-bs-theme='dark'] .hide-theme-light,
-:root:not(.dummy) body[data-bs-theme='light'] .hide-theme-dark {
+:root:not(.dummy)[data-bs-theme='dark'] .hide-theme-light,
+:root:not(.dummy)[data-bs-theme='light'] .hide-theme-dark {
   display: inline-flex !important;
 }

+ 2 - 2
netbox/project-static/styles/transitional/_navigation.scss

@@ -77,13 +77,13 @@
 }
 
 // Light theme styling
-body[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
+html[data-bs-theme=light] .navbar-vertical.navbar-expand-lg {
   // Background Gradient
   background: linear-gradient(180deg, rgba(0, 133, 125, 0.00) 0%, rgba(0, 133, 125, 0.10) 100%), #FFF;
 }
 
 // Dark theme styling
-body[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
+html[data-bs-theme=dark] .navbar-vertical.navbar-expand-lg {
 
   // Background Gradient
   background: linear-gradient(180deg, rgba(0, 242, 212, 0.00) 0%, rgba(0, 242, 212, 0.10) 100%), #001423;

+ 1 - 1
netbox/project-static/styles/transitional/_tables.scss

@@ -59,7 +59,7 @@ table th.orderable a {
   color: var(--#{$prefix}body-color);
 }
 
-body[data-bs-theme=dark] {
+html[data-bs-theme=dark] {
   // Adjust table header background color
   .table thead th, .markdown>table thead th {
     background: $rich-black !important;

+ 251 - 262
netbox/project-static/yarn.lock

@@ -24,135 +24,135 @@
   dependencies:
     tslib "^2.4.0"
 
-"@esbuild/aix-ppc64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.3.tgz#815b39267f9bffd3407ea6c376ac32946e24f8d2"
-  integrity sha512-9fJMTNFTWZMh5qwrBItuziu834eOCUcEqymSH7pY+zoMVEZg3gcPuBNxH1EvfVYe9h0x/Ptw8KBzv7qxb7l8dg==
-
-"@esbuild/android-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.3.tgz#19b882408829ad8e12b10aff2840711b2da361e8"
-  integrity sha512-YdghPYUmj/FX2SYKJ0OZxf+iaKgMsKHVPF1MAq/P8WirnSpCStzKJFjOjzsW0QQ7oIAiccHdcqjbHmJxRb/dmg==
-
-"@esbuild/android-arm@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.3.tgz#90be58de27915efa27b767fcbdb37a4470627d7b"
-  integrity sha512-i5D1hPY7GIQmXlXhs2w8AWHhenb00+GxjxRncS2ZM7YNVGNfaMxgzSGuO8o8SJzRc/oZwU2bcScvVERk03QhzA==
-
-"@esbuild/android-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.3.tgz#d7dcc976f16e01a9aaa2f9b938fbec7389f895ac"
-  integrity sha512-IN/0BNTkHtk8lkOM8JWAYFg4ORxBkZQf9zXiEOfERX/CzxW3Vg1ewAhU7QSWQpVIzTW+b8Xy+lGzdYXV6UZObQ==
-
-"@esbuild/darwin-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.3.tgz#9f6cac72b3a8532298a6a4493ed639a8988e8abd"
-  integrity sha512-Re491k7ByTVRy0t3EKWajdLIr0gz2kKKfzafkth4Q8A5n1xTHrkqZgLLjFEHVD+AXdUGgQMq+Godfq45mGpCKg==
-
-"@esbuild/darwin-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.3.tgz#ac61d645faa37fd650340f1866b0812e1fb14d6a"
-  integrity sha512-vHk/hA7/1AckjGzRqi6wbo+jaShzRowYip6rt6q7VYEDX4LEy1pZfDpdxCBnGtl+A5zq8iXDcyuxwtv3hNtHFg==
-
-"@esbuild/freebsd-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.3.tgz#b8625689d73cf1830fe58c39051acdc12474ea1b"
-  integrity sha512-ipTYM2fjt3kQAYOvo6vcxJx3nBYAzPjgTCk7QEgZG8AUO3ydUhvelmhrbOheMnGOlaSFUoHXB6un+A7q4ygY9w==
-
-"@esbuild/freebsd-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.3.tgz#07be7dd3c9d42fe0eccd2ab9f9ded780bc53bead"
-  integrity sha512-dDk0X87T7mI6U3K9VjWtHOXqwAMJBNN2r7bejDsc+j03SEjtD9HrOl8gVFByeM0aJksoUuUVU9TBaZa2rgj0oA==
-
-"@esbuild/linux-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.3.tgz#bf31918fe5c798586460d2b3d6c46ed2c01ca0b6"
-  integrity sha512-sZOuFz/xWnZ4KH3YfFrKCf1WyPZHakVzTiqji3WDc0BCl2kBwiJLCXpzLzUBLgmp4veFZdvN5ChW4Eq/8Fc2Fg==
-
-"@esbuild/linux-arm@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.3.tgz#28493ee46abec1dc3f500223cd9f8d2df08f9d11"
-  integrity sha512-s6nPv2QkSupJwLYyfS+gwdirm0ukyTFNl3KTgZEAiJDd+iHZcbTPPcWCcRYH+WlNbwChgH2QkE9NSlNrMT8Gfw==
-
-"@esbuild/linux-ia32@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.3.tgz#750752a8b30b43647402561eea764d0a41d0ee29"
-  integrity sha512-yGlQYjdxtLdh0a3jHjuwOrxQjOZYD/C9PfdbgJJF3TIZWnm/tMd/RcNiLngiu4iwcBAOezdnSLAwQDPqTmtTYg==
-
-"@esbuild/linux-loong64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.3.tgz#a5a92813a04e71198c50f05adfaf18fc1e95b9ed"
-  integrity sha512-WO60Sn8ly3gtzhyjATDgieJNet/KqsDlX5nRC5Y3oTFcS1l0KWba+SEa9Ja1GfDqSF1z6hif/SkpQJbL63cgOA==
-
-"@esbuild/linux-mips64el@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.3.tgz#deb45d7fd2d2161eadf1fbc593637ed766d50bb1"
-  integrity sha512-APsymYA6sGcZ4pD6k+UxbDjOFSvPWyZhjaiPyl/f79xKxwTnrn5QUnXR5prvetuaSMsb4jgeHewIDCIWljrSxw==
-
-"@esbuild/linux-ppc64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.3.tgz#6f39ae0b8c4d3d2d61a65b26df79f6e12a1c3d78"
-  integrity sha512-eizBnTeBefojtDb9nSh4vvVQ3V9Qf9Df01PfawPcRzJH4gFSgrObw+LveUyDoKU3kxi5+9RJTCWlj4FjYXVPEA==
-
-"@esbuild/linux-riscv64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.3.tgz#4c5c19c3916612ec8e3915187030b9df0b955c1d"
-  integrity sha512-3Emwh0r5wmfm3ssTWRQSyVhbOHvqegUDRd0WhmXKX2mkHJe1SFCMJhagUleMq+Uci34wLSipf8Lagt4LlpRFWQ==
-
-"@esbuild/linux-s390x@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.3.tgz#9ed17b3198fa08ad5ccaa9e74f6c0aff7ad0156d"
-  integrity sha512-pBHUx9LzXWBc7MFIEEL0yD/ZVtNgLytvx60gES28GcWMqil8ElCYR4kvbV2BDqsHOvVDRrOxGySBM9Fcv744hw==
-
-"@esbuild/linux-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.3.tgz#12383dcbf71b7cf6513e58b4b08d95a710bf52a5"
-  integrity sha512-Czi8yzXUWIQYAtL/2y6vogER8pvcsOsk5cpwL4Gk5nJqH5UZiVByIY8Eorm5R13gq+DQKYg0+JyQoytLQas4dA==
-
-"@esbuild/netbsd-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.3.tgz#dd0cb2fa543205fcd931df44f4786bfcce6df7d7"
-  integrity sha512-sDpk0RgmTCR/5HguIZa9n9u+HVKf40fbEUt+iTzSnCaGvY9kFP0YKBWZtJaraonFnqef5SlJ8/TiPAxzyS+UoA==
-
-"@esbuild/netbsd-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.3.tgz#028ad1807a8e03e155153b2d025b506c3787354b"
-  integrity sha512-P14lFKJl/DdaE00LItAukUdZO5iqNH7+PjoBm+fLQjtxfcfFE20Xf5CrLsmZdq5LFFZzb5JMZ9grUwvtVYzjiA==
-
-"@esbuild/openbsd-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.3.tgz#e3c16ff3490c9b59b969fffca87f350ffc0e2af5"
-  integrity sha512-AIcMP77AvirGbRl/UZFTq5hjXK+2wC7qFRGoHSDrZ5v5b8DK/GYpXW3CPRL53NkvDqb9D+alBiC/dV0Fb7eJcw==
-
-"@esbuild/openbsd-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.3.tgz#c5a4693fcb03d1cbecbf8b422422468dfc0d2a8b"
-  integrity sha512-DnW2sRrBzA+YnE70LKqnM3P+z8vehfJWHXECbwBmH/CU51z6FiqTQTHFenPlHmo3a8UgpLyH3PT+87OViOh1AQ==
-
-"@esbuild/openharmony-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.3.tgz#082082444f12db564a0775a41e1991c0e125055e"
-  integrity sha512-NinAEgr/etERPTsZJ7aEZQvvg/A6IsZG/LgZy+81wON2huV7SrK3e63dU0XhyZP4RKGyTm7aOgmQk0bGp0fy2g==
-
-"@esbuild/sunos-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.3.tgz#5ab036c53f929e8405c4e96e865a424160a1b537"
-  integrity sha512-PanZ+nEz+eWoBJ8/f8HKxTTD172SKwdXebZ0ndd953gt1HRBbhMsaNqjTyYLGLPdoWHy4zLU7bDVJztF5f3BHA==
-
-"@esbuild/win32-arm64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.3.tgz#38de700ef4b960a0045370c171794526e589862e"
-  integrity sha512-B2t59lWWYrbRDw/tjiWOuzSsFh1Y/E95ofKz7rIVYSQkUYBjfSgf6oeYPNWHToFRr2zx52JKApIcAS/D5TUBnA==
-
-"@esbuild/win32-ia32@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.3.tgz#451b93dc03ec5d4f38619e6cd64d9f9eff06f55c"
-  integrity sha512-QLKSFeXNS8+tHW7tZpMtjlNb7HKau0QDpwm49u0vUp9y1WOF+PEzkU84y9GqYaAVW8aH8f3GcBck26jh54cX4Q==
-
-"@esbuild/win32-x64@0.27.3":
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.3.tgz#0eaf705c941a218a43dba8e09f1df1d6cd2f1f17"
-  integrity sha512-4uJGhsxuptu3OcpVAzli+/gWusVGwZZHTlS63hh++ehExkVT8SgiEf7/uC/PclrPPkLhZqGgCTjd0VWLo6xMqA==
+"@esbuild/aix-ppc64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.4.tgz#4c585002f7ad694d38fe0e8cbf5cfd939ccff327"
+  integrity sha512-cQPwL2mp2nSmHHJlCyoXgHGhbEPMrEEU5xhkcy3Hs/O7nGZqEpZ2sUtLaL9MORLtDfRvVl2/3PAuEkYZH0Ty8Q==
+
+"@esbuild/android-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.4.tgz#7625d0952c3b402d3ede203a16c9f2b78f8a4827"
+  integrity sha512-gdLscB7v75wRfu7QSm/zg6Rx29VLdy9eTr2t44sfTW7CxwAtQghZ4ZnqHk3/ogz7xao0QAgrkradbBzcqFPasw==
+
+"@esbuild/android-arm@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.4.tgz#9a0cf1d12997ec46dddfb32ce67e9bca842381ac"
+  integrity sha512-X9bUgvxiC8CHAGKYufLIHGXPJWnr0OCdR0anD2e21vdvgCI8lIfqFbnoeOz7lBjdrAGUhqLZLcQo6MLhTO2DKQ==
+
+"@esbuild/android-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.4.tgz#06e1fdc6283fccd6bc6aadd6754afce6cf96f42e"
+  integrity sha512-PzPFnBNVF292sfpfhiyiXCGSn9HZg5BcAz+ivBuSsl6Rk4ga1oEXAamhOXRFyMcjwr2DVtm40G65N3GLeH1Lvw==
+
+"@esbuild/darwin-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.27.4.tgz#6c550ee6c0273bcb0fac244478ff727c26755d80"
+  integrity sha512-b7xaGIwdJlht8ZFCvMkpDN6uiSmnxxK56N2GDTMYPr2/gzvfdQN8rTfBsvVKmIVY/X7EM+/hJKEIbbHs9oA4tQ==
+
+"@esbuild/darwin-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.4.tgz#ed7a125e9f25ce0091b9aff783ee943f6ba6cb86"
+  integrity sha512-sR+OiKLwd15nmCdqpXMnuJ9W2kpy0KigzqScqHI3Hqwr7IXxBp3Yva+yJwoqh7rE8V77tdoheRYataNKL4QrPw==
+
+"@esbuild/freebsd-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.4.tgz#597dc8e7161dba71db4c1656131c1f1e9d7660c6"
+  integrity sha512-jnfpKe+p79tCnm4GVav68A7tUFeKQwQyLgESwEAUzyxk/TJr4QdGog9sqWNcUbr/bZt/O/HXouspuQDd9JxFSw==
+
+"@esbuild/freebsd-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.4.tgz#ea171f9f4f00efaa8e9d3fe8baa1b75d757d1b36"
+  integrity sha512-2kb4ceA/CpfUrIcTUl1wrP/9ad9Atrp5J94Lq69w7UwOMolPIGrfLSvAKJp0RTvkPPyn6CIWrNy13kyLikZRZQ==
+
+"@esbuild/linux-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.4.tgz#e52d57f202369386e6dbcb3370a17a0491ab1464"
+  integrity sha512-7nQOttdzVGth1iz57kxg9uCz57dxQLHWxopL6mYuYthohPKEK0vU0C3O21CcBK6KDlkYVcnDXY099HcCDXd9dA==
+
+"@esbuild/linux-arm@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.4.tgz#5e0c0b634908adbce0a02cebeba8b3acac263fb6"
+  integrity sha512-aBYgcIxX/wd5n2ys0yESGeYMGF+pv6g0DhZr3G1ZG4jMfruU9Tl1i2Z+Wnj9/KjGz1lTLCcorqE2viePZqj4Eg==
+
+"@esbuild/linux-ia32@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.4.tgz#5f90f01f131652473ec06b038a14c49683e14ec7"
+  integrity sha512-oPtixtAIzgvzYcKBQM/qZ3R+9TEUd1aNJQu0HhGyqtx6oS7qTpvjheIWBbes4+qu1bNlo2V4cbkISr8q6gRBFA==
+
+"@esbuild/linux-loong64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.4.tgz#63bacffdb99574c9318f9afbd0dd4fff76a837e3"
+  integrity sha512-8mL/vh8qeCoRcFH2nM8wm5uJP+ZcVYGGayMavi8GmRJjuI3g1v6Z7Ni0JJKAJW+m0EtUuARb6Lmp4hMjzCBWzA==
+
+"@esbuild/linux-mips64el@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.4.tgz#c4b6952eca6a8efff67fee3671a3536c8e67b7eb"
+  integrity sha512-1RdrWFFiiLIW7LQq9Q2NES+HiD4NyT8Itj9AUeCl0IVCA459WnPhREKgwrpaIfTOe+/2rdntisegiPWn/r/aAw==
+
+"@esbuild/linux-ppc64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.4.tgz#6dea67d3d98c6986f1b7769e4f1848e5ae47ad58"
+  integrity sha512-tLCwNG47l3sd9lpfyx9LAGEGItCUeRCWeAx6x2Jmbav65nAwoPXfewtAdtbtit/pJFLUWOhpv0FpS6GQAmPrHA==
+
+"@esbuild/linux-riscv64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.4.tgz#9ad2b4c3c0502c6bada9c81997bb56c597853489"
+  integrity sha512-BnASypppbUWyqjd1KIpU4AUBiIhVr6YlHx/cnPgqEkNoVOhHg+YiSVxM1RLfiy4t9cAulbRGTNCKOcqHrEQLIw==
+
+"@esbuild/linux-s390x@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.4.tgz#c43d3cfd073042ca6f5c52bb9bc313ed2066ce28"
+  integrity sha512-+eUqgb/Z7vxVLezG8bVB9SfBie89gMueS+I0xYh2tJdw3vqA/0ImZJ2ROeWwVJN59ihBeZ7Tu92dF/5dy5FttA==
+
+"@esbuild/linux-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.4.tgz#45fa173e0591ac74d80d3cf76704713e14e2a4a6"
+  integrity sha512-S5qOXrKV8BQEzJPVxAwnryi2+Iq5pB40gTEIT69BQONqR7JH1EPIcQ/Uiv9mCnn05jff9umq/5nqzxlqTOg9NA==
+
+"@esbuild/netbsd-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.4.tgz#366b0ef40cdb986fc751cbdad16e8c25fe1ba879"
+  integrity sha512-xHT8X4sb0GS8qTqiwzHqpY00C95DPAq7nAwX35Ie/s+LO9830hrMd3oX0ZMKLvy7vsonee73x0lmcdOVXFzd6Q==
+
+"@esbuild/netbsd-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.4.tgz#e985d49a3668fd2044343071d52e1ae815112b3e"
+  integrity sha512-RugOvOdXfdyi5Tyv40kgQnI0byv66BFgAqjdgtAKqHoZTbTF2QqfQrFwa7cHEORJf6X2ht+l9ABLMP0dnKYsgg==
+
+"@esbuild/openbsd-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.4.tgz#6fb4ab7b73f7e5572ce5ec9cf91c13ff6dd44842"
+  integrity sha512-2MyL3IAaTX+1/qP0O1SwskwcwCoOI4kV2IBX1xYnDDqthmq5ArrW94qSIKCAuRraMgPOmG0RDTA74mzYNQA9ow==
+
+"@esbuild/openbsd-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.4.tgz#641f052040a0d79843d68898f5791638a026d983"
+  integrity sha512-u8fg/jQ5aQDfsnIV6+KwLOf1CmJnfu1ShpwqdwC0uA7ZPwFws55Ngc12vBdeUdnuWoQYx/SOQLGDcdlfXhYmXQ==
+
+"@esbuild/openharmony-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.4.tgz#fc1d33eac9d81ae0a433b3ed1dd6171a20d4e317"
+  integrity sha512-JkTZrl6VbyO8lDQO3yv26nNr2RM2yZzNrNHEsj9bm6dOwwu9OYN28CjzZkH57bh4w0I2F7IodpQvUAEd1mbWXg==
+
+"@esbuild/sunos-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.4.tgz#af2cd5ca842d6d057121f66a192d4f797de28f53"
+  integrity sha512-/gOzgaewZJfeJTlsWhvUEmUG4tWEY2Spp5M20INYRg2ZKl9QPO3QEEgPeRtLjEWSW8FilRNacPOg8R1uaYkA6g==
+
+"@esbuild/win32-arm64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.4.tgz#78ec7e59bb06404583d4c9511e621db31c760de3"
+  integrity sha512-Z9SExBg2y32smoDQdf1HRwHRt6vAHLXcxD2uGgO/v2jK7Y718Ix4ndsbNMU/+1Qiem9OiOdaqitioZwxivhXYg==
+
+"@esbuild/win32-ia32@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.4.tgz#0e616aa488b7ee5d2592ab070ff9ec06a9fddf11"
+  integrity sha512-DAyGLS0Jz5G5iixEbMHi5KdiApqHBWMGzTtMiJ72ZOLhbu/bzxgAe8Ue8CTS3n3HbIUHQz/L51yMdGMeoxXNJw==
+
+"@esbuild/win32-x64@0.27.4":
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.4.tgz#1f7ba71a3d6155d44a6faa8dbe249c62ab3e408c"
+  integrity sha512-+knoa0BDoeXgkNvvV1vvbZX4+hizelrkwmGJBdT17t8FNPwG2lKemmuMZlmaNQ3ws3DKKCxpb4zRZEIp3UxFCg==
 
 "@eslint-community/eslint-utils@^4.8.0":
   version "4.9.0"
@@ -173,12 +173,12 @@
   resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.12.2.tgz#bccdf615bcf7b6e8db830ec0b8d21c9a25de597b"
   integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==
 
-"@eslint/compat@^2.0.2":
-  version "2.0.2"
-  resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.2.tgz#fc1495688664861870f5e7ee56999dc252b6dd52"
-  integrity sha512-pR1DoD0h3HfF675QZx0xsyrsU8q70Z/plx7880NOhS02NuWLgBCOMDL787nUeQ7EWLkxv3bPQJaarjcPQb2Dwg==
+"@eslint/compat@^2.0.3":
+  version "2.0.3"
+  resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-2.0.3.tgz#860bdd23d0df1c71a8d751f0aa1430e05bc056dd"
+  integrity sha512-SjIJhGigp8hmd1YGIBwh7Ovri7Kisl42GYFjrOyHhtfYGGoLW6teYi/5p8W50KSsawUPpuLOSmsq1bD0NGQLBw==
   dependencies:
-    "@eslint/core" "^1.1.0"
+    "@eslint/core" "^1.1.1"
 
 "@eslint/config-array@^0.21.1":
   version "0.21.1"
@@ -203,10 +203,10 @@
   dependencies:
     "@types/json-schema" "^7.0.15"
 
-"@eslint/core@^1.1.0":
-  version "1.1.0"
-  resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d"
-  integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==
+"@eslint/core@^1.1.1":
+  version "1.1.1"
+  resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.1.tgz#450f3d2be2d463ccd51119544092256b4e88df32"
+  integrity sha512-QUPblTtE51/7/Zhfv8BDwO0qkkzQL7P/aWWbqcf4xWLEYn1oKjdO0gglQBB4GAsu7u6wjijbCmzsUTy6mnk6oQ==
   dependencies:
     "@types/json-schema" "^7.0.15"
 
@@ -225,10 +225,10 @@
     minimatch "^3.1.2"
     strip-json-comments "^3.1.1"
 
-"@eslint/eslintrc@^3.3.4":
-  version "3.3.4"
-  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.4.tgz#e402b1920f7c1f5a15342caa432b1348cacbb641"
-  integrity sha512-4h4MVF8pmBsncB60r0wSJiIeUKTSD4m7FmTFThG8RHlsg9ajqckLm9OraguFGZE4vVdpiI1Q4+hFnisopmG6gQ==
+"@eslint/eslintrc@^3.3.5":
+  version "3.3.5"
+  resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-3.3.5.tgz#c131793cfc1a7b96f24a83e0a8bbd4b881558c60"
+  integrity sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==
   dependencies:
     ajv "^6.14.0"
     debug "^4.3.2"
@@ -237,7 +237,7 @@
     ignore "^5.2.0"
     import-fresh "^3.2.1"
     js-yaml "^4.1.1"
-    minimatch "^3.1.3"
+    minimatch "^3.1.5"
     strip-json-comments "^3.1.1"
 
 "@eslint/js@9.39.2", "@eslint/js@^9.39.2":
@@ -950,100 +950,100 @@
   dependencies:
     "@types/estree" "*"
 
-"@typescript-eslint/eslint-plugin@^8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.56.1.tgz#b1ce606d87221daec571e293009675992f0aae76"
-  integrity sha512-Jz9ZztpB37dNC+HU2HI28Bs9QXpzCz+y/twHOwhyrIRdbuVDxSytJNDl6z/aAKlaRIwC7y8wJdkBv7FxYGgi0A==
+"@typescript-eslint/eslint-plugin@^8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.0.tgz#6e4085604ab63f55b3dcc61ce2c16965b2c36374"
+  integrity sha512-qeu4rTHR3/IaFORbD16gmjq9+rEs9fGKdX0kF6BKSfi+gCuG3RCKLlSBYzn/bGsY9Tj7KE/DAQStbp8AHJGHEQ==
   dependencies:
     "@eslint-community/regexpp" "^4.12.2"
-    "@typescript-eslint/scope-manager" "8.56.1"
-    "@typescript-eslint/type-utils" "8.56.1"
-    "@typescript-eslint/utils" "8.56.1"
-    "@typescript-eslint/visitor-keys" "8.56.1"
+    "@typescript-eslint/scope-manager" "8.57.0"
+    "@typescript-eslint/type-utils" "8.57.0"
+    "@typescript-eslint/utils" "8.57.0"
+    "@typescript-eslint/visitor-keys" "8.57.0"
     ignore "^7.0.5"
     natural-compare "^1.4.0"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/parser@^8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.56.1.tgz#21d13b3d456ffb08614c1d68bb9a4f8d9237cdc7"
-  integrity sha512-klQbnPAAiGYFyI02+znpBRLyjL4/BrBd0nyWkdC0s/6xFLkXYQ8OoRrSkqacS1ddVxf/LDyODIKbQ5TgKAf/Fg==
+"@typescript-eslint/parser@^8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.57.0.tgz#444c57a943e8b04f255cda18a94c8e023b46b08c"
+  integrity sha512-XZzOmihLIr8AD1b9hL9ccNMzEMWt/dE2u7NyTY9jJG6YNiNthaD5XtUHVF2uCXZ15ng+z2hT3MVuxnUYhq6k1g==
   dependencies:
-    "@typescript-eslint/scope-manager" "8.56.1"
-    "@typescript-eslint/types" "8.56.1"
-    "@typescript-eslint/typescript-estree" "8.56.1"
-    "@typescript-eslint/visitor-keys" "8.56.1"
+    "@typescript-eslint/scope-manager" "8.57.0"
+    "@typescript-eslint/types" "8.57.0"
+    "@typescript-eslint/typescript-estree" "8.57.0"
+    "@typescript-eslint/visitor-keys" "8.57.0"
     debug "^4.4.3"
 
-"@typescript-eslint/project-service@8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.56.1.tgz#65c8d645f028b927bfc4928593b54e2ecd809244"
-  integrity sha512-TAdqQTzHNNvlVFfR+hu2PDJrURiwKsUvxFn1M0h95BB8ah5jejas08jUWG4dBA68jDMI988IvtfdAI53JzEHOQ==
+"@typescript-eslint/project-service@8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/project-service/-/project-service-8.57.0.tgz#2014ed527bcd0eff8aecb7e44879ae3150604ab3"
+  integrity sha512-pR+dK0BlxCLxtWfaKQWtYr7MhKmzqZxuii+ZjuFlZlIGRZm22HnXFqa2eY+90MUz8/i80YJmzFGDUsi8dMOV5w==
   dependencies:
-    "@typescript-eslint/tsconfig-utils" "^8.56.1"
-    "@typescript-eslint/types" "^8.56.1"
+    "@typescript-eslint/tsconfig-utils" "^8.57.0"
+    "@typescript-eslint/types" "^8.57.0"
     debug "^4.4.3"
 
-"@typescript-eslint/scope-manager@8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.56.1.tgz#254df93b5789a871351335dd23e20bc164060f24"
-  integrity sha512-YAi4VDKcIZp0O4tz/haYKhmIDZFEUPOreKbfdAN3SzUDMcPhJ8QI99xQXqX+HoUVq8cs85eRKnD+rne2UAnj2w==
+"@typescript-eslint/scope-manager@8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.57.0.tgz#7d2a2aeaaef2ae70891b21939fadb4cb0b19f840"
+  integrity sha512-nvExQqAHF01lUM66MskSaZulpPL5pgy5hI5RfrxviLgzZVffB5yYzw27uK/ft8QnKXI2X0LBrHJFr1TaZtAibw==
   dependencies:
-    "@typescript-eslint/types" "8.56.1"
-    "@typescript-eslint/visitor-keys" "8.56.1"
+    "@typescript-eslint/types" "8.57.0"
+    "@typescript-eslint/visitor-keys" "8.57.0"
 
-"@typescript-eslint/tsconfig-utils@8.56.1", "@typescript-eslint/tsconfig-utils@^8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.56.1.tgz#1afa830b0fada5865ddcabdc993b790114a879b7"
-  integrity sha512-qOtCYzKEeyr3aR9f28mPJqBty7+DBqsdd63eO0yyDwc6vgThj2UjWfJIcsFeSucYydqcuudMOprZ+x1SpF3ZuQ==
+"@typescript-eslint/tsconfig-utils@8.57.0", "@typescript-eslint/tsconfig-utils@^8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.57.0.tgz#cf2f2822af3887d25dd325b6bea6c3f60a83a0b4"
+  integrity sha512-LtXRihc5ytjJIQEH+xqjB0+YgsV4/tW35XKX3GTZHpWtcC8SPkT/d4tqdf1cKtesryHm2bgp6l555NYcT2NLvA==
 
-"@typescript-eslint/type-utils@8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.56.1.tgz#7a6c4fabf225d674644931e004302cbbdd2f2e24"
-  integrity sha512-yB/7dxi7MgTtGhZdaHCemf7PuwrHMenHjmzgUW1aJpO+bBU43OycnM3Wn+DdvDO/8zzA9HlhaJ0AUGuvri4oGg==
+"@typescript-eslint/type-utils@8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/type-utils/-/type-utils-8.57.0.tgz#2877af4c2e8f0998b93a07dad1c34ce1bb669448"
+  integrity sha512-yjgh7gmDcJ1+TcEg8x3uWQmn8ifvSupnPfjP21twPKrDP/pTHlEQgmKcitzF/rzPSmv7QjJ90vRpN4U+zoUjwQ==
   dependencies:
-    "@typescript-eslint/types" "8.56.1"
-    "@typescript-eslint/typescript-estree" "8.56.1"
-    "@typescript-eslint/utils" "8.56.1"
+    "@typescript-eslint/types" "8.57.0"
+    "@typescript-eslint/typescript-estree" "8.57.0"
+    "@typescript-eslint/utils" "8.57.0"
     debug "^4.4.3"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/types@8.56.1", "@typescript-eslint/types@^8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.56.1.tgz#975e5942bf54895291337c91b9191f6eb0632ab9"
-  integrity sha512-dbMkdIUkIkchgGDIv7KLUpa0Mda4IYjo4IAMJUZ+3xNoUXxMsk9YtKpTHSChRS85o+H9ftm51gsK1dZReY9CVw==
+"@typescript-eslint/types@8.57.0", "@typescript-eslint/types@^8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.57.0.tgz#4fa5385ffd1cd161fa5b9dce93e0493d491b8dc6"
+  integrity sha512-dTLI8PEXhjUC7B9Kre+u0XznO696BhXcTlOn0/6kf1fHaQW8+VjJAVHJ3eTI14ZapTxdkOmc80HblPQLaEeJdg==
 
-"@typescript-eslint/typescript-estree@8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.56.1.tgz#3b9e57d8129a860c50864c42188f761bdef3eab0"
-  integrity sha512-qzUL1qgalIvKWAf9C1HpvBjif+Vm6rcT5wZd4VoMb9+Km3iS3Cv9DY6dMRMDtPnwRAFyAi7YXJpTIEXLvdfPxg==
+"@typescript-eslint/typescript-estree@8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.57.0.tgz#e0e4a89bfebb207de314826df876e2dabc7dea04"
+  integrity sha512-m7faHcyVg0BT3VdYTlX8GdJEM7COexXxS6KqGopxdtkQRvBanK377QDHr4W/vIPAR+ah9+B/RclSW5ldVniO1Q==
   dependencies:
-    "@typescript-eslint/project-service" "8.56.1"
-    "@typescript-eslint/tsconfig-utils" "8.56.1"
-    "@typescript-eslint/types" "8.56.1"
-    "@typescript-eslint/visitor-keys" "8.56.1"
+    "@typescript-eslint/project-service" "8.57.0"
+    "@typescript-eslint/tsconfig-utils" "8.57.0"
+    "@typescript-eslint/types" "8.57.0"
+    "@typescript-eslint/visitor-keys" "8.57.0"
     debug "^4.4.3"
     minimatch "^10.2.2"
     semver "^7.7.3"
     tinyglobby "^0.2.15"
     ts-api-utils "^2.4.0"
 
-"@typescript-eslint/utils@8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.56.1.tgz#5a86acaf9f1b4c4a85a42effb217f73059f6deb7"
-  integrity sha512-HPAVNIME3tABJ61siYlHzSWCGtOoeP2RTIaHXFMPqjrQKCGB9OgUVdiNgH7TJS2JNIQ5qQ4RsAUDuGaGme/KOA==
+"@typescript-eslint/utils@8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/utils/-/utils-8.57.0.tgz#c7193385b44529b788210d20c94c11de79ad3498"
+  integrity sha512-5iIHvpD3CZe06riAsbNxxreP+MuYgVUsV0n4bwLH//VJmgtt54sQeY2GszntJ4BjYCpMzrfVh2SBnUQTtys2lQ==
   dependencies:
     "@eslint-community/eslint-utils" "^4.9.1"
-    "@typescript-eslint/scope-manager" "8.56.1"
-    "@typescript-eslint/types" "8.56.1"
-    "@typescript-eslint/typescript-estree" "8.56.1"
+    "@typescript-eslint/scope-manager" "8.57.0"
+    "@typescript-eslint/types" "8.57.0"
+    "@typescript-eslint/typescript-estree" "8.57.0"
 
-"@typescript-eslint/visitor-keys@8.56.1":
-  version "8.56.1"
-  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.56.1.tgz#50e03475c33a42d123dc99e63acf1841c0231f87"
-  integrity sha512-KiROIzYdEV85YygXw6BI/Dx4fnBlFQu6Mq4QE4MOH9fFnhohw6wX/OAvDY2/C+ut0I3RSPKenvZJIVYqJNkhEw==
+"@typescript-eslint/visitor-keys@8.57.0":
+  version "8.57.0"
+  resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.57.0.tgz#23aea662279bb66209700854453807a119350f85"
+  integrity sha512-zm6xx8UT/Xy2oSr2ZXD0pZo7Jx2XsCoID2IUh9YSTFRu7z+WdwYTRk6LhUftm1crwqbuoF6I8zAFeCMw0YjwDg==
   dependencies:
-    "@typescript-eslint/types" "8.56.1"
+    "@typescript-eslint/types" "8.57.0"
     eslint-visitor-keys "^5.0.0"
 
 "@unrs/resolver-binding-android-arm-eabi@1.11.1":
@@ -1794,45 +1794,45 @@ es-to-primitive@^1.3.0:
     is-date-object "^1.0.5"
     is-symbol "^1.0.4"
 
-esbuild-sass-plugin@^3.6.0:
-  version "3.6.0"
-  resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-3.6.0.tgz#6e93d0aec87b6ab7bde2e459c5f1ab472088bd41"
-  integrity sha512-lzPJQSEXcnj5amBPPib5lBjsDNPzvdMnX+1Rf7eha9BIpLSM5Ad2pi+Rqg5CAlWMduCgLntS2hLAqG7v1fxWGw==
+esbuild-sass-plugin@^3.7.0:
+  version "3.7.0"
+  resolved "https://registry.yarnpkg.com/esbuild-sass-plugin/-/esbuild-sass-plugin-3.7.0.tgz#58883053252390b4ef9e4b044baf84daec97b698"
+  integrity sha512-vxNSXFx3/0ZFApKo9036ek2iRfsT+yVO99qIYqa+JaDSuJuId2/N4s1TY+xfK+5LRpAMQkfdBVUTxb/1r2bq1A==
   dependencies:
     resolve "^1.22.11"
-    sass "^1.97.2"
+    sass "^1.97.3"
 
-esbuild@^0.27.3:
-  version "0.27.3"
-  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.3.tgz#5859ca8e70a3af956b26895ce4954d7e73bd27a8"
-  integrity sha512-8VwMnyGCONIs6cWue2IdpHxHnAjzxnw2Zr7MkVxB2vjmQ2ivqGFb4LEG3SMnv0Gb2F/G/2yA8zUaiL1gywDCCg==
+esbuild@^0.27.4:
+  version "0.27.4"
+  resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.27.4.tgz#b9591dd7e0ab803a11c9c3b602850403bef22f00"
+  integrity sha512-Rq4vbHnYkK5fws5NF7MYTU68FPRE1ajX7heQ/8QXXWqNgqqJ/GkmmyxIzUnf2Sr/bakf8l54716CcMGHYhMrrQ==
   optionalDependencies:
-    "@esbuild/aix-ppc64" "0.27.3"
-    "@esbuild/android-arm" "0.27.3"
-    "@esbuild/android-arm64" "0.27.3"
-    "@esbuild/android-x64" "0.27.3"
-    "@esbuild/darwin-arm64" "0.27.3"
-    "@esbuild/darwin-x64" "0.27.3"
-    "@esbuild/freebsd-arm64" "0.27.3"
-    "@esbuild/freebsd-x64" "0.27.3"
-    "@esbuild/linux-arm" "0.27.3"
-    "@esbuild/linux-arm64" "0.27.3"
-    "@esbuild/linux-ia32" "0.27.3"
-    "@esbuild/linux-loong64" "0.27.3"
-    "@esbuild/linux-mips64el" "0.27.3"
-    "@esbuild/linux-ppc64" "0.27.3"
-    "@esbuild/linux-riscv64" "0.27.3"
-    "@esbuild/linux-s390x" "0.27.3"
-    "@esbuild/linux-x64" "0.27.3"
-    "@esbuild/netbsd-arm64" "0.27.3"
-    "@esbuild/netbsd-x64" "0.27.3"
-    "@esbuild/openbsd-arm64" "0.27.3"
-    "@esbuild/openbsd-x64" "0.27.3"
-    "@esbuild/openharmony-arm64" "0.27.3"
-    "@esbuild/sunos-x64" "0.27.3"
-    "@esbuild/win32-arm64" "0.27.3"
-    "@esbuild/win32-ia32" "0.27.3"
-    "@esbuild/win32-x64" "0.27.3"
+    "@esbuild/aix-ppc64" "0.27.4"
+    "@esbuild/android-arm" "0.27.4"
+    "@esbuild/android-arm64" "0.27.4"
+    "@esbuild/android-x64" "0.27.4"
+    "@esbuild/darwin-arm64" "0.27.4"
+    "@esbuild/darwin-x64" "0.27.4"
+    "@esbuild/freebsd-arm64" "0.27.4"
+    "@esbuild/freebsd-x64" "0.27.4"
+    "@esbuild/linux-arm" "0.27.4"
+    "@esbuild/linux-arm64" "0.27.4"
+    "@esbuild/linux-ia32" "0.27.4"
+    "@esbuild/linux-loong64" "0.27.4"
+    "@esbuild/linux-mips64el" "0.27.4"
+    "@esbuild/linux-ppc64" "0.27.4"
+    "@esbuild/linux-riscv64" "0.27.4"
+    "@esbuild/linux-s390x" "0.27.4"
+    "@esbuild/linux-x64" "0.27.4"
+    "@esbuild/netbsd-arm64" "0.27.4"
+    "@esbuild/netbsd-x64" "0.27.4"
+    "@esbuild/openbsd-arm64" "0.27.4"
+    "@esbuild/openbsd-x64" "0.27.4"
+    "@esbuild/openharmony-arm64" "0.27.4"
+    "@esbuild/sunos-x64" "0.27.4"
+    "@esbuild/win32-arm64" "0.27.4"
+    "@esbuild/win32-ia32" "0.27.4"
+    "@esbuild/win32-x64" "0.27.4"
 
 escape-string-regexp@^4.0.0:
   version "4.0.0"
@@ -2353,10 +2353,10 @@ ignore@^7.0.5:
   resolved "https://registry.yarnpkg.com/ignore/-/ignore-7.0.5.tgz#4cb5f6cd7d4c7ab0365738c7aea888baa6d7efd9"
   integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==
 
-immutable@^5.0.2:
-  version "5.0.3"
-  resolved "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz"
-  integrity sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==
+immutable@^5.1.5:
+  version "5.1.5"
+  resolved "https://registry.yarnpkg.com/immutable/-/immutable-5.1.5.tgz#93ee4db5c2a9ab42a4a783069f3c5d8847d40165"
+  integrity sha512-t7xcm2siw+hlUM68I+UEOK+z84RzmN59as9DZ7P1l0994DKUWV7UXBMQZVxaoMSRQ+PBZbHCOoBt7a2wxOMt+A==
 
 import-fresh@^3.2.1:
   version "3.3.1"
@@ -2821,7 +2821,7 @@ minimatch@^10.2.2:
   dependencies:
     brace-expansion "^5.0.2"
 
-minimatch@^3.1.2, minimatch@^3.1.3:
+minimatch@^3.1.2, minimatch@^3.1.3, minimatch@^3.1.5:
   version "3.1.5"
   resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-3.1.5.tgz#580c88f8d5445f2bd6aa8f3cadefa0de79fbd69e"
   integrity sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==
@@ -3207,24 +3207,13 @@ safe-regex-test@^1.1.0:
     es-errors "^1.3.0"
     is-regex "^1.2.1"
 
-sass@1.97.3:
-  version "1.97.3"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.3.tgz#9cb59339514fa7e2aec592b9700953ac6e331ab2"
-  integrity sha512-fDz1zJpd5GycprAbu4Q2PV/RprsRtKC/0z82z0JLgdytmcq0+ujJbJ/09bPGDxCLkKY3Np5cRAOcWiVkLXJURg==
-  dependencies:
-    chokidar "^4.0.0"
-    immutable "^5.0.2"
-    source-map-js ">=0.6.2 <2.0.0"
-  optionalDependencies:
-    "@parcel/watcher" "^2.4.1"
-
-sass@^1.97.2:
-  version "1.97.2"
-  resolved "https://registry.yarnpkg.com/sass/-/sass-1.97.2.tgz#e515a319092fd2c3b015228e3094b40198bff0da"
-  integrity sha512-y5LWb0IlbO4e97Zr7c3mlpabcbBtS+ieiZ9iwDooShpFKWXf62zz5pEPdwrLYm+Bxn1fnbwFGzHuCLSA9tBmrw==
+sass@1.98.0, sass@^1.97.3:
+  version "1.98.0"
+  resolved "https://registry.yarnpkg.com/sass/-/sass-1.98.0.tgz#924ce85a3745ccaccd976262fdc1bc0c13aa8e57"
+  integrity sha512-+4N/u9dZ4PrgzGgPlKnaaRQx64RO0JBKs9sDhQ2pLgN6JQZ25uPQZKQYaBJU48Kd5BxgXoJ4e09Dq7nMcOUW3A==
   dependencies:
     chokidar "^4.0.0"
-    immutable "^5.0.2"
+    immutable "^5.1.5"
     source-map-js ">=0.6.2 <2.0.0"
   optionalDependencies:
     "@parcel/watcher" "^2.4.1"

+ 2 - 2
netbox/release.yaml

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

+ 1 - 1
netbox/templates/core/rq_queue_list.html

@@ -28,7 +28,7 @@
     </div>
   </div>
 
-  <div class="card">
+  <div class="card table-responsive">
     {% render_table table %}
   </div>
 {% endblock content %}

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

@@ -86,6 +86,11 @@
               <th scope="row">{% trans "Q-in-Q SVLAN" %}</th>
               <td>{{ object.qinq_svlan|linkify|placeholder }}</td>
             </tr>
+          {% elif object.mode %}
+            <tr>
+              <th scope="row">{% trans "Untagged VLAN" %}</th>
+              <td>{{ object.untagged_vlan|linkify|placeholder }}</td>
+            </tr>
           {% endif %}
           <tr>
             <th scope="row">{% trans "Transmit power (dBm)" %}</th>
@@ -411,7 +416,10 @@
   </div>
   <div class="row mb-3">
     <div class="col col-md-12">
-      {% include 'inc/panel_table.html' with table=vlan_table heading="VLANs" %}
+      <div class="card">
+        <h2 class="card-header">{% trans "VLANs" %}</h2>
+        {% htmx_table 'ipam:vlan_list' interface_id=object.pk %}
+      </div>
     </div>
   </div>
   {% if object.is_lag %}

+ 3 - 1
netbox/templates/extras/script_list.html

@@ -15,7 +15,9 @@
 {% endblock tabs %}
 
 {% block controls %}
-  {% add_button model %}
+  {% if perms.core.add_managedfile and perms.extras.add_scriptmodule %}
+    {% add_button model %}
+  {% endif %}
 {% endblock controls %}
 
 {% block content %}

+ 1 - 0
netbox/templates/ui/attrs/datetime.html

@@ -0,0 +1 @@
+{% load helpers %}{% if spec == 'date' %}{{ value|isodate }}{% else %}{{ value|isodatetime:spec }}{% endif %}

+ 1 - 0
netbox/templates/users/attrs/full_name.html

@@ -0,0 +1 @@
+{% load helpers %}{{ object.get_full_name|placeholder }}

+ 0 - 57
netbox/templates/users/group.html

@@ -1,60 +1,3 @@
 {% extends 'generic/object.html' %}
-{% load i18n %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
-
-{% block title %}{% trans "Group" %} {{ object.name }}{% endblock %}
 
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "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>
-        </table>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Users" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for user in object.users.all %}
-            <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for perm in object.object_permissions.all %}
-            <a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Owner Membership" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for owner in object.owners.all %}
-            <a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 0 - 88
netbox/templates/users/objectpermission.html

@@ -1,93 +1,5 @@
 {% extends 'generic/object.html' %}
 {% load i18n %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
 
 {% block title %}{% trans "Permission" %} {{ object.name }}{% endblock %}
-
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Permission" %}</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 "Enabled" %}</th>
-            <td>{% checkmark object.enabled %}</td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Actions" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "View" %}</th>
-            <td>{% checkmark object.can_view %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Add" %}</th>
-            <td>{% checkmark object.can_add %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Change" %}</th>
-            <td>{% checkmark object.can_change %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Delete" %}</th>
-            <td>{% checkmark object.can_delete %}</td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Constraints" %}</h2>
-        <div class="card-body">
-          {% if object.constraints %}
-            <pre>{{ object.constraints|json }}</pre>
-          {% else %}
-            <span class="text-muted">None</span>
-          {% endif %}
-        </div>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Object Types" %}</h2>
-        <ul class="list-group list-group-flush">
-          {% for user in object.object_types.all %}
-            <li class="list-group-item">{{ user }}</li>
-          {% endfor %}
-        </ul>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Users" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for user in object.users.all %}
-            <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Groups" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for group in object.groups.all %}
-            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 0 - 47
netbox/templates/users/owner.html

@@ -11,50 +11,3 @@
 {% endblock %}
 
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Owner" %}</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 "Group" %}</th>
-            <td>{{ object.group|linkify|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 "Groups" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for group in object.user_groups.all %}
-            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Users" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for user in object.users.all %}
-            <a href="{% url 'users:user' pk=user.pk %}" class="list-group-item list-group-item-action">{{ user }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-    <div class="col-md-6">
-      {% include 'inc/panels/related_objects.html' with filter_name='owner_id' %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 43
netbox/templates/users/ownergroup.html

@@ -1,46 +1,3 @@
 {% extends 'generic/object.html' %}
-{% load i18n %}
-{% load helpers %}
-{% load render_table from django_tables2 %}
 
 {% block subtitle %}{% endblock %}
-
-{% block extra_controls %}
-  {% if perms.users.add_owner %}
-    <a href="{% url 'users:owner_add' %}?group={{ object.pk }}" class="btn btn-primary">
-      <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Owner" %}
-    </a>
-  {% endif %}
-{% endblock extra_controls %}
-
-{% block content %}
-  <div class="row mb-3">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "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>
-        </table>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Members" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for owner in object.members.all %}
-            <a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-{% endblock %}

+ 11 - 0
netbox/templates/users/panels/object_types.html

@@ -0,0 +1,11 @@
+{% load i18n %}
+<div class="card">
+  <h2 class="card-header">{% trans "Object Types" %}</h2>
+  <ul class="list-group list-group-flush">
+    {% for object_type in object.object_types.all %}
+      <li class="list-group-item">{{ object_type }}</li>
+    {% empty %}
+      <li class="list-group-item text-muted">{% trans "None" %}</li>
+    {% endfor %}
+  </ul>
+</div>

+ 0 - 82
netbox/templates/users/user.html

@@ -1,85 +1,3 @@
 {% extends 'generic/object.html' %}
-{% load i18n %}
-
-{% block title %}{% trans "User" %} {{ object.username }}{% endblock %}
 
 {% block subtitle %}{% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "User" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Username" %}</th>
-            <td>{{ object.username }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Full Name" %}</th>
-            <td>{{ object.get_full_name|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Email" %}</th>
-            <td>{{ object.email|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Account Created" %}</th>
-            <td>{{ object.date_joined|isodate }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Last Login" %}</th>
-            <td>{{ object.last_login|isodatetime:"minutes"|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Active" %}</th>
-            <td>{% checkmark object.is_active %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Superuser" %}</th>
-            <td>{% checkmark object.is_superuser %}</td>
-          </tr>
-        </table>
-      </div>
-    </div>
-    <div class="col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Groups" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for group in object.groups.all %}
-            <a href="{% url 'users:group' pk=group.pk %}" class="list-group-item list-group-item-action">{{ group }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Assigned Permissions" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for perm in object.object_permissions.all %}
-            <a href="{% url 'users:objectpermission' pk=perm.pk %}" class="list-group-item list-group-item-action">{{ perm }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Owner Membership" %}</h2>
-        <div class="list-group list-group-flush">
-          {% for owner in object.owners.all %}
-            <a href="{% url 'users:owner' pk=owner.pk %}" class="list-group-item list-group-item-action">{{ owner }}</a>
-          {% empty %}
-            <div class="list-group-item text-muted">{% trans "None" %}</div>
-          {% endfor %}
-        </div>
-      </div>
-    </div>
-  </div>
-  {% if perms.core.view_objectchange %}
-    <div class="row">
-      <div class="col-md-12">
-        {% include 'users/inc/user_activity.html' with user=object table=changelog_table %}
-      </div>
-    </div>
-  {% endif %}
-{% endblock %}

+ 8 - 6
netbox/templates/virtualization/virtualmachine/attrs/ipaddress.html

@@ -1,10 +1,12 @@
 {% load i18n %}
-<a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
-{% if value.nat_inside %}
-  ({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
-{% elif value.nat_outside.exists %}
-  ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
-{% endif %}
+<span>
+  <a href="{{ value.get_absolute_url }}"{% if name %} id="attr_{{ name }}"{% endif %}>{{ value.address.ip }}</a>
+  {% if value.nat_inside %}
+    ({% trans "NAT for" %} <a href="{{ value.nat_inside.get_absolute_url }}">{{ value.nat_inside.address.ip }}</a>)
+  {% elif value.nat_outside.exists %}
+    ({% trans "NAT" %}: {% for nat in value.nat_outside.all %}<a href="{{ nat.get_absolute_url }}">{{ nat.address.ip }}</a>{% if not forloop.last %}, {% endif %}{% endfor %})
+  {% endif %}
+</span>
 <a class="btn btn-sm btn-primary copy-content" data-clipboard-target="#attr_{{ name }}" title="{% trans "Copy to clipboard" %}">
   <i class="mdi mdi-content-copy"></i>
 </a>

+ 15 - 15
netbox/templates/virtualization/virtualmachine/base.html

@@ -16,23 +16,23 @@
 {% endblock %}
 
 {% block extra_controls %}
-
-  <div class="dropdown">
+  {% if perms.virtualization.change_virtualmachine %}
+    <div class="dropdown">
       <button id="add-components" type="button" class="btn btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
-          <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add Components" %}
       </button>
       <ul class="dropdown-menu" aria-labeled-by="add-components">
-          {% if perms.virtualization.add_vminterface %}
-            <li><a class="dropdown-item"  href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
-              {% trans "Interfaces" %}
-            </a></li>
-          {% endif %}
-          {% if perms.virtualization.add_virtualdisk %}
-            <li><a class="dropdown-item"  href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
-              {% trans "Virtual Disks" %}
-            </a></li>
-          {% endif %}
+        {% if perms.virtualization.add_vminterface %}
+          <li><a class="dropdown-item"  href="{% url 'virtualization:vminterface_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_interfaces' pk=object.pk %}">
+            {% trans "Interfaces" %}
+          </a></li>
+        {% endif %}
+        {% if perms.virtualization.add_virtualdisk %}
+          <li><a class="dropdown-item"  href="{% url 'virtualization:virtualdisk_add' %}?virtual_machine={{ object.pk }}&return_url={% url 'virtualization:virtualmachine_disks' pk=object.pk %}">
+            {% trans "Virtual Disks" %}
+          </a></li>
+        {% endif %}
       </ul>
-  </div>
-
+    </div>
+  {% endif %}
 {% endblock %}

+ 3 - 0
netbox/templates/vpn/attrs/preshared_key.html

@@ -0,0 +1,3 @@
+{% load i18n %}
+<span id="secret" class="font-monospace" data-secret="{{ value }}">{{ value }}</span>
+<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>

+ 0 - 62
netbox/templates/vpn/ikepolicy.html

@@ -1,63 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% 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 "IKE Policy" %}</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 "IKE Version" %}</th>
-            <td>{{ object.get_version_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Mode" %}</th>
-            <td>{{ object.get_mode_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Pre-Shared Key" %}</th>
-            <td>
-              <span id="secret" class="font-monospace" data-secret="{{ object.preshared_key }}">{{ object.preshared_key|placeholder }}</span>
-              {% if object.preshared_key %}
-                <button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
-              {% endif %}
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "IPSec Profiles" %}</th>
-            <td>
-              <a href="{% url 'vpn:ipsecprofile_list' %}?ike_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
-            </td>
-          </tr>
-        </table>
-      </div>
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">{% trans "Proposals" %}</h2>
-        {% htmx_table 'vpn:ikeproposal_list' ike_policy_id=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 61
netbox/templates/vpn/ikeproposal.html

@@ -1,62 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% 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 "IKE Proposal" %}</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 "Authentication method" %}</th>
-            <td>{{ object.get_authentication_method_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Encryption algorithm" %}</th>
-            <td>{{ object.get_encryption_algorithm_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Authentication algorithm" %}</th>
-            <td>{{ object.get_authentication_algorithm_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "DH group" %}</th>
-            <td>{{ object.get_group_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "SA lifetime (seconds)" %}</th>
-            <td>{{ object.sa_lifetime|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "IKE Policies" %}</th>
-            <td>
-              <a href="{% url 'vpn:ikepolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ike_policies.count }}</a>
-            </td>
-          </tr>
-        </table>
-      </div>
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/comments.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 - 50
netbox/templates/vpn/ipsecpolicy.html

@@ -1,51 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% 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 "IPSec Policy" %}</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 "PFS group" %}</th>
-            <td>{{ object.get_pfs_group_display|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "IPSec Profiles" %}</th>
-            <td>
-              <a href="{% url 'vpn:ipsecprofile_list' %}?ipsec_policy_id={{ object.pk }}">{{ object.ipsec_profiles.count }}</a>
-            </td>
-          </tr>
-        </table>
-      </div>
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% include 'inc/panels/tags.html' %}
-      {% plugin_right_page object %}
-    </div>
-  </div>
-  <div class="row">
-    <div class="col col-md-12">
-    <div class="col col-md-12">
-      <div class="card">
-        <h2 class="card-header">{% trans "Proposals" %}</h2>
-        {% htmx_table 'vpn:ipsecproposal_list' ipsec_policy_id=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 101
netbox/templates/vpn/ipsecprofile.html

@@ -1,102 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% 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 "IPSec Profile" %}</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 "Mode" %}</th>
-            <td>{{ object.get_mode_display }}</td>
-          </tr>
-        </table>
-      </div>
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/custom_fields.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 "IKE Policy" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.ike_policy|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.ike_policy.description|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Version" %}</th>
-            <td>{{ object.ike_policy.get_version_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Mode" %}</th>
-            <td>{{ object.ike_policy.get_mode_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Proposals" %}</th>
-            <td>
-              <ul class="list-unstyled mb-0">
-                {% for proposal in object.ike_policy.proposals.all %}
-                  <li>
-                    <a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
-                  </li>
-                {% endfor %}
-              </ul>
-            </td>
-          </tr>
-        </table>
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "IPSec Policy" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Name" %}</th>
-            <td>{{ object.ipsec_policy|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.ipsec_policy.description|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Proposals" %}</th>
-            <td>
-              <ul class="list-unstyled mb-0">
-                {% for proposal in object.ipsec_policy.proposals.all %}
-                  <li>
-                    <a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a>
-                  </li>
-                {% endfor %}
-              </ul>
-            </td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "PFS Group" %}</th>
-            <td>{{ object.ipsec_policy.get_pfs_group_display }}</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 - 57
netbox/templates/vpn/ipsecproposal.html

@@ -1,58 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% 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 "IPSec Proposal" %}</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 "Encryption algorithm" %}</th>
-            <td>{{ object.get_encryption_algorithm_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Authentication algorithm" %}</th>
-            <td>{{ object.get_authentication_algorithm_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "SA lifetime (seconds)" %}</th>
-            <td>{{ object.sa_lifetime_seconds|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "SA lifetime (KB)" %}</th>
-            <td>{{ object.sa_lifetime_data|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "IPSec Policies" %}</th>
-            <td>
-              <a href="{% url 'vpn:ipsecpolicy_list' %}?proposal_id={{ object.pk }}">{{ object.ipsec_policies.count }}</a>
-            </td>
-          </tr>
-        </table>
-      </div>
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/comments.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 - 77
netbox/templates/vpn/l2vpn.html

@@ -1,78 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% 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 "L2VPN Attributes" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "Name" %}</th>
-          <td>{{ object.name|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Identifier" %}</th>
-          <td>{{ object.identifier|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Type" %}</th>
-          <td>{{ object.get_type_display }}</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 "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Tenant" %}</th>
-          <td>{{ object.tenant|linkify|placeholder }}</td>
-        </tr>
-      </table>
-    </div>
-    {% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpn_list' %}
-    {% plugin_left_page object %}
-	</div>
-	<div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% plugin_right_page object %}
-    </div>
-</div>
-<div class="row mb-3">
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panel_table.html' with table=import_targets_table heading="Import Route Targets" %}
-  </div>
-	<div class="col col-12 col-md-6">
-    {% include 'inc/panel_table.html' with table=export_targets_table heading="Export Route Targets" %}
-  </div>
-</div>
-<div class="row mb-3">
-	<div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">
-        {% trans "Terminations" %}
-        {% if perms.vpn.add_l2vpntermination %}
-          <div class="card-actions">
-            <a href="{% url 'vpn:l2vpntermination_add' %}?l2vpn={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-ghost-primary btn-sm{% if not object.can_add_termination %} disabled" aria-disabled="true{% endif %}">
-              <i class="mdi mdi-plus-thick" aria-hidden="true"></i> {% trans "Add a Termination" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'vpn:l2vpntermination_list' l2vpn_id=object.pk %}
-    </div>
-  </div>
-</div>
-<div class="row mb-3">
-  <div class="col col-md-12">
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 27
netbox/templates/vpn/l2vpntermination.html

@@ -1,28 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load i18n %}
-
-{% block content %}
-<div class="row">
-	<div class="col col-12 col-md-6">
-        <div class="card">
-            <h2 class="card-header">{% trans "L2VPN Attributes" %}</h2>
-            <table class="table table-hover">
-                <tr>
-                    <th scope="row">{% trans "L2VPN" %}</th>
-                    <td>{{ object.l2vpn|linkify }}</td>
-                </tr>
-                <tr>
-                    <th scope="row">{% trans "Assigned Object" %}</th>
-                    <td>{{ object.assigned_object|linkify }}</td>
-                </tr>
-            </table>
-        </div>
-	</div>
-	<div class="col col-12 col-md-6">
-        {% include 'inc/panels/custom_fields.html' %}
-        {% include 'inc/panels/tags.html' with tags=object.tags.all url='vpn:l2vpntermination_list' %}
-    </div>
-</div>
-
-{% endblock %}

+ 34 - 0
netbox/templates/vpn/panels/ipsecprofile_ike_policy.html

@@ -0,0 +1,34 @@
+{% load helpers %}
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header">{% trans "IKE Policy" %}</h2>
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row">{% trans "Name" %}</th>
+      <td>{{ object.ike_policy|linkify }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Description" %}</th>
+      <td>{{ object.ike_policy.description|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Version" %}</th>
+      <td>{{ object.ike_policy.get_version_display }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Mode" %}</th>
+      <td>{{ object.ike_policy.get_mode_display }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Proposals" %}</th>
+      <td>
+        <ul class="list-unstyled mb-0">
+          {% for proposal in object.ike_policy.proposals.all %}
+            <li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
+          {% endfor %}
+        </ul>
+      </td>
+    </tr>
+  </table>
+</div>

+ 30 - 0
netbox/templates/vpn/panels/ipsecprofile_ipsec_policy.html

@@ -0,0 +1,30 @@
+{% load helpers %}
+{% load i18n %}
+
+<div class="card">
+  <h2 class="card-header">{% trans "IPSec Policy" %}</h2>
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row">{% trans "Name" %}</th>
+      <td>{{ object.ipsec_policy|linkify }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Description" %}</th>
+      <td>{{ object.ipsec_policy.description|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Proposals" %}</th>
+      <td>
+        <ul class="list-unstyled mb-0">
+          {% for proposal in object.ipsec_policy.proposals.all %}
+            <li><a href="{{ proposal.get_absolute_url }}">{{ proposal }}</a></li>
+          {% endfor %}
+        </ul>
+      </td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "PFS Group" %}</th>
+      <td>{{ object.ipsec_policy.get_pfs_group_display }}</td>
+    </tr>
+  </table>
+</div>

+ 0 - 76
netbox/templates/vpn/tunnel.html

@@ -1,6 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
 {% load i18n %}
 
 {% block extra_controls %}
@@ -10,77 +8,3 @@
     </a>
   {% endif %}
 {% endblock %}
-
-{% block content %}
-  <div class="row">
-    <div class="col col-12 col-md-6">
-      <div class="card">
-        <h2 class="card-header">{% trans "Tunnel" %}</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 "Status" %}</th>
-            <td>{% badge object.get_status_display bg_color=object.get_status_color %}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Group" %}</th>
-            <td>{{ object.group|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Encapsulation" %}</th>
-            <td>{{ object.get_encapsulation_display }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "IPSec profile" %}</th>
-            <td>{{ object.ipsec_profile|linkify|placeholder }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Tunnel ID" %}</th>
-            <td>{{ object.tunnel_id|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>
-      {% plugin_left_page object %}
-    </div>
-    <div class="col col-12 col-md-6">
-      {% include 'inc/panels/custom_fields.html' %}
-      {% include 'inc/panels/tags.html' %}
-      {% include 'inc/panels/comments.html' %}
-      {% 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.vpn.add_tunneltermination %}
-            <div class="card-actions">
-              <a href="{% url 'vpn:tunneltermination_add' %}?tunnel={{ 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 Termination" %}
-              </a>
-            </div>
-          {% endif %}
-        </h2>
-        {% htmx_table 'vpn:tunneltermination_list' tunnel_id=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 0 - 40
netbox/templates/vpn/tunnelgroup.html

@@ -1,13 +1,6 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
-{% block breadcrumbs %}
-  <li class="breadcrumb-item"><a href="{% url 'vpn:tunnelgroup_list' %}">{% trans "Tunnel Groups" %}</a></li>
-{% endblock %}
-
 {% block extra_controls %}
   {% if perms.vpn.add_tunnel %}
     <a href="{% url 'vpn:tunnel_add' %}?group={{ object.pk }}" class="btn btn-primary">
@@ -15,36 +8,3 @@
     </a>
   {% endif %}
 {% 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 "Tunnel 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>
-        </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 - 56
netbox/templates/vpn/tunneltermination.html

@@ -1,57 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% 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 "Tunnel Termination" %}</h2>
-        <table class="table table-hover attr-table">
-          <tr>
-            <th scope="row">{% trans "Tunnel" %}</th>
-            <td>{{ object.tunnel|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Role" %}</th>
-            <td>{% badge object.get_role_display bg_color=object.get_role_color %}</td>
-          </tr>
-          <tr>
-            <th scope="row">
-              {% if object.termination.device %}
-                {% trans "Device" %}
-              {% elif object.termination.virtual_machine %}
-                {% trans "Virtual Machine" %}
-              {% endif %}
-            </th>
-            <td>{{ object.termination.parent_object|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Interface" %}</th>
-            <td>{{ object.termination|linkify }}</td>
-          </tr>
-          <tr>
-            <th scope="row">{% trans "Outside IP" %}</th>
-            <td>{{ object.outside_ip|linkify|placeholder }}</td>
-          </tr>
-        </table>
-      </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">
-      <div class="card">
-        <h2 class="card-header">{% trans "Peer Terminations" %}</h2>
-        {% htmx_table 'vpn:tunneltermination_list' tunnel_id=object.tunnel.pk id__n=object.pk %}
-      </div>
-      {% plugin_full_width_page object %}
-    </div>
-  </div>
-{% endblock %}

+ 3 - 0
netbox/templates/wireless/attrs/auth_psk.html

@@ -0,0 +1,3 @@
+{% load i18n %}
+<span id="secret" class="font-monospace" data-secret="{{ value }}">{{ value }}</span>
+<button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>

+ 0 - 25
netbox/templates/wireless/inc/authentication_attrs.html

@@ -1,25 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-<div class="card">
-  <h2 class="card-header">{% trans "Authentication" %}</h2>
-  <table class="table table-hover attr-table">
-    <tr>
-      <th scope="row">{% trans "Type" %}</th>
-      <td>{{ object.get_auth_type_display|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "Cipher" %}</th>
-      <td>{{ object.get_auth_cipher_display|placeholder }}</td>
-    </tr>
-    <tr>
-      <th scope="row">{% trans "PSK" %}</th>
-      <td>
-        <span id="secret" class="font-monospace" data-secret="{{ object.auth_psk }}">{{ object.auth_psk|placeholder }}</span>
-        {% if object.auth_psk %}
-        <button type="button" class="btn btn-primary toggle-secret float-end" data-bs-toggle="button">{% trans "Show Secret" %}</button>
-        {% endif %}
-      </td>
-    </tr>
-  </table>
-</div>

+ 0 - 51
netbox/templates/wireless/inc/wirelesslink_interface.html

@@ -1,51 +0,0 @@
-{% load helpers %}
-{% load i18n %}
-
-<table class="table table-hover attr-table">
-  <tr>
-    <th scope="row">{% trans "Device" %}</th>
-    <td>{{ interface.device|linkify }}</td>
-  </tr>
-  <tr>
-    <th scope="row">{% trans "Interface" %}</th>
-    <td>{{ interface|linkify }}</td>
-  </tr>
-  <tr>
-    <th scope="row">{% trans "Type" %}</th>
-    <td>
-      {{ interface.get_type_display }}
-    </td>
-  </tr>
-  <tr>
-    <th scope="row">{% trans "Role" %}</th>
-    <td>
-      {{ interface.get_rf_role_display|placeholder }}
-    </td>
-  </tr>
-  <tr>
-    <th scope="row">{% trans "Channel" %}</th>
-    <td>
-      {{ interface.get_rf_channel_display|placeholder }}
-    </td>
-  </tr>
-  <tr>
-      <th scope="row">{% trans "Channel Frequency" %}</th>
-      <td>
-        {% if interface.rf_channel_frequency %}
-          {{ interface.rf_channel_frequency|floatformat:"-2" }} {% trans "MHz" context "Abbreviation for megahertz" %}
-        {% else %}
-          {{ ''|placeholder }}
-        {% endif %}
-      </td>
-  </tr>
-  <tr>
-      <th scope="row">{% trans "Channel Width" %}</th>
-      <td>
-        {% if interface.rf_channel_width %}
-          {{ interface.rf_channel_width|floatformat:"-3" }} {% trans "MHz" context "Abbreviation for megahertz" %}
-        {% else %}
-          {{ ''|placeholder }}
-        {% endif %}
-      </td>
-  </tr>
-</table>

+ 48 - 0
netbox/templates/wireless/panels/wirelesslink_interface.html

@@ -0,0 +1,48 @@
+{% extends "ui/panels/_base.html" %}
+{% load helpers %}
+{% load i18n %}
+
+{% block panel_content %}
+  <table class="table table-hover attr-table">
+    <tr>
+      <th scope="row">{% trans "Device" %}</th>
+      <td>{{ interface.device|linkify }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Interface" %}</th>
+      <td>{{ interface|linkify }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Type" %}</th>
+      <td>{{ interface.get_type_display }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Role" %}</th>
+      <td>{{ interface.get_rf_role_display|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Channel" %}</th>
+      <td>{{ interface.get_rf_channel_display|placeholder }}</td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Channel Frequency" %}</th>
+      <td>
+        {% if interface.rf_channel_frequency %}
+          {{ interface.rf_channel_frequency|floatformat:"-2" }} {% trans "MHz" context "Abbreviation for megahertz" %}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+    <tr>
+      <th scope="row">{% trans "Channel Width" %}</th>
+      <td>
+        {% if interface.rf_channel_width %}
+          {{ interface.rf_channel_width|floatformat:"-3" }} {% trans "MHz" context "Abbreviation for megahertz" %}
+        {% else %}
+          {{ ''|placeholder }}
+        {% endif %}
+      </td>
+    </tr>
+  </table>
+{% endblock panel_content %}

+ 0 - 73
netbox/templates/wireless/wirelesslan.html

@@ -1,74 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
-{% load i18n %}
-
-{% block content %}
-<div class="row">
-	<div class="col col-12 col-md-6">
-    <div class="card">
-      <h2 class="card-header">{% trans "Wireless LAN" %}</h2>
-      <table class="table table-hover attr-table">
-        <tr>
-          <th scope="row">{% trans "SSID" %}</th>
-          <td>{{ object.ssid }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Group" %}</th>
-          <td>{{ object.group|linkify|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 "Scope" %}</th>
-          {% if object.scope %}
-            <td>{{ object.scope|linkify }} ({% trans object.scope_type.name %})</td>
-          {% else %}
-            <td>{{ ''|placeholder }}</td>
-          {% endif %}
-        </tr>
-        <tr>
-          <th scope="row">{% trans "Description" %}</th>
-          <td>{{ object.description|placeholder }}</td>
-        </tr>
-        <tr>
-          <th scope="row">{% trans "VLAN" %}</th>
-          <td>{{ object.vlan|linkify|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' %}
-    {% include 'inc/panels/comments.html' %}
-    {% plugin_left_page object %}
-  </div>
-  <div class="col col-12 col-md-6">
-    {% include 'wireless/inc/authentication_attrs.html' %}
-    {% include 'inc/panels/custom_fields.html' %}
-    {% plugin_right_page object %}
-	</div>
-</div>
-<div class="row">
-  <div class="col col-md-12">
-    <div class="card">
-      <h2 class="card-header">{% trans "Attached Interfaces" %}</h2>
-      <div class="card-body table-responsive">
-        {% render_table interfaces_table 'inc/table.html' %}
-        {% include 'inc/paginator.html' with paginator=interfaces_table.paginator page=interfaces_table.page %}
-      </div>
-    </div>
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 53
netbox/templates/wireless/wirelesslangroup.html

@@ -1,7 +1,4 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% load plugins %}
-{% load render_table from django_tables2 %}
 {% load i18n %}
 
 {% block breadcrumbs %}
@@ -18,53 +15,3 @@
     </a>
   {% endif %}
 {% 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 "Wireless LAN 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 "Parent" %}</th>
-          <td>{{ object.parent|linkify|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 "Child Groups" %}
-        {% if perms.wireless.add_wirelesslangroup %}
-          <div class="card-actions">
-            <a href="{% url 'wireless:wirelesslangroup_add' %}?parent={{ object.pk }}" class="btn btn-ghost-primary btn-sm">
-              <span class="mdi mdi-plus-thick" aria-hidden="true"></span> {% trans "Add Wireless LAN Group" %}
-            </a>
-          </div>
-        {% endif %}
-      </h2>
-      {% htmx_table 'wireless:wirelesslangroup_list' parent_id=object.pk %}
-    </div>
-    {% plugin_full_width_page object %}
-  </div>
-</div>
-{% endblock %}

+ 0 - 67
netbox/templates/wireless/wirelesslink.html

@@ -1,68 +1 @@
 {% extends 'generic/object.html' %}
-{% load helpers %}
-{% 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 "Interface" %} A</h2>
-        {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_a %}
-      </div>
-      <div class="card">
-        <h2 class="card-header">{% trans "Link Properties" %}</h2>
-        <table class="table table-hover attr-table">
-          <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 "SSID" %}</th>
-            <td>{{ object.ssid|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>
-          <tr>
-            <th scope="row">{% trans "Description" %}</th>
-            <td>{{ object.description|placeholder }}</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>
-        </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 "Interface" %} B</h2>
-        {% include 'wireless/inc/wirelesslink_interface.html' with interface=object.interface_b %}
-      </div>
-      {% include 'wireless/inc/authentication_attrs.html' %}
-      {% 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 %}

BIN
netbox/translations/cs/LC_MESSAGES/django.mo


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 219 - 284
netbox/translations/cs/LC_MESSAGES/django.po


BIN
netbox/translations/da/LC_MESSAGES/django.mo


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 219 - 284
netbox/translations/da/LC_MESSAGES/django.po


BIN
netbox/translations/de/LC_MESSAGES/django.mo


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 218 - 283
netbox/translations/de/LC_MESSAGES/django.po


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 217 - 273
netbox/translations/en/LC_MESSAGES/django.po


BIN
netbox/translations/es/LC_MESSAGES/django.mo


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 219 - 284
netbox/translations/es/LC_MESSAGES/django.po


BIN
netbox/translations/fr/LC_MESSAGES/django.mo


تفاوت فایلی نمایش داده نمی شود زیرا این فایل بسیار بزرگ است
+ 219 - 284
netbox/translations/fr/LC_MESSAGES/django.po


برخی فایل ها در این مقایسه diff نمایش داده نمی شوند زیرا تعداد فایل ها بسیار زیاد است