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

Merge pull request #10937 from netbox-community/develop

Release v3.3.8
Jeremy Stretch 3 лет назад
Родитель
Сommit
bfda5d9011

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

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

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

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

+ 1 - 1
docs/installation/3-netbox.md

@@ -36,7 +36,7 @@ This documentation provides two options for installing NetBox: from a downloadab
 Download the [latest stable release](https://github.com/netbox-community/netbox/releases) from GitHub as a tarball or ZIP archive and extract it to your desired path. In this example, we'll use `/opt/netbox` as the NetBox root.
 
 ```no-highlight
-sudo wget https://github.com/netbox-community/netbox/archive/vX.Y.Z.tar.gz
+sudo wget https://github.com/netbox-community/netbox/archive/refs/tags/vX.Y.Z.tar.gz
 sudo tar -xzf vX.Y.Z.tar.gz -C /opt
 sudo ln -s /opt/netbox-X.Y.Z/ /opt/netbox
 ```

+ 26 - 0
docs/release-notes/version-3.3.md

@@ -1,5 +1,31 @@
 # NetBox v3.3
 
+## v3.3.8 (2022-11-16)
+
+### Enhancements
+
+* [#10356](https://github.com/netbox-community/netbox/issues/10356) - Add backplane Ethernet interface types
+* [#10902](https://github.com/netbox-community/netbox/issues/10902) - Add location selector to power feed form
+* [#10904](https://github.com/netbox-community/netbox/issues/10904) - Use front/rear port colors in cable trace SVG
+* [#10914](https://github.com/netbox-community/netbox/issues/10914) - Include "add module type" button on manufacturer view
+* [#10915](https://github.com/netbox-community/netbox/issues/10915) - Add count of L2VPNs to tenant view
+* [#10919](https://github.com/netbox-community/netbox/issues/10919) - Include device location under cable view
+* [#10920](https://github.com/netbox-community/netbox/issues/10920) - Include request cookies when queuing a custom script
+
+### Bug Fixes
+
+* [#9439](https://github.com/netbox-community/netbox/issues/9439) - Ensure thread safety of change logging functions
+* [#10709](https://github.com/netbox-community/netbox/issues/10709) - Correct UI display for `azuread-v2-tenant-oauth2` SSO backend
+* [#10829](https://github.com/netbox-community/netbox/issues/10829) - Fix bulk edit/delete buttons ad top of object lists
+* [#10837](https://github.com/netbox-community/netbox/issues/10837) - Correct cookie paths when `BASE_PATH` is set
+* [#10874](https://github.com/netbox-community/netbox/issues/10874) - Remove erroneous link for contact assignment count
+* [#10881](https://github.com/netbox-community/netbox/issues/10881) - Fix dark mode coloring for data on device status page
+* [#10891](https://github.com/netbox-community/netbox/issues/10891) - Populate tag selection list for service filter form
+* [#10897](https://github.com/netbox-community/netbox/issues/10897) - Fix form widget styling on FHRP group form
+* [#10910](https://github.com/netbox-community/netbox/issues/10910) - Fix cable creation links on power port view
+
+---
+
 ## v3.3.7 (2022-11-01)
 
 ### Bug Fixes

+ 25 - 0
netbox/dcim/choices.py

@@ -783,6 +783,17 @@ class InterfaceTypeChoices(ChoiceSet):
     TYPE_400GE_QSFP_DD = '400gbase-x-qsfpdd'
     TYPE_400GE_OSFP = '400gbase-x-osfp'
 
+    # Ethernet Backplane
+    TYPE_1GE_KX = '1000base-kx'
+    TYPE_10GE_KR = '10gbase-kr'
+    TYPE_10GE_KX4 = '10gbase-kx4'
+    TYPE_25GE_KR = '25gbase-kr'
+    TYPE_40GE_KR4 = '40gbase-kr4'
+    TYPE_50GE_KR = '50gbase-kr'
+    TYPE_100GE_KP4 = '100gbase-kp4'
+    TYPE_100GE_KR2 = '100gbase-kr2'
+    TYPE_100GE_KR4 = '100gbase-kr4'
+
     # Wireless
     TYPE_80211A = 'ieee802.11a'
     TYPE_80211G = 'ieee802.11g'
@@ -911,6 +922,20 @@ class InterfaceTypeChoices(ChoiceSet):
                 (TYPE_400GE_OSFP, 'OSFP (400GE)'),
             )
         ),
+        (
+            'Ethernet (backplane)',
+            (
+                (TYPE_1GE_KX, '1000BASE-KX (1GE)'),
+                (TYPE_10GE_KR, '10GBASE-KR (10GE)'),
+                (TYPE_10GE_KX4, '10GBASE-KX4 (10GE)'),
+                (TYPE_25GE_KR, '25GBASE-KR (25GE)'),
+                (TYPE_40GE_KR4, '40GBASE-KR4 (40GE)'),
+                (TYPE_50GE_KR, '50GBASE-KR (50GE)'),
+                (TYPE_100GE_KP4, '100GBASE-KP4 (100GE)'),
+                (TYPE_100GE_KR2, '100GBASE-KR2 (100GE)'),
+                (TYPE_100GE_KR4, '100GBASE-KR4 (100GE)'),
+            )
+        ),
         (
             'Wireless',
             (

+ 13 - 2
netbox/dcim/forms/models.py

@@ -877,10 +877,21 @@ class PowerFeedForm(NetBoxModelForm):
             'site_id': '$site'
         }
     )
+    location = DynamicModelChoiceField(
+        queryset=Location.objects.all(),
+        required=False,
+        query_params={
+            'site_id': '$site'
+        },
+        initial_params={
+            'racks': '$rack'
+        }
+    )
     rack = DynamicModelChoiceField(
         queryset=Rack.objects.all(),
         required=False,
         query_params={
+            'location_id': '$location',
             'site_id': '$site'
         }
     )
@@ -888,14 +899,14 @@ class PowerFeedForm(NetBoxModelForm):
 
     fieldsets = (
         ('Power Panel', ('region', 'site', 'power_panel')),
-        ('Power Feed', ('rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
+        ('Power Feed', ('location', 'rack', 'name', 'status', 'type', 'mark_connected', 'tags')),
         ('Characteristics', ('supply', 'voltage', 'amperage', 'phase', 'max_utilization')),
     )
 
     class Meta:
         model = PowerFeed
         fields = [
-            'region', 'site_group', 'site', 'power_panel', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
+            'region', 'site_group', 'site', 'power_panel', 'location', 'rack', 'name', 'status', 'type', 'mark_connected', 'supply',
             'phase', 'voltage', 'amperage', 'max_utilization', 'comments', 'tags',
         ]
         widgets = {

+ 1 - 1
netbox/dcim/svg/cables.py

@@ -166,7 +166,7 @@ class CableTraceSVG:
         """
         if hasattr(instance, 'parent_object'):
             # Termination
-            return 'f0f0f0'
+            return getattr(instance, 'color', 'f0f0f0') or 'f0f0f0'
         if hasattr(instance, 'device_role'):
             # Device
             return instance.device_role.color

+ 7 - 24
netbox/extras/context_managers.py

@@ -1,10 +1,6 @@
 from contextlib import contextmanager
 
-from django.db.models.signals import m2m_changed, pre_delete, post_save
-
-from extras.signals import clear_webhooks, clear_webhook_queue, handle_changed_object, handle_deleted_object
-from netbox import thread_locals
-from netbox.request_context import set_request
+from netbox.context import current_request, webhooks_queue
 from .webhooks import flush_webhooks
 
 
@@ -16,27 +12,14 @@ def change_logging(request):
 
     :param request: WSGIRequest object with a unique `id` set
     """
-    set_request(request)
-    thread_locals.webhook_queue = []
-
-    # Connect our receivers to the post_save and post_delete signals.
-    post_save.connect(handle_changed_object, dispatch_uid='handle_changed_object')
-    m2m_changed.connect(handle_changed_object, dispatch_uid='handle_changed_object')
-    pre_delete.connect(handle_deleted_object, dispatch_uid='handle_deleted_object')
-    clear_webhooks.connect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
+    current_request.set(request)
+    webhooks_queue.set([])
 
     yield
 
-    # Disconnect change logging signals. This is necessary to avoid recording any errant
-    # changes during test cleanup.
-    post_save.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
-    m2m_changed.disconnect(handle_changed_object, dispatch_uid='handle_changed_object')
-    pre_delete.disconnect(handle_deleted_object, dispatch_uid='handle_deleted_object')
-    clear_webhooks.disconnect(clear_webhook_queue, dispatch_uid='clear_webhook_queue')
-
     # Flush queued webhooks to RQ
-    flush_webhooks(thread_locals.webhook_queue)
-    del thread_locals.webhook_queue
+    flush_webhooks(webhooks_queue.get())
 
-    # Clear the request from thread-local storage
-    set_request(None)
+    # Clear context vars
+    current_request.set(None)
+    webhooks_queue.set([])

+ 38 - 23
netbox/extras/signals.py

@@ -7,14 +7,14 @@ from django.dispatch import receiver, Signal
 from django_prometheus.models import model_deletes, model_inserts, model_updates
 
 from extras.validators import CustomValidator
-from netbox import thread_locals
 from netbox.config import get_config
-from netbox.request_context import get_request
+from netbox.context import current_request, webhooks_queue
 from netbox.signals import post_clean
 from .choices import ObjectChangeActionChoices
 from .models import ConfigRevision, CustomField, ObjectChange
 from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
 
+
 #
 # Change logging/webhooks
 #
@@ -23,22 +23,32 @@ from .webhooks import enqueue_object, get_snapshots, serialize_for_webhook
 clear_webhooks = Signal()
 
 
+def is_same_object(instance, webhook_data, request_id):
+    """
+    Compare the given instance to the most recent queued webhook object, returning True
+    if they match. This check is used to avoid creating duplicate webhook entries.
+    """
+    return (
+        ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
+        instance.pk == webhook_data['object_id'] and
+        request_id == webhook_data['request_id']
+    )
+
+
+@receiver((post_save, m2m_changed))
 def handle_changed_object(sender, instance, **kwargs):
     """
     Fires when an object is created or updated.
     """
+    m2m_changed = False
+
     if not hasattr(instance, 'to_objectchange'):
         return
 
-    request = get_request()
-    m2m_changed = False
-
-    def is_same_object(instance, webhook_data):
-        return (
-            ContentType.objects.get_for_model(instance) == webhook_data['content_type'] and
-            instance.pk == webhook_data['object_id'] and
-            request.id == webhook_data['request_id']
-        )
+    # Get the current request, or bail if not set
+    request = current_request.get()
+    if request is None:
+        return
 
     # Determine the type of change being made
     if kwargs.get('created'):
@@ -69,13 +79,14 @@ def handle_changed_object(sender, instance, **kwargs):
             objectchange.save()
 
     # If this is an M2M change, update the previously queued webhook (from post_save)
-    webhook_queue = thread_locals.webhook_queue
-    if m2m_changed and webhook_queue and is_same_object(instance, webhook_queue[-1]):
+    queue = webhooks_queue.get()
+    if m2m_changed and queue and is_same_object(instance, queue[-1], request.id):
         instance.refresh_from_db()  # Ensure that we're working with fresh M2M assignments
-        webhook_queue[-1]['data'] = serialize_for_webhook(instance)
-        webhook_queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
+        queue[-1]['data'] = serialize_for_webhook(instance)
+        queue[-1]['snapshots']['postchange'] = get_snapshots(instance, action)['postchange']
     else:
-        enqueue_object(webhook_queue, instance, request.user, request.id, action)
+        enqueue_object(queue, instance, request.user, request.id, action)
+    webhooks_queue.set(queue)
 
     # Increment metric counters
     if action == ObjectChangeActionChoices.ACTION_CREATE:
@@ -84,6 +95,7 @@ def handle_changed_object(sender, instance, **kwargs):
         model_updates.labels(instance._meta.model_name).inc()
 
 
+@receiver(pre_delete)
 def handle_deleted_object(sender, instance, **kwargs):
     """
     Fires when an object is deleted.
@@ -91,7 +103,10 @@ def handle_deleted_object(sender, instance, **kwargs):
     if not hasattr(instance, 'to_objectchange'):
         return
 
-    request = get_request()
+    # Get the current request, or bail if not set
+    request = current_request.get()
+    if request is None:
+        return
 
     # Record an ObjectChange if applicable
     if hasattr(instance, 'to_objectchange'):
@@ -101,22 +116,22 @@ def handle_deleted_object(sender, instance, **kwargs):
         objectchange.save()
 
     # Enqueue webhooks
-    webhook_queue = thread_locals.webhook_queue
-    enqueue_object(webhook_queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
+    queue = webhooks_queue.get()
+    enqueue_object(queue, instance, request.user, request.id, ObjectChangeActionChoices.ACTION_DELETE)
+    webhooks_queue.set(queue)
 
     # Increment metric counters
     model_deletes.labels(instance._meta.model_name).inc()
 
 
+@receiver(clear_webhooks)
 def clear_webhook_queue(sender, **kwargs):
     """
     Delete any queued webhooks (e.g. because of an aborted bulk transaction)
     """
     logger = logging.getLogger('webhooks')
-    webhook_queue = thread_locals.webhook_queue
-
-    logger.info(f"Clearing {len(webhook_queue)} queued webhooks ({sender})")
-    webhook_queue.clear()
+    logger.info(f"Clearing {len(webhooks_queue.get())} queued webhooks ({sender})")
+    webhooks_queue.set([])
 
 
 #

+ 1 - 0
netbox/ipam/forms/filtersets.py

@@ -478,6 +478,7 @@ class ServiceTemplateFilterForm(NetBoxModelFilterSetForm):
 
 class ServiceFilterForm(ServiceTemplateFilterForm):
     model = Service
+    tag = TagFilterField(model)
 
 
 class L2VPNFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm):

+ 5 - 0
netbox/ipam/forms/models.py

@@ -549,6 +549,11 @@ class FHRPGroupForm(NetBoxModelForm):
         fields = (
             'protocol', 'group_id', 'auth_type', 'auth_key', 'description', 'ip_vrf', 'ip_address', 'ip_status', 'tags',
         )
+        widgets = {
+            'protocol': StaticSelect(),
+            'auth_type': StaticSelect(),
+            'ip_status': StaticSelect(),
+        }
 
     def save(self, *args, **kwargs):
         instance = super().save(*args, **kwargs)

+ 0 - 3
netbox/netbox/__init__.py

@@ -1,3 +0,0 @@
-import threading
-
-thread_locals = threading.local()

+ 1 - 0
netbox/netbox/authentication.py

@@ -24,6 +24,7 @@ AUTH_BACKEND_ATTRS = {
     'azuread-oauth2': ('Microsoft Azure AD', 'microsoft'),
     'azuread-b2c-oauth2': ('Microsoft Azure AD', 'microsoft'),
     'azuread-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
+    'azuread-v2-tenant-oauth2': ('Microsoft Azure AD', 'microsoft'),
     'bitbucket': ('BitBucket', 'bitbucket'),
     'bitbucket-oauth2': ('BitBucket', 'bitbucket'),
     'digitalocean': ('DigitalOcean', 'digital-ocean'),

+ 10 - 0
netbox/netbox/context.py

@@ -0,0 +1,10 @@
+from contextvars import ContextVar
+
+__all__ = (
+    'current_request',
+    'webhooks_queue',
+)
+
+
+current_request = ContextVar('current_request', default=None)
+webhooks_queue = ContextVar('webhooks_queue')

+ 0 - 9
netbox/netbox/request_context.py

@@ -1,9 +0,0 @@
-from netbox import thread_locals
-
-
-def set_request(request):
-    thread_locals.request = request
-
-
-def get_request():
-    return getattr(thread_locals, 'request', None)

+ 3 - 4
netbox/netbox/settings.py

@@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str
 # Environment setup
 #
 
-VERSION = '3.3.7'
+VERSION = '3.3.8'
 
 # Hostname
 HOSTNAME = platform.node()
@@ -81,11 +81,11 @@ AUTH_PASSWORD_VALIDATORS = getattr(configuration, 'AUTH_PASSWORD_VALIDATORS', []
 BASE_PATH = getattr(configuration, 'BASE_PATH', '')
 if BASE_PATH:
     BASE_PATH = BASE_PATH.strip('/') + '/'  # Enforce trailing slash only
+CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = f'/{BASE_PATH.rstrip("/")}'
 CORS_ORIGIN_ALLOW_ALL = getattr(configuration, 'CORS_ORIGIN_ALLOW_ALL', False)
 CORS_ORIGIN_REGEX_WHITELIST = getattr(configuration, 'CORS_ORIGIN_REGEX_WHITELIST', [])
 CORS_ORIGIN_WHITELIST = getattr(configuration, 'CORS_ORIGIN_WHITELIST', [])
 CSRF_COOKIE_NAME = getattr(configuration, 'CSRF_COOKIE_NAME', 'csrftoken')
-CSRF_COOKIE_PATH = BASE_PATH or '/'
 CSRF_TRUSTED_ORIGINS = getattr(configuration, 'CSRF_TRUSTED_ORIGINS', [])
 DATE_FORMAT = getattr(configuration, 'DATE_FORMAT', 'N j, Y')
 DATETIME_FORMAT = getattr(configuration, 'DATETIME_FORMAT', 'N j, Y g:i a')
@@ -130,8 +130,6 @@ SENTRY_TRACES_SAMPLE_RATE = getattr(configuration, 'SENTRY_TRACES_SAMPLE_RATE',
 SENTRY_TAGS = getattr(configuration, 'SENTRY_TAGS', {})
 SESSION_FILE_PATH = getattr(configuration, 'SESSION_FILE_PATH', None)
 SESSION_COOKIE_NAME = getattr(configuration, 'SESSION_COOKIE_NAME', 'sessionid')
-SESSION_COOKIE_PATH = BASE_PATH or '/'
-LANGUAGE_COOKIE_PATH = BASE_PATH or '/'
 SHORT_DATE_FORMAT = getattr(configuration, 'SHORT_DATE_FORMAT', 'Y-m-d')
 SHORT_DATETIME_FORMAT = getattr(configuration, 'SHORT_DATETIME_FORMAT', 'Y-m-d H:i')
 SHORT_TIME_FORMAT = getattr(configuration, 'SHORT_TIME_FORMAT', 'H:i:s')
@@ -407,6 +405,7 @@ STATIC_URL = f'/{BASE_PATH}static/'
 STATICFILES_DIRS = (
     os.path.join(BASE_DIR, 'project-static', 'dist'),
     os.path.join(BASE_DIR, 'project-static', 'img'),
+    os.path.join(BASE_DIR, 'project-static', 'js'),
     ('docs', os.path.join(BASE_DIR, 'project-static', 'docs')),  # Prefix with /docs
 )
 

+ 72 - 0
netbox/project-static/js/setmode.js

@@ -0,0 +1,72 @@
+/**
+ * Set the color mode on the `<html/>` element and in local storage.
+ *
+ * @param mode {"dark" | "light"} NetBox Color Mode.
+ * @param inferred {boolean} Value is inferred from browser/system preference.
+ */
+function setMode(mode, inferred) {
+    document.documentElement.setAttribute("data-netbox-color-mode", mode);
+    localStorage.setItem("netbox-color-mode", mode);
+    localStorage.setItem("netbox-color-mode-inferred", inferred);
+}
+/**
+ * Determine the best initial color mode to use prior to rendering.
+ */
+function initMode() {
+    try {
+        // Browser prefers dark color scheme.
+        var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
+        // Browser prefers light color scheme.
+        var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
+        // Client NetBox color-mode override.
+        var clientMode = localStorage.getItem("netbox-color-mode");
+        // NetBox server-rendered value.
+        var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
+        // Color mode is inferred from browser/system preference and not deterministically set by
+        // the client or server.
+        var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred"));
+
+        if (inferred === true && (serverMode === "light" || serverMode === "dark")) {
+            // The color mode was previously inferred from browser/system preference, but
+            // the server now has a value, so we should use the server's value.
+            return setMode(serverMode, false);
+        }
+        if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
+            // If the client mode is not set but the server mode is, use the server mode.
+            return setMode(serverMode, false);
+        }
+        if (clientMode !== null && serverMode === "unset") {
+            // The color mode has been set, deterministically or otherwise, and the server
+            // has no preference or has not been set. Use the client mode, but allow it to
+            /// be overridden by the server if/when a server value exists.
+            return setMode(clientMode, true);
+        }
+        if (
+            clientMode !== null &&
+            (serverMode === "light" || serverMode === "dark") &&
+            clientMode !== serverMode
+        ) {
+            // If the client mode is set and is different than the server mode (which is also set),
+            // use the client mode over the server mode, as it should be more recent.
+            return setMode(clientMode, false);
+        }
+        if (clientMode === serverMode) {
+            // If the client and server modes match, use that value.
+            return setMode(clientMode, false);
+        }
+        if (preferDark && serverMode === "unset") {
+            // If the server mode is not set but the browser prefers dark mode, use dark mode, but
+            // allow it to be overridden by an explicit preference.
+            return setMode("dark", true);
+        }
+        if (preferLight && serverMode === "unset") {
+            // If the server mode is not set but the browser prefers light mode, use light mode,
+            // but allow it to be overridden by an explicit preference.
+            return setMode("light", true);
+        }
+    } catch (error) {
+        // In the event of an error, log it to the console and set the mode to light mode.
+        console.error(error);
+    }
+    return setMode("light", true);
+};

+ 7 - 70
netbox/templates/base/base.html

@@ -26,78 +26,15 @@
     {# Page title #}
     <title>{% block title %}Home{% endblock %} | NetBox</title>
 
+    <script
+      type="text/javascript"
+      src="{% static 'setmode.js' %}"
+      onerror="window.location='{% url 'media_failure' %}?filename=setmode.js'">
+    </script>
+
     <script type="text/javascript">
-      /**
-       * Set the color mode on the `<html/>` element and in local storage.
-       *
-       * @param mode {"dark" | "light"} NetBox Color Mode.
-       * @param inferred {boolean} Value is inferred from browser/system preference.
-       */
-      function setMode(mode, inferred) {
-          document.documentElement.setAttribute("data-netbox-color-mode", mode);
-          localStorage.setItem("netbox-color-mode", mode);
-          localStorage.setItem("netbox-color-mode-inferred", inferred);
-      }
-      /**
-       * Determine the best initial color mode to use prior to rendering.
-       */
       (function () {
-          try {
-              // Browser prefers dark color scheme.
-              var preferDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
-              // Browser prefers light color scheme.
-              var preferLight = window.matchMedia("(prefers-color-scheme: light)").matches;
-              // Client NetBox color-mode override.
-              var clientMode = localStorage.getItem("netbox-color-mode");
-              // NetBox server-rendered value.
-              var serverMode = document.documentElement.getAttribute("data-netbox-color-mode");
-              // Color mode is inferred from browser/system preference and not deterministically set by
-              // the client or server.
-              var inferred = JSON.parse(localStorage.getItem("netbox-color-mode-inferred"));
-      
-              if (inferred === true && (serverMode === "light" || serverMode === "dark")) {
-                  // The color mode was previously inferred from browser/system preference, but
-                  // the server now has a value, so we should use the server's value.
-                  return setMode(serverMode, false);
-              }
-              if (clientMode === null && (serverMode === "light" || serverMode === "dark")) {
-                  // If the client mode is not set but the server mode is, use the server mode.
-                  return setMode(serverMode, false);
-              }
-              if (clientMode !== null && serverMode === "unset") {
-                  // The color mode has been set, deterministically or otherwise, and the server
-                  // has no preference or has not been set. Use the client mode, but allow it to
-                  /// be overridden by the server if/when a server value exists.
-                  return setMode(clientMode, true);
-              }
-              if (
-                  clientMode !== null &&
-                  (serverMode === "light" || serverMode === "dark") &&
-                  clientMode !== serverMode
-              ) {
-                  // If the client mode is set and is different than the server mode (which is also set),
-                  // use the client mode over the server mode, as it should be more recent.
-                  return setMode(clientMode, false);
-              }
-              if (clientMode === serverMode) {
-                  // If the client and server modes match, use that value.
-                  return setMode(clientMode, false);
-              }
-              if (preferDark && serverMode === "unset") {
-                  // If the server mode is not set but the browser prefers dark mode, use dark mode, but
-                  // allow it to be overridden by an explicit preference.
-                  return setMode("dark", true);
-              }
-              if (preferLight && serverMode === "unset") {
-                  // If the server mode is not set but the browser prefers light mode, use light mode,
-                  // but allow it to be overridden by an explicit preference.
-                  return setMode("light", true);
-              }
-          } catch (error) {
-              // In the event of an error, log it to the console and set the mode to light mode.
-              console.error(error);
-          }
-          return setMode("light", true);
+          initMode()
       })();
       window.CSRF_TOKEN = "{{ csrf_token }}";
     </script>

+ 5 - 5
netbox/templates/dcim/device/status.html

@@ -64,19 +64,19 @@
                 <h5 class="card-header">Environment</h5>
                 <div class="card-body">
                     <table class="table">
-                        <tr id="status-cpu" class="bg-light">
+                        <tr id="status-cpu">
                             <th colspan="2"><i class="mdi mdi-gauge"></i> CPU</th>
                         </tr>
-                        <tr id="status-memory" class="bg-light">
+                        <tr id="status-memory">
                             <th colspan="2"><i class="mdi mdi-chip"></i> Memory</th>
                         </tr>
-                        <tr id="status-temperature" class="bg-light">
+                        <tr id="status-temperature">
                             <th colspan="2"><i class="mdi mdi-thermometer"></i> Temperature</th>
                         </tr>
-                        <tr id="status-fans" class="bg-light">
+                        <tr id="status-fans">
                             <th colspan="2"><i class="mdi mdi-fan"></i> Fans</th>
                         </tr>
-                        <tr id="status-power" class="bg-light">
+                        <tr id="status-power">
                             <th colspan="2"><i class="mdi mdi-power"></i> Power</th>
                         </tr>
                         <tr class="napalm-table-placeholder d-none invisible">

+ 4 - 0
netbox/templates/dcim/inc/cable_termination.html

@@ -7,6 +7,10 @@
         <td>Site</td>
         <td>{{ terminations.0.device.site|linkify }}</td>
       </tr>
+      <tr>
+        <td>Location</td>
+        <td>{{ terminations.0.device.location|linkify|placeholder }}</td>
+      </tr>
       <tr>
         <td>Rack</td>
         <td>{{ terminations.0.device.rack|linkify|placeholder }}</td>

+ 18 - 4
netbox/templates/dcim/manufacturer.html

@@ -4,10 +4,24 @@
 {% load render_table from django_tables2 %}
 
 {% block extra_controls %}
-  {% if perms.dcim.add_devicetype %}
-    <a href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}" class="btn btn-sm btn-primary">
-      <span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Device Type
-    </a>
+  {% if perms.dcim.add_devicetype or perms.dcim.add_moduletype %}
+    <div class="dropdown">
+      <button id="add-components" type="button" class="btn btn-sm btn-primary dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add
+      </button>
+      <ul class="dropdown-menu" aria-labeled-by="add-components">
+        {% if perms.dcim.add_devicetype %}
+          <li><a class="dropdown-item" href="{% url 'dcim:devicetype_add' %}?manufacturer={{ object.pk }}">
+            Add Device Type
+          </a></li>
+        {% endif %}
+        {% if perms.dcim.add_moduletype %}
+          <li><a class="dropdown-item" href="{% url 'dcim:moduletype_add' %}?manufacturer={{ object.pk }}">
+            Add Module Type
+          </a></li>
+        {% endif %}
+      </ul>
+    </div>
   {% endif %}
 {% endblock extra_controls %}
 

+ 2 - 2
netbox/templates/dcim/powerport.html

@@ -77,10 +77,10 @@
                       </button>
                       <ul class="dropdown-menu dropdown-menu-end">
                         <li>
-                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.poweroutlet&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Outlet</a>
                         </li>
                         <li>
-                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerportport&a_terminations={{ object.pk }}&termination_b_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
+                          <a href="{% url 'dcim:cable_add' %}?a_terminations_type=dcim.powerport&a_terminations={{ object.pk }}&b_terminations_type=dcim.powerfeed&termination_b_site={{ object.device.site.pk }}&termination_b_rack={{ object.device.rack.pk }}&return_url={{ object.get_absolute_url }}" class="dropdown-item">Power Feed</a>
                         </li>
                       </ul>
                     </span>

+ 48 - 47
netbox/templates/generic/object_list.html

@@ -67,64 +67,65 @@ Context:
         {% applied_filters filter_form request.GET %}
       {% endif %}
 
-      {# "Select all" form #}
-      {% if table.paginator.num_pages > 1 %}
-        <div id="select-all-box" class="d-none card noprint">
-          <form method="post" class="form col-md-12">
-            {% csrf_token %}
-            <div class="card-body">
-              <div class="float-end">
-                {% if 'bulk_edit' in actions %}
-                  {% bulk_edit_button model query_params=request.GET %}
-                {% endif %}
-                {% if 'bulk_delete' in actions %}
-                  {% bulk_delete_button model query_params=request.GET %}
-                {% endif %}
-              </div>
-              <div class="form-check">
-                <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
-                <label for="select-all" class="form-check-label">
-                  Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
-                </label>
+      <form method="post" class="form form-horizontal">
+        {% csrf_token %}
+        {# "Select all" form #}
+        {% if table.paginator.num_pages > 1 %}
+          <div id="select-all-box" class="d-none card noprint">
+            <div class="form col-md-12">
+              <div class="card-body">
+                <div class="float-end">
+                  {% if 'bulk_edit' in actions %}
+                    {% bulk_edit_button model query_params=request.GET %}
+                  {% endif %}
+                  {% if 'bulk_delete' in actions %}
+                    {% bulk_delete_button model query_params=request.GET %}
+                  {% endif %}
+                </div>
+                <div class="form-check">
+                  <input type="checkbox" id="select-all" name="_all" class="form-check-input" />
+                  <label for="select-all" class="form-check-label">
+                    Select <strong>all {{ table.rows|length }} {{ table.data.verbose_name_plural }}</strong> matching query
+                  </label>
+                </div>
               </div>
             </div>
-          </form>
-        </div>
-      {% endif %}
+          </div>
+        {% endif %}
 
-      {# Object table controls #}
-      {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
+        {# Object table controls #}
+        {% include 'inc/table_controls_htmx.html' with table_modal="ObjectTable_config" %}
 
-      <form method="post" class="form form-horizontal">
-        {% csrf_token %}
-        <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
+        <div class="form form-horizontal">
+          {% csrf_token %}
+          <input type="hidden" name="return_url" value="{% if return_url %}{{ return_url }}{% else %}{{ request.path }}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}{% endif %}" />
 
-        {# Object table #}
+          {# Object table #}
 
-            {% if prerequisite_model %}
-              {% include 'inc/missing_prerequisites.html' %}
-            {% endif %}
+              {% if prerequisite_model %}
+                {% include 'inc/missing_prerequisites.html' %}
+              {% endif %}
 
-        <div class="card">
-          <div class="card-body" id="object_list">
-            {% include 'htmx/table.html' %}
+          <div class="card">
+            <div class="card-body" id="object_list">
+              {% include 'htmx/table.html' %}
+            </div>
           </div>
-        </div>
 
-        {# Form buttons #}
-        <div class="noprint bulk-buttons">
-          <div class="bulk-button-group">
-            {% block bulk_buttons %}
-              {% if 'bulk_edit' in actions %}
-                {% bulk_edit_button model query_params=request.GET %}
-              {% endif %}
-              {% if 'bulk_delete' in actions %}
-                {% bulk_delete_button model query_params=request.GET %}
-              {% endif %}
-            {% endblock %}
+          {# Form buttons #}
+          <div class="noprint bulk-buttons">
+            <div class="bulk-button-group">
+              {% block bulk_buttons %}
+                {% if 'bulk_edit' in actions %}
+                  {% bulk_edit_button model query_params=request.GET %}
+                {% endif %}
+                {% if 'bulk_delete' in actions %}
+                  {% bulk_delete_button model query_params=request.GET %}
+                {% endif %}
+              {% endblock %}
+            </div>
           </div>
         </div>
-
       </form>
 
     </div>

+ 1 - 1
netbox/templates/tenancy/contactrole.html

@@ -25,7 +25,7 @@
             <tr>
               <th scope="row">Assignments</th>
               <td>
-                <a href="{% url 'tenancy:contact_list' %}?role={{ object.slug }}">{{ assignment_count }}</a>
+                {{ assignment_count }}
               </td>
             </tr>
           </table>

+ 6 - 0
netbox/templates/tenancy/tenant.html

@@ -93,6 +93,12 @@
                     <h2><a href="{% url 'ipam:vlan_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.vlan_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.vlan_count }}</a></h2>
                     <p>VLANs</p>
                 </div>
+
+                <div class="col col-md-4 text-center">
+                    <h2><a href="{% url 'ipam:l2vpn_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.l2vpn_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.l2vpn_count }}</a></h2>
+                    <p>L2VPNs</p>
+                </div>
+
                 <div class="col col-md-4 text-center">
                     <h2><a href="{% url 'circuits:circuit_list' %}?tenant_id={{ object.pk }}" class="stat-btn btn {% if stats.circuit_count %}btn-primary{% else %}btn-outline-dark{% endif %} btn-lg">{{ stats.circuit_count }}</a></h2>
                     <p>Circuits</p>

+ 2 - 1
netbox/tenancy/views.py

@@ -4,7 +4,7 @@ from django.shortcuts import get_object_or_404
 
 from circuits.models import Circuit
 from dcim.models import Cable, Device, Location, Rack, RackReservation, Site
-from ipam.models import Aggregate, IPAddress, IPRange, Prefix, VLAN, VRF, ASN
+from ipam.models import Aggregate, IPAddress, IPRange, L2VPN, Prefix, VLAN, VRF, ASN
 from netbox.views import generic
 from utilities.utils import count_related
 from virtualization.models import VirtualMachine, Cluster
@@ -111,6 +111,7 @@ class TenantView(generic.ObjectView):
             'iprange_count': IPRange.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'ipaddress_count': IPAddress.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'vlan_count': VLAN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
+            'l2vpn_count': L2VPN.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'circuit_count': Circuit.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'virtualmachine_count': VirtualMachine.objects.restrict(request.user, 'view').filter(tenant=instance).count(),
             'cluster_count': Cluster.objects.restrict(request.user, 'view').filter(tenant=instance).count(),

+ 1 - 0
netbox/utilities/utils.py

@@ -410,6 +410,7 @@ def copy_safe_request(request):
     }
     return NetBoxFakeRequest({
         'META': meta,
+        'COOKIES': request.COOKIES,
         'POST': request.POST,
         'GET': request.GET,
         'FILES': request.FILES,

+ 3 - 3
requirements.txt

@@ -9,7 +9,7 @@ django-pglocks==1.0.4
 django-prometheus==2.2.0
 django-redis==5.2.0
 django-rich==1.4.0
-django-rq==2.5.1
+django-rq==2.6.0
 django-tables2==2.4.1
 django-taggit==3.0.0
 django-timezone-field==5.0
@@ -19,13 +19,13 @@ graphene-django==2.15.0
 gunicorn==20.1.0
 Jinja2==3.1.2
 Markdown==3.3.7
-mkdocs-material==8.5.7
+mkdocs-material==8.5.10
 mkdocstrings[python-legacy]==0.19.0
 netaddr==0.8.0
 Pillow==9.3.0
 psycopg2-binary==2.9.5
 PyYAML==6.0
-sentry-sdk==1.10.1
+sentry-sdk==1.11.0
 social-auth-app-django==5.0.0
 social-auth-core[openidconnect]==4.3.0
 svgwrite==1.4.3