Ver código fonte

Merge branch 'develop' into feature

Jeremy Stretch 1 ano atrás
pai
commit
312291b010
38 arquivos alterados com 747 adições e 539 exclusões
  1. 1 1
      .github/ISSUE_TEMPLATE/bug_report.yaml
  2. 1 1
      .github/ISSUE_TEMPLATE/feature_request.yaml
  3. 4 0
      contrib/gunicorn.py
  4. 4 1
      docs/administration/authentication/overview.md
  5. 3 0
      docs/configuration/remote-authentication.md
  6. 8 0
      docs/customization/custom-scripts.md
  7. 26 1
      docs/release-notes/version-3.7.md
  8. 15 6
      netbox/dcim/forms/connections.py
  9. 21 2
      netbox/dcim/forms/model_forms.py
  10. 174 195
      netbox/dcim/svg/cables.py
  11. 5 32
      netbox/dcim/tables/devices.py
  12. 16 21
      netbox/dcim/views.py
  13. 2 0
      netbox/extras/migrations/0087_squashed_0098.py
  14. 2 1
      netbox/extras/models/customfields.py
  15. 8 0
      netbox/extras/models/scripts.py
  16. 25 0
      netbox/extras/scripts.py
  17. 23 3
      netbox/extras/tables/tables.py
  18. 45 0
      netbox/extras/tests/test_scripts.py
  19. 11 2
      netbox/extras/views.py
  20. 1 1
      netbox/ipam/models/ip.py
  21. 7 1
      netbox/netbox/forms/base.py
  22. 8 2
      netbox/netbox/urls.py
  23. 9 4
      netbox/netbox/views/generic/object_views.py
  24. 0 0
      netbox/project-static/dist/netbox-dark.css
  25. 0 0
      netbox/project-static/dist/netbox-light.css
  26. 0 0
      netbox/project-static/dist/netbox.css
  27. 0 0
      netbox/project-static/dist/netbox.js
  28. 0 0
      netbox/project-static/dist/netbox.js.map
  29. 10 26
      netbox/project-static/src/buttons/connectionToggle.ts
  30. 29 0
      netbox/project-static/styles/custom/_interfaces.scss
  31. 1 0
      netbox/project-static/styles/netbox.scss
  32. 1 1
      netbox/templates/base/layout.html
  33. 1 86
      netbox/templates/dcim/cable_edit.html
  34. 92 0
      netbox/templates/dcim/htmx/cable_edit.html
  35. 6 9
      netbox/templates/dcim/inc/cable_toggle_buttons.html
  36. 183 138
      netbox/translations/en/LC_MESSAGES/django.po
  37. 1 1
      netbox/vpn/tables/l2vpn.py
  38. 4 4
      requirements.txt

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

@@ -26,7 +26,7 @@ body:
     attributes:
       label: NetBox Version
       description: What version of NetBox are you currently running?
-      placeholder: v3.7.6
+      placeholder: v3.7.7
     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.7.6
+      placeholder: v3.7.7
     validations:
       required: true
   - type: dropdown

+ 4 - 0
contrib/gunicorn.py

@@ -14,3 +14,7 @@ timeout = 120
 # The maximum number of requests a worker can handle before being respawned
 max_requests = 5000
 max_requests_jitter = 500
+
+# Uncomment this line to accept HTTP headers containing underscores, e.g. for remote
+# authentication support. See https://docs.gunicorn.org/en/stable/settings.html#header-map
+# header-map = 'dangerous'

+ 4 - 1
docs/administration/authentication/overview.md

@@ -26,7 +26,10 @@ REMOTE_AUTH_BACKEND = 'netbox.authentication.RemoteUserBackend'
 
 Another option for remote authentication in NetBox is to enable HTTP header-based user assignment. The front end HTTP server (e.g. nginx or Apache) performs client authentication as a process external to NetBox, and passes information about the authenticated user via HTTP headers. By default, the user is assigned via the `REMOTE_USER` header, but this can be customized via the `REMOTE_AUTH_HEADER` configuration parameter.
 
-Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the users profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
+Optionally, user profile information can be supplied by `REMOTE_USER_FIRST_NAME`, `REMOTE_USER_LAST_NAME` and `REMOTE_USER_EMAIL` headers. These are saved to the user's profile during the authentication process. These headers can be customized like the `REMOTE_USER` header.
+
+!!! warning Verify Header Compatibility
+    Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`.
 
 ### Single Sign-On (SSO)
 

+ 3 - 0
docs/configuration/remote-authentication.md

@@ -85,6 +85,9 @@ Default: `'HTTP_REMOTE_USER'`
 
 When remote user authentication is in use, this is the name of the HTTP header which informs NetBox of the currently authenticated user. For example, to use the request header `X-Remote-User` it needs to be set to `HTTP_X_REMOTE_USER`. (Requires `REMOTE_AUTH_ENABLED`.)
 
+!!! warning Verify Header Compatibility
+    Some WSGI servers may drop headers which contain unsupported characters. For instance, gunicorn v22.0 and later silently drops HTTP headers containing underscores. This behavior can be disabled by changing gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to `dangerous`.
+
 ---
 
 ## REMOTE_AUTH_USER_EMAIL

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

@@ -371,6 +371,14 @@ An IPv4 or IPv6 network with a mask. Returns a `netaddr.IPNetwork` object. Two a
 * `min_prefix_length` - Minimum length of the mask
 * `max_prefix_length` - Maximum length of the mask
 
+### DateVar
+
+A calendar date. Returns a `datetime.date` object.
+
+### DateTimeVar
+
+A complete date & time. Returns a `datetime.datetime` object.
+
 ## Running Custom Scripts
 
 !!! note

+ 26 - 1
docs/release-notes/version-3.7.md

@@ -1,11 +1,36 @@
 # NetBox v3.7
 
-## v3.7.7 (FUTURE)
+## v3.7.7 (2024-05-01)
+
+### Enhancements
+
+* [#15428](https://github.com/netbox-community/netbox/issues/15428) - Show usage counts for associated objects on config template list
+* [#15812](https://github.com/netbox-community/netbox/issues/15812) - Add Date & DateTime variable types for custom scripts
+* [#15894](https://github.com/netbox-community/netbox/issues/15894) - Cache the generated API schema definition for shorter loading times
+
+### Bug Fixes
+
+* [#11460](https://github.com/netbox-community/netbox/issues/11460) - Fix AttributeError exception when editing a cable with only one end terminated
+* [#13712](https://github.com/netbox-community/netbox/issues/13712) - Fix row highlighting for device interface list display
+* [#13806](https://github.com/netbox-community/netbox/issues/13806) - Fix "mark" button tooltip on button activation for device interface list display
+* [#13922](https://github.com/netbox-community/netbox/issues/13922) - Fix SVG drawing error on multiple termination trace with multiple devices
+* [#14241](https://github.com/netbox-community/netbox/issues/14241) - Fix random interface swap when performing cable trace with multiple termination
+* [#14852](https://github.com/netbox-community/netbox/issues/14852) - Fix NoReverseMatch exception when viewing an event rule which references a deleted custom script
+* [#15524](https://github.com/netbox-community/netbox/issues/15524) - Fix rounding error when reporting IP range utilization
+* [#15548](https://github.com/netbox-community/netbox/issues/15548) - Ignore many-to-many mappings when checking dependencies of an object being deleted
+* [#15845](https://github.com/netbox-community/netbox/issues/15845) - Avoid extraneous database queries when fetching assigned IP addresses via REST API
+* [#15872](https://github.com/netbox-community/netbox/issues/15872) - `BANNER_MAINTENANCE` content should permit custom HTML
+* [#15891](https://github.com/netbox-community/netbox/issues/15891) - Ensure deterministic ordering for scripts & reports
+* [#15896](https://github.com/netbox-community/netbox/issues/15896) - Fix retention of default value when editing a custom JSON field
+* [#15899](https://github.com/netbox-community/netbox/issues/15899) - Fix exception when enabling the tags column on the L2VPN terminations table
 
 ---
 
 ## v3.7.6 (2024-04-22)
 
+!!! warning
+    If remote authentication is in use with Gunicorn v22.0 or later, it may be necessary to configure Gunicorn's [`header_map`](https://docs.gunicorn.org/en/stable/settings.html#header-map) setting to preserve authentication headers.
+
 ### Enhancements
 
 * [#14690](https://github.com/netbox-community/netbox/issues/14690) - Improve rendering of JSON data in configuration form

+ 15 - 6
netbox/dcim/forms/connections.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.contrib.contenttypes.models import ContentType
 from django.utils.translation import gettext_lazy as _
 
 from circuits.models import Circuit, CircuitTermination
@@ -88,14 +89,22 @@ def get_cable_form(a_type, b_type):
 
     class _CableForm(CableForm, metaclass=FormMetaclass):
 
-        def __init__(self, *args, **kwargs):
+        def __init__(self, *args, initial=None, **kwargs):
+
+            initial = initial or {}
+            if a_type:
+                ct = ContentType.objects.get_for_model(a_type)
+                initial['a_terminations_type'] = f'{ct.app_label}.{ct.model}'
+            if b_type:
+                ct = ContentType.objects.get_for_model(b_type)
+                initial['b_terminations_type'] = f'{ct.app_label}.{ct.model}'
 
             # TODO: Temporary hack to work around list handling limitations with utils.normalize_querydict()
             for field_name in ('a_terminations', 'b_terminations'):
-                if field_name in kwargs.get('initial', {}) and type(kwargs['initial'][field_name]) is not list:
-                    kwargs['initial'][field_name] = [kwargs['initial'][field_name]]
+                if field_name in initial and type(initial[field_name]) is not list:
+                    initial[field_name] = [initial[field_name]]
 
-            super().__init__(*args, **kwargs)
+            super().__init__(*args, initial=initial, **kwargs)
 
             if self.instance and self.instance.pk:
                 # Initialize A/B terminations when modifying an existing Cable instance
@@ -106,7 +115,7 @@ def get_cable_form(a_type, b_type):
             super().clean()
 
             # Set the A/B terminations on the Cable instance
-            self.instance.a_terminations = self.cleaned_data['a_terminations']
-            self.instance.b_terminations = self.cleaned_data['b_terminations']
+            self.instance.a_terminations = self.cleaned_data.get('a_terminations', [])
+            self.instance.b_terminations = self.cleaned_data.get('b_terminations', [])
 
     return _CableForm

+ 21 - 2
netbox/dcim/forms/model_forms.py

@@ -628,14 +628,33 @@ class ModuleForm(ModuleCommonForm, NetBoxModelForm):
             self.fields['adopt_components'].disabled = True
 
 
+def get_termination_type_choices():
+    return add_blank_choice([
+        (f'{ct.app_label}.{ct.model}', ct.model_class()._meta.verbose_name.title())
+        for ct in ContentType.objects.filter(CABLE_TERMINATION_MODELS)
+    ])
+
+
 class CableForm(TenancyForm, NetBoxModelForm):
+    a_terminations_type = forms.ChoiceField(
+        choices=get_termination_type_choices,
+        required=False,
+        widget=HTMXSelect(),
+        label=_('Type')
+    )
+    b_terminations_type = forms.ChoiceField(
+        choices=get_termination_type_choices,
+        required=False,
+        widget=HTMXSelect(),
+        label=_('Type')
+    )
     comments = CommentField()
 
     class Meta:
         model = Cable
         fields = [
-            'type', 'status', 'tenant_group', 'tenant', 'label', 'color', 'length', 'length_unit', 'description',
-            'comments', 'tags',
+            'a_terminations_type', 'b_terminations_type', 'type', 'status', 'tenant_group', 'tenant', 'label', 'color',
+            'length', 'length_unit', 'description', 'comments', 'tags',
         ]
         error_messages = {
             'length': {

+ 174 - 195
netbox/dcim/svg/cables.py

@@ -8,17 +8,16 @@ from django.conf import settings
 from dcim.constants import CABLE_TRACE_SVG_DEFAULT_WIDTH
 from utilities.html import foreground_color
 
-
 __all__ = (
     'CableTraceSVG',
 )
 
-
 OFFSET = 0.5
 PADDING = 10
 LINE_HEIGHT = 20
 FANOUT_HEIGHT = 35
 FANOUT_LEG_HEIGHT = 15
+CABLE_HEIGHT = 4 * LINE_HEIGHT + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
 
 
 class Node(Hyperlink):
@@ -84,31 +83,38 @@ class Connector(Group):
         labels: Iterable of text labels
     """
 
-    def __init__(self, start, url, color, labels=[], description=[], **extra):
-        super().__init__(class_='connector', **extra)
+    def __init__(self, start, url, color, wireless, labels=[], description=[], end=None, text_offset=0, **extra):
+        super().__init__(class_="connector", **extra)
 
         self.start = start
         self.height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
-        self.end = (start[0], start[1] + self.height)
+        # Allow to specify end-position or auto-calculate
+        self.end = end if end else (start[0], start[1] + self.height)
         self.color = color or '000000'
 
-        # Draw a "shadow" line to give the cable a border
-        cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
-        self.add(cable_shadow)
+        if wireless:
+            # Draw the cable
+            cable = Line(start=self.start, end=self.end, class_="wireless-link")
+            self.add(cable)
+        else:
+            # Draw a "shadow" line to give the cable a border
+            cable_shadow = Line(start=self.start, end=self.end, class_='cable-shadow')
+            self.add(cable_shadow)
 
-        # Draw the cable
-        cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
-        self.add(cable)
+            # Draw the cable
+            cable = Line(start=self.start, end=self.end, style=f'stroke: #{self.color}')
+            self.add(cable)
 
         # Add link
         link = Hyperlink(href=url, target='_parent')
 
         # Add text label(s)
-        cursor = start[1]
-        cursor += PADDING * 2
+        cursor = start[1] + text_offset
+        cursor += PADDING * 2 + LINE_HEIGHT * 2
+        x_coord = (start[0] + end[0]) / 2 + PADDING
         for i, label in enumerate(labels):
             cursor += LINE_HEIGHT
-            text_coords = (start[0] + PADDING * 2, cursor - LINE_HEIGHT / 2)
+            text_coords = (x_coord, cursor - LINE_HEIGHT / 2)
             text = Text(label, insert=text_coords, class_='bold' if not i else [])
             link.add(text)
         if len(description) > 0:
@@ -190,8 +196,9 @@ class CableTraceSVG:
 
     def draw_parent_objects(self, obj_list):
         """
-        Draw a set of parent objects.
+        Draw a set of parent objects (eg hosts, switched, patchpanels) and return all created nodes
         """
+        objects = []
         width = self.width / len(obj_list)
         for i, obj in enumerate(obj_list):
             node = Node(
@@ -199,23 +206,26 @@ class CableTraceSVG:
                 width=width,
                 url=f'{self.base_url}{obj.get_absolute_url()}',
                 color=self._get_color(obj),
-                labels=self._get_labels(obj)
+                labels=self._get_labels(obj),
+                object=obj
             )
+            objects.append(node)
             self.parent_objects.append(node)
             if i + 1 == len(obj_list):
                 self.cursor += node.box['height']
+        return objects
 
-    def draw_terminations(self, terminations):
+    def draw_object_terminations(self, terminations, offset_x, width):
         """
-        Draw a row of terminating objects (e.g. interfaces), all of which are attached to the same end of a cable.
+        Draw all terminations belonging to an object with specified offset and width
+        Return all created nodes and their maximum height
         """
-        nodes = []
         nodes_height = 0
-        width = self.width / len(terminations)
-
-        for i, term in enumerate(terminations):
+        nodes = []
+        # Sort them by name to make renders more readable
+        for i, term in enumerate(sorted(terminations, key=lambda x: x.name)):
             node = Node(
-                position=(i * width, self.cursor),
+                position=(offset_x + i * width, self.cursor),
                 width=width,
                 url=f'{self.base_url}{term.get_absolute_url()}',
                 color=self._get_color(term),
@@ -225,133 +235,89 @@ class CableTraceSVG:
             )
             nodes_height = max(nodes_height, node.box['height'])
             nodes.append(node)
+        return nodes, nodes_height
 
-        self.cursor += nodes_height
-        self.terminations.extend(nodes)
-
-        return nodes
-
-    def draw_fanin(self, node, connector):
-        points = (
-            node.bottom_center,
-            (node.bottom_center[0], node.bottom_center[1] + FANOUT_LEG_HEIGHT),
-            connector.start,
-        )
-        self.connectors.extend((
-            Polyline(points=points, class_='cable-shadow'),
-            Polyline(points=points, style=f'stroke: #{connector.color}'),
-        ))
-
-    def draw_fanout(self, node, connector):
-        points = (
-            connector.end,
-            (node.top_center[0], node.top_center[1] - FANOUT_LEG_HEIGHT),
-            node.top_center,
-        )
-        self.connectors.extend((
-            Polyline(points=points, class_='cable-shadow'),
-            Polyline(points=points, style=f'stroke: #{connector.color}'),
-        ))
-
-    def draw_cable(self, cable, terminations, cable_count=0):
+    def draw_terminations(self, terminations, parent_object_nodes):
         """
-        Draw a single cable.  Terminations and cable count are passed for determining position and padding
-
-        :param cable: The cable to draw
-        :param terminations: List of terminations to build positioning data off of
-        :param cable_count: Count of all cables on this layer for determining whether to collapse description into a
-                            tooltip.
+        Draw a row of terminating objects (e.g. interfaces) and return all created nodes
+        Attach them to previously created parent objects
         """
+        nodes = []
+        nodes_height = 0
 
-        # If the cable count is higher than 2, collapse the description into a tooltip
-        if cable_count > 2:
-            # Use the cable __str__ function to denote the cable
-            labels = [f'{cable}']
-
-            # Include the label and the status description in the tooltip
-            description = [
-                f'Cable {cable}',
-                cable.get_status_display()
-            ]
-
-            if cable.type:
-                # Include the cable type in the tooltip
-                description.append(cable.get_type_display())
-            if cable.length is not None and cable.length_unit:
-                # Include the cable length in the tooltip
-                description.append(f'{cable.length} {cable.get_length_unit_display()}')
-        else:
-            labels = [
-                f'Cable {cable}',
-                cable.get_status_display()
-            ]
-            description = []
-            if cable.type:
-                labels.append(cable.get_type_display())
-            if cable.length is not None and cable.length_unit:
-                # Include the cable length in the tooltip
-                labels.append(f'{cable.length} {cable.get_length_unit_display()}')
-
-        # If there is only one termination, center on that termination
-        # Otherwise average the center across the terminations
-        if len(terminations) == 1:
-            center = terminations[0].bottom_center[0]
-        else:
-            # Get a list of termination centers
-            termination_centers = [term.bottom_center[0] for term in terminations]
-            # Average the centers
-            center = sum(termination_centers) / len(termination_centers)
-
-        # Create the connector
-        connector = Connector(
-            start=(center, self.cursor),
-            color=cable.color or '000000',
-            url=f'{self.base_url}{cable.get_absolute_url()}',
-            labels=labels,
-            description=description
-        )
+        # Draw terminations for each parent object
+        for parent in parent_object_nodes:
+            parent_terms = [term for term in terminations if term.parent_object == parent.object]
 
-        # Set the cursor position
-        self.cursor += connector.height
+            # Width and offset(position) for each termination box
+            width = parent.box['width'] / len(parent_terms)
+            offset_x = parent.box['x']
 
-        return connector
+            result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
+            nodes.extend(result)
 
-    def draw_wirelesslink(self, wirelesslink):
+        self.cursor += nodes_height
+        self.terminations.extend(nodes)
+
+        return nodes
+
+    def draw_far_objects(self, obj_list, terminations):
         """
-        Draw a line with labels representing a WirelessLink.
+        Draw the far-end objects and its terminations and return all created nodes
         """
-        group = Group(class_='connector')
+        # Make sure elements are sorted by name for readability
+        objects = sorted(obj_list, key=lambda x: x.name)
+        width = self.width / len(objects)
 
-        labels = [
-            f'Wireless link {wirelesslink}',
-            wirelesslink.get_status_display()
-        ]
-        if wirelesslink.ssid:
-            labels.append(wirelesslink.ssid)
+        # Max-height of created terminations
+        terms_height = 0
+        term_nodes = []
 
-        # Draw the wireless link
-        start = (OFFSET + self.center, self.cursor)
-        height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
-        end = (start[0], start[1] + height)
-        line = Line(start=start, end=end, class_='wireless-link')
-        group.add(line)
+        # Draw the terminations by per object first
+        for i, obj in enumerate(objects):
+            obj_terms = [term for term in terminations if term.parent_object == obj]
+            obj_pos = i * width
+            result, result_nodes_height = self.draw_object_terminations(obj_terms, obj_pos, width / len(obj_terms))
 
-        self.cursor += PADDING * 2
+            terms_height = max(terms_height, result_nodes_height)
+            term_nodes.extend(result)
 
-        # Add link
-        link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent')
+        # Update cursor and draw the objects
+        self.cursor += terms_height
+        self.terminations.extend(term_nodes)
+        object_nodes = self.draw_parent_objects(objects)
 
-        # Add text label(s)
-        for i, label in enumerate(labels):
-            self.cursor += LINE_HEIGHT
-            text_coords = (self.center + PADDING * 2, self.cursor - LINE_HEIGHT / 2)
-            text = Text(label, insert=text_coords, class_='bold' if not i else [])
-            link.add(text)
+        return object_nodes, term_nodes
 
-        group.add(link)
-        self.cursor += PADDING * 2
+    def draw_fanin(self, target, terminations, color):
+        """
+        Draw the fan-in-lines from each of the terminations to the targetpoint
+        """
+        for term in terminations:
+            points = (
+                term.bottom_center,
+                (term.bottom_center[0], term.bottom_center[1] + FANOUT_LEG_HEIGHT),
+                target,
+            )
+            self.connectors.extend((
+                Polyline(points=points, class_='cable-shadow'),
+                Polyline(points=points, style=f'stroke: #{color}'),
+            ))
 
-        return group
+    def draw_fanout(self, start, terminations, color):
+        """
+        Draw the fan-out-lines from the startpoint to each of the terminations
+        """
+        for term in terminations:
+            points = (
+                term.top_center,
+                (term.top_center[0], term.top_center[1] - FANOUT_LEG_HEIGHT),
+                start,
+            )
+            self.connectors.extend((
+                Polyline(points=points, class_='cable-shadow'),
+                Polyline(points=points, style=f'stroke: #{color}'),
+            ))
 
     def draw_attachment(self):
         """
@@ -378,86 +344,99 @@ class CableTraceSVG:
 
         traced_path = self.origin.trace()
 
+        parent_object_nodes = []
         # Iterate through each (terms, cable, terms) segment in the path
         for i, segment in enumerate(traced_path):
             near_ends, links, far_ends = segment
 
-            # Near end parent
+            # This is segment number one.
             if i == 0:
                 # If this is the first segment, draw the originating termination's parent object
-                self.draw_parent_objects(set(end.parent_object for end in near_ends))
+                parent_object_nodes = self.draw_parent_objects(set(end.parent_object for end in near_ends))
+            # Else: No need to draw parent objects (parent objects are drawn in last "round" as the far-end!)
 
-            # Near end termination(s)
-            terminations = self.draw_terminations(near_ends)
+            near_terminations = self.draw_terminations(near_ends, parent_object_nodes)
+            self.cursor += CABLE_HEIGHT
 
             # Connector (a Cable or WirelessLink)
             if links:
-                link_cables = {}
-                fanin = False
-                fanout = False
-
-                # Determine if we have fanins or fanouts
-                if len(near_ends) > len(set(links)):
-                    self.cursor += FANOUT_HEIGHT
-                    fanin = True
-                if len(far_ends) > len(set(links)):
-                    fanout = True
-                cursor = self.cursor
-                for link in links:
-                    # Cable
-                    if type(link) is Cable and not link_cables.get(link.pk):
-                        # Reset cursor
-                        self.cursor = cursor
-                        # Generate a list of terminations connected to this cable
-                        near_end_link_terminations = [term for term in terminations if term.object.cable == link]
-                        # Draw the cable
-                        cable = self.draw_cable(link, near_end_link_terminations, cable_count=len(links))
-                        # Add cable to the list of cables
-                        link_cables.update({link.pk: cable})
-                        # Add cable to drawing
-                        self.connectors.append(cable)
-
-                        # Draw fan-ins
-                        if len(near_ends) > 1 and fanin:
-                            for term in terminations:
-                                if term.object.cable == link:
-                                    self.draw_fanin(term, cable)
-
-                    # WirelessLink
-                    elif type(link) is WirelessLink:
-                        wirelesslink = self.draw_wirelesslink(link)
-                        self.connectors.append(wirelesslink)
-
-                # Far end termination(s)
-                if len(far_ends) > 1:
-                    if fanout:
-                        self.cursor += FANOUT_HEIGHT
-                        terminations = self.draw_terminations(far_ends)
-                        for term in terminations:
-                            if hasattr(term.object, 'cable') and link_cables.get(term.object.cable.pk):
-                                self.draw_fanout(term, link_cables.get(term.object.cable.pk))
-                    else:
-                        self.draw_terminations(far_ends)
-                elif far_ends:
-                    self.draw_terminations(far_ends)
-                else:
-                    # Link is not connected to anything
-                    break
-
-                # Far end parent
-                parent_objects = set(end.parent_object for end in far_ends)
-                self.draw_parent_objects(parent_objects)
+
+                parent_object_nodes, far_terminations = self.draw_far_objects(set(end.parent_object for end in far_ends), far_ends)
+                for cable in links:
+                    # Fill in labels and description with all available data
+                    description = [
+                        f"Link {cable}",
+                        cable.get_status_display()
+                    ]
+                    near = []
+                    far = []
+                    color = '000000'
+                    if cable.description:
+                        description.append(f"{cable.description}")
+                    if isinstance(cable, Cable):
+                        labels = [f"{cable}"] if len(links) > 2 else [f"Cable {cable}", cable.get_status_display()]
+                        if cable.type:
+                            description.append(cable.get_type_display())
+                        if cable.length and cable.length_unit:
+                            description.append(f"{cable.length} {cable.get_length_unit_display()}")
+                        color = cable.color or '000000'
+
+                        # Collect all connected nodes to this cable
+                        near = [term for term in near_terminations if term.object in cable.a_terminations]
+                        far = [term for term in far_terminations if term.object in cable.b_terminations]
+                        if not (near and far):
+                            # a and b terminations may be swapped
+                            near = [term for term in near_terminations if term.object in cable.b_terminations]
+                            far = [term for term in far_terminations if term.object in cable.a_terminations]
+                    elif isinstance(cable, WirelessLink):
+                        labels = [f"{cable}"] if len(links) > 2 else [f"Wireless {cable}", cable.get_status_display()]
+                        if cable.ssid:
+                            description.append(f"{cable.ssid}")
+                        near = [term for term in near_terminations if term.object == cable.interface_a]
+                        far = [term for term in far_terminations if term.object == cable.interface_b]
+                        if not (near and far):
+                            # a and b terminations may be swapped
+                            near = [term for term in near_terminations if term.object == cable.interface_b]
+                            far = [term for term in far_terminations if term.object == cable.interface_a]
+
+                    # Select most-probable start and end position
+                    start = near[0].bottom_center
+                    end = far[0].top_center
+                    text_offset = 0
+
+                    if len(near) > 1:
+                        # Handle Fan-In - change start position to be directly below start
+                        start = (end[0], start[1] + FANOUT_HEIGHT + FANOUT_LEG_HEIGHT)
+                        self.draw_fanin(start, near, color)
+                        text_offset -= FANOUT_HEIGHT + FANOUT_LEG_HEIGHT
+                    elif len(far) > 1:
+                        # Handle Fan-Out - change end position to be directly above end
+                        end = (start[0], end[1] - FANOUT_HEIGHT - FANOUT_LEG_HEIGHT)
+                        self.draw_fanout(end, far, color)
+                        text_offset -= FANOUT_HEIGHT
+
+                    # Create the connector
+                    connector = Connector(
+                        start=start,
+                        end=end,
+                        color=color,
+                        wireless=isinstance(cable, WirelessLink),
+                        url=f'{self.base_url}{cable.get_absolute_url()}',
+                        text_offset=text_offset,
+                        labels=labels,
+                        description=description
+                    )
+                    self.connectors.append(connector)
 
             # Render a far-end object not connected via a link (e.g. a ProviderNetwork or Site associated with
             # a CircuitTermination)
             elif far_ends:
-
                 # Attachment
                 attachment = self.draw_attachment()
                 self.connectors.append(attachment)
 
                 # Object
-                self.draw_parent_objects(far_ends)
+                parent_object_nodes = self.draw_parent_objects(far_ends)
 
         # Determine drawing size
         self.drawing = svgwrite.Drawing(

+ 5 - 32
netbox/dcim/tables/devices.py

@@ -51,34 +51,6 @@ def get_cabletermination_row_class(record):
     return ''
 
 
-def get_interface_row_class(record):
-    if not record.enabled:
-        return 'danger'
-    elif record.is_virtual:
-        return 'primary'
-    return get_cabletermination_row_class(record)
-
-
-def get_interface_state_attribute(record):
-    """
-    Get interface enabled state as string to attach to <tr/> DOM element.
-    """
-    if record.enabled:
-        return 'enabled'
-    else:
-        return 'disabled'
-
-
-def get_interface_connected_attribute(record):
-    """
-    Get interface disconnected state as string to attach to <tr/> DOM element.
-    """
-    if record.mark_connected or record.cable:
-        return 'connected'
-    else:
-        return 'disconnected'
-
-
 #
 # Device roles
 #
@@ -706,11 +678,12 @@ class DeviceInterfaceTable(InterfaceTable):
             'cable', 'connection',
         )
         row_attrs = {
-            'class': get_interface_row_class,
             'data-name': lambda record: record.name,
-            'data-enabled': get_interface_state_attribute,
-            'data-type': lambda record: record.type,
-            'data-connected': get_interface_connected_attribute
+            'data-enabled': lambda record: "enabled" if record.enabled else "disabled",
+            'data-virtual': lambda record: "true" if record.is_virtual else "false",
+            'data-mark-connected': lambda record: "true" if record.mark_connected else "false",
+            'data-cable-status': lambda record: record.cable.status if record.cable else "",
+            'data-type': lambda record: record.type
         }
 
 

+ 16 - 21
netbox/dcim/views.py

@@ -3177,34 +3177,29 @@ class CableView(generic.ObjectView):
 class CableEditView(generic.ObjectEditView):
     queryset = Cable.objects.all()
     template_name = 'dcim/cable_edit.html'
+    htmx_template_name = 'dcim/htmx/cable_edit.html'
 
-    def dispatch(self, request, *args, **kwargs):
-
-        # If creating a new Cable, initialize the form class using URL query params
-        if 'pk' not in kwargs:
-            self.form = forms.get_cable_form(
-                a_type=CABLE_TERMINATION_TYPES.get(request.GET.get('a_terminations_type')),
-                b_type=CABLE_TERMINATION_TYPES.get(request.GET.get('b_terminations_type'))
-            )
-
-        return super().dispatch(request, *args, **kwargs)
-
-    def get_object(self, **kwargs):
+    def alter_object(self, obj, request, url_args, url_kwargs):
         """
-        Hack into get_object() to set the form class when editing an existing Cable, since ObjectEditView
+        Hack into alter_object() to set the form class when editing an existing Cable, since ObjectEditView
         doesn't currently provide a hook for dynamic class resolution.
         """
-        obj = super().get_object(**kwargs)
+        a_terminations_type = CABLE_TERMINATION_TYPES.get(
+            request.GET.get('a_terminations_type') or request.POST.get('a_terminations_type')
+        )
+        b_terminations_type = CABLE_TERMINATION_TYPES.get(
+            request.GET.get('b_terminations_type') or request.POST.get('b_terminations_type')
+        )
 
         if obj.pk:
-            # TODO: Optimize this logic
-            termination_a = obj.terminations.filter(cable_end='A').first()
-            a_type = termination_a.termination._meta.model if termination_a else None
-            termination_b = obj.terminations.filter(cable_end='B').first()
-            b_type = termination_b.termination._meta.model if termination_b else None
-            self.form = forms.get_cable_form(a_type, b_type)
+            if not a_terminations_type and (termination_a := obj.terminations.filter(cable_end='A').first()):
+                a_terminations_type = termination_a.termination._meta.model
+            if not b_terminations_type and (termination_b := obj.terminations.filter(cable_end='B').first()):
+                b_terminations_type = termination_b.termination._meta.model
 
-        return obj
+        self.form = forms.get_cable_form(a_terminations_type, b_terminations_type)
+
+        return super().alter_object(obj, request, url_args, url_kwargs)
 
     def get_extra_addanother_params(self, request):
 

+ 2 - 0
netbox/extras/migrations/0087_squashed_0098.py

@@ -68,6 +68,7 @@ class Migration(migrations.Migration):
             ],
             options={
                 'proxy': True,
+                'ordering': ('file_root', 'file_path'),
                 'indexes': [],
                 'constraints': [],
             },
@@ -79,6 +80,7 @@ class Migration(migrations.Migration):
             ],
             options={
                 'proxy': True,
+                'ordering': ('file_root', 'file_path'),
                 'indexes': [],
                 'constraints': [],
             },

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

@@ -1,4 +1,5 @@
 import decimal
+import json
 import re
 from datetime import datetime, date
 
@@ -488,7 +489,7 @@ class CustomField(CloningMixin, ExportTemplatesMixin, ChangeLoggedModel):
 
         # JSON
         elif self.type == CustomFieldTypeChoices.TYPE_JSON:
-            field = JSONField(required=required, initial=initial)
+            field = JSONField(required=required, initial=json.dumps(initial) if initial else '')
 
         # Object
         elif self.type == CustomFieldTypeChoices.TYPE_OBJECT:

+ 8 - 0
netbox/extras/models/scripts.py

@@ -97,8 +97,16 @@ class ScriptModule(PythonModuleMixin, JobsMixin, ManagedFile):
     """
     objects = ScriptModuleManager()
 
+    event_rules = GenericRelation(
+        to='extras.EventRule',
+        content_type_field='action_object_type',
+        object_id_field='action_object_id',
+        for_concrete_model=False
+    )
+
     class Meta:
         proxy = True
+        ordering = ('file_root', 'file_path')
         verbose_name = _('script module')
         verbose_name_plural = _('script modules')
 

+ 25 - 0
netbox/extras/scripts.py

@@ -24,6 +24,7 @@ from ipam.validators import MaxPrefixLengthValidator, MinPrefixLengthValidator,
 from utilities.exceptions import AbortScript, AbortTransaction
 from utilities.forms import add_blank_choice
 from utilities.forms.fields import DynamicModelChoiceField, DynamicModelMultipleChoiceField
+from utilities.forms.widgets import DatePicker, DateTimePicker
 from .context_managers import event_tracking
 from .forms import ScriptForm
 from .utils import is_report
@@ -33,6 +34,8 @@ __all__ = (
     'BaseScript',
     'BooleanVar',
     'ChoiceVar',
+    'DateVar',
+    'DateTimeVar',
     'FileVar',
     'IntegerVar',
     'IPAddressVar',
@@ -174,6 +177,28 @@ class ChoiceVar(ScriptVariable):
         self.field_attrs['choices'] = add_blank_choice(choices)
 
 
+class DateVar(ScriptVariable):
+    """
+    A date.
+    """
+    form_field = forms.DateField
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.form_field.widget = DatePicker()
+
+
+class DateTimeVar(ScriptVariable):
+    """
+    A date and a time.
+    """
+    form_field = forms.DateTimeField
+
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+        self.form_field.widget = DateTimePicker()
+
+
 class MultiChoiceVar(ScriptVariable):
     """
     Like ChoiceVar, but allows for the selection of multiple choices.

+ 23 - 3
netbox/extras/tables/tables.py

@@ -419,15 +419,35 @@ class ConfigTemplateTable(NetBoxTable):
     tags = columns.TagColumn(
         url_name='extras:configtemplate_list'
     )
+    role_count = columns.LinkedCountColumn(
+        viewname='dcim:devicerole_list',
+        url_params={'config_template_id': 'pk'},
+        verbose_name=_('Device Roles')
+    )
+    platform_count = columns.LinkedCountColumn(
+        viewname='dcim:platform_list',
+        url_params={'config_template_id': 'pk'},
+        verbose_name=_('Platforms')
+    )
+    device_count = columns.LinkedCountColumn(
+        viewname='dcim:device_list',
+        url_params={'config_template_id': 'pk'},
+        verbose_name=_('Devices')
+    )
+    vm_count = columns.LinkedCountColumn(
+        viewname='virtualization:virtualmachine_list',
+        url_params={'config_template_id': 'pk'},
+        verbose_name=_('Virtual Machines')
+    )
 
     class Meta(NetBoxTable.Meta):
         model = ConfigTemplate
         fields = (
-            'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'created', 'last_updated',
-            'tags',
+            'pk', 'id', 'name', 'description', 'data_source', 'data_file', 'data_synced', 'role_count',
+            'platform_count', 'device_count', 'vm_count', 'created', 'last_updated', 'tags',
         )
         default_columns = (
-            'pk', 'name', 'description', 'is_synced',
+            'pk', 'name', 'description', 'is_synced', 'device_count', 'vm_count',
         )
 
 

+ 45 - 0
netbox/extras/tests/test_scripts.py

@@ -1,4 +1,5 @@
 import tempfile
+from datetime import date, datetime, timezone
 
 from django.core.files.uploadedfile import SimpleUploadedFile
 from django.test import TestCase
@@ -322,3 +323,47 @@ class ScriptVariablesTest(TestCase):
         form = TestScript().as_form(data, None)
         self.assertTrue(form.is_valid())
         self.assertEqual(form.cleaned_data['var1'], IPNetwork(data['var1']))
+
+    def test_datevar(self):
+
+        class TestScript(Script):
+
+            var1 = DateVar()
+            var2 = DateVar(required=False)
+
+        # Test date validation
+        data = {'var1': 'not a date'}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate valid data
+        input_date = date(2024, 4, 1)
+        data = {'var1': input_date}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], input_date)
+        # Validate required=False works for this Var type
+        self.assertEqual(form.cleaned_data['var2'], None)
+
+    def test_datetimevar(self):
+
+        class TestScript(Script):
+
+            var1 = DateTimeVar()
+            var2 = DateTimeVar(required=False)
+
+        # Test datetime validation
+        data = {'var1': 'not a datetime'}
+        form = TestScript().as_form(data, None)
+        self.assertFalse(form.is_valid())
+        self.assertIn('var1', form.errors)
+
+        # Validate valid data
+        input_datetime = datetime(2024, 4, 1, 8, 0, 0, 0, timezone.utc)
+        data = {'var1': input_datetime}
+        form = TestScript().as_form(data, None)
+        self.assertTrue(form.is_valid())
+        self.assertEqual(form.cleaned_data['var1'], input_datetime)
+        # Validate required=False works for this Var type
+        self.assertEqual(form.cleaned_data['var2'], None)

+ 11 - 2
netbox/extras/views.py

@@ -13,6 +13,7 @@ from core.choices import ManagedFileRootPathChoices
 from core.forms import ManagedFileForm
 from core.models import Job
 from core.tables import JobTable
+from dcim.models import Device, DeviceRole, Platform
 from extras.dashboard.forms import DashboardWidgetAddForm, DashboardWidgetForm
 from extras.dashboard.utils import get_widget_class
 from netbox.constants import DEFAULT_ACTION_PERMISSIONS
@@ -28,6 +29,7 @@ from utilities.request import copy_safe_request
 from utilities.rqworker import get_workers_for_queue
 from utilities.templatetags.builtins.filters import render_markdown
 from utilities.views import ContentTypePermissionRequiredMixin, get_viewname, register_model_view
+from virtualization.models import VirtualMachine
 from . import filtersets, forms, tables
 from .models import *
 from .scripts import run_script
@@ -627,7 +629,12 @@ class ObjectConfigContextView(generic.ObjectView):
 #
 
 class ConfigTemplateListView(generic.ObjectListView):
-    queryset = ConfigTemplate.objects.all()
+    queryset = ConfigTemplate.objects.annotate(
+        device_count=count_related(Device, 'config_template'),
+        vm_count=count_related(VirtualMachine, 'config_template'),
+        role_count=count_related(DeviceRole, 'config_template'),
+        platform_count=count_related(Platform, 'config_template'),
+    )
     filterset = filtersets.ConfigTemplateFilterSet
     filterset_form = forms.ConfigTemplateFilterForm
     table = tables.ConfigTemplateTable
@@ -1035,7 +1042,9 @@ class ScriptListView(ContentTypePermissionRequiredMixin, View):
         return 'extras.view_script'
 
     def get(self, request):
-        script_modules = ScriptModule.objects.restrict(request.user).prefetch_related('jobs')
+        script_modules = ScriptModule.objects.restrict(request.user).prefetch_related(
+            'data_source', 'data_file', 'jobs'
+        )
 
         return render(request, 'extras/script_list.html', {
             'model': ScriptModule,

+ 1 - 1
netbox/ipam/models/ip.py

@@ -692,7 +692,7 @@ class IPRange(PrimaryModel):
             ip.address.ip for ip in self.get_child_ips()
         ]).size
 
-        return int(float(child_count) / self.size * 100)
+        return min(float(child_count) / self.size * 100, 100)
 
 
 class IPAddress(PrimaryModel):

+ 7 - 1
netbox/netbox/forms/base.py

@@ -1,3 +1,5 @@
+import json
+
 from django import forms
 from django.contrib.contenttypes.models import ContentType
 from django.db.models import Q
@@ -35,7 +37,11 @@ class NetBoxModelForm(CheckLastUpdatedMixin, CustomFieldsMixin, TagsMixin, forms
     def _get_form_field(self, customfield):
         if self.instance.pk:
             form_field = customfield.to_form_field(set_initial=False)
-            form_field.initial = self.instance.custom_field_data.get(customfield.name, None)
+            initial = self.instance.custom_field_data.get(customfield.name)
+            if customfield.type == CustomFieldTypeChoices.TYPE_JSON:
+                form_field.initial = json.dumps(initial)
+            else:
+                form_field.initial = initial
             return form_field
 
         return customfield.to_form_field()

+ 8 - 2
netbox/netbox/urls.py

@@ -1,6 +1,7 @@
 from django.conf import settings
 from django.conf.urls import include
 from django.urls import path
+from django.views.decorators.cache import cache_page
 from django.views.static import serve
 from drf_spectacular.views import SpectacularAPIView, SpectacularRedocView, SpectacularSwaggerView
 
@@ -10,7 +11,6 @@ from netbox.graphql.schema import schema
 from netbox.graphql.views import NetBoxGraphQLView
 from netbox.plugins.urls import plugin_patterns, plugin_api_patterns
 from netbox.views import HomeView, StaticMediaFailureView, SearchView, htmx
-from strawberry.django.views import GraphQLView
 
 _patterns = [
 
@@ -55,7 +55,13 @@ _patterns = [
     path('api/wireless/', include('wireless.api.urls')),
     path('api/status/', StatusView.as_view(), name='api-status'),
 
-    path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
+    path(
+        "api/schema/",
+        cache_page(timeout=86400, key_prefix=f"api_schema_{settings.VERSION}")(
+            SpectacularAPIView.as_view()
+        ),
+        name="schema",
+    ),
     path('api/schema/swagger-ui/', SpectacularSwaggerView.as_view(url_name='schema'), name='api_docs'),
     path('api/schema/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='api_redocs'),
 

+ 9 - 4
netbox/netbox/views/generic/object_views.py

@@ -167,6 +167,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
     """
     template_name = 'generic/object_edit.html'
     form = None
+    htmx_template_name = 'htmx/form.html'
 
     def dispatch(self, request, *args, **kwargs):
         # Determine required permission based on whether we are editing an existing object
@@ -228,7 +229,7 @@ class ObjectEditView(GetReturnURLMixin, BaseObjectView):
 
         # If this is an HTMX request, return only the rendered form HTML
         if htmx_partial(request):
-            return render(request, 'htmx/form.html', {
+            return render(request, self.htmx_template_name, {
                 'form': form,
             })
 
@@ -339,10 +340,14 @@ class ObjectDeleteView(GetReturnURLMixin, BaseObjectView):
 
         # Compile a mapping of models to instances
         dependent_objects = defaultdict(list)
-        for model, instance in collector.instances_with_model():
+        for model, instances in collector.instances_with_model():
+            # Ignore relations to auto-created models (e.g. many-to-many mappings)
+            if model._meta.auto_created:
+                continue
             # Omit the root object
-            if instance != obj:
-                dependent_objects[model].append(instance)
+            if instances == obj:
+                continue
+            dependent_objects[model].append(instances)
 
         return dict(dependent_objects)
 

Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox-light.css


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox.css


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox.js


Diferenças do arquivo suprimidas por serem muito extensas
+ 0 - 0
netbox/project-static/dist/netbox.js.map


+ 10 - 26
netbox/project-static/src/buttons/connectionToggle.ts

@@ -7,10 +7,10 @@ import { isTruthy, apiPatch, hasError, getElements } from '../util';
  *
  * @param element Connection Toggle Button Element
  */
-function toggleConnection(element: HTMLButtonElement): void {
+function setConnectionStatus(element: HTMLButtonElement, status: string): void {
+  // Get the button's row to change its data-cable-status attribute
+  const row = element.parentElement?.parentElement as HTMLTableRowElement;
   const url = element.getAttribute('data-url');
-  const connected = element.classList.contains('connected');
-  const status = connected ? 'planned' : 'connected';
 
   if (isTruthy(url)) {
     apiPatch(url, { status }).then(res => {
@@ -19,34 +19,18 @@ function toggleConnection(element: HTMLButtonElement): void {
         createToast('danger', 'Error', res.error).show();
         return;
       } else {
-        // Get the button's row to change its styles.
-        const row = element.parentElement?.parentElement as HTMLTableRowElement;
-        // Get the button's icon to change its CSS class.
-        const icon = element.querySelector('i.mdi, span.mdi') as HTMLSpanElement;
-        if (connected) {
-          row.classList.remove('success');
-          row.classList.add('info');
-          element.classList.remove('connected', 'btn-warning');
-          element.classList.add('btn-info');
-          element.title = 'Mark Installed';
-          icon.classList.remove('mdi-lan-disconnect');
-          icon.classList.add('mdi-lan-connect');
-        } else {
-          row.classList.remove('info');
-          row.classList.add('success');
-          element.classList.remove('btn-success');
-          element.classList.add('connected', 'btn-warning');
-          element.title = 'Mark Installed';
-          icon.classList.remove('mdi-lan-connect');
-          icon.classList.add('mdi-lan-disconnect');
-        }
+        // Update cable status in DOM
+        row.setAttribute('data-cable-status', status);
       }
     });
   }
 }
 
 export function initConnectionToggle(): void {
-  for (const element of getElements<HTMLButtonElement>('button.cable-toggle')) {
-    element.addEventListener('click', () => toggleConnection(element));
+  for (const element of getElements<HTMLButtonElement>('button.mark-planned')) {
+    element.addEventListener('click', () => setConnectionStatus(element, 'planned'));
+  }
+  for (const element of getElements<HTMLButtonElement>('button.mark-installed')) {
+    element.addEventListener('click', () => setConnectionStatus(element, 'connected'));
   }
 }

+ 29 - 0
netbox/project-static/styles/custom/_interfaces.scss

@@ -0,0 +1,29 @@
+@use 'sass:map';
+
+// Interface row coloring
+tr[data-cable-status=connected] {
+  background-color: rgba(map.get($theme-colors, "green"), 0.15);
+}
+tr[data-cable-status=planned] {
+  background-color: rgba(map.get($theme-colors, "blue"), 0.15);
+}
+tr[data-cable-status=decommissioning] {
+  background-color: rgba(map.get($theme-colors, "yellow"), 0.15);
+}
+tr[data-mark-connected=true] {
+  background-color: rgba(map.get($theme-colors, "success"), 0.15);
+}
+tr[data-virtual=true] {
+  background-color: rgba(map.get($theme-colors, "primary"), 0.15);
+}
+tr[data-enabled=disabled] {
+  background-color: rgba(map.get($theme-colors, "danger"), 0.15);
+}
+
+// Only show the correct button depending on the cable status
+tr[data-cable-status=connected] button.mark-installed {
+  display: none;
+}
+tr:not([data-cable-status=connected]) button.mark-planned {
+  display: none;
+}

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

@@ -20,5 +20,6 @@
 
 // Custom styling
 @import 'custom/code';
+@import 'custom/interfaces';
 @import 'custom/markdown';
 @import 'custom/misc';

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

@@ -99,7 +99,7 @@ Blocks:
         {% endif %}
 
         {% if config.MAINTENANCE_MODE and config.BANNER_MAINTENANCE %}
-          {% include 'inc/alerts/warning.html' with title="Maintenance Mode" message=config.BANNER_MAINTENANCE|escape %}
+          {% include 'inc/alerts/warning.html' with title="Maintenance Mode" message=config.BANNER_MAINTENANCE|safe %}
         {% endif %}
         {# /Alerts #}
 

+ 1 - 86
netbox/templates/dcim/cable_edit.html

@@ -1,90 +1,5 @@
 {% extends 'generic/object_edit.html' %}
-{% load static %}
-{% load helpers %}
-{% load form_helpers %}
-{% load i18n %}
 
 {% block form %}
-
-  {# A side termination #}
-  <div class="field-group mb-5">
-    <div class="row">
-      <h5 class="col-9 offset-3">{% trans "A Side" %}</h5>
-    </div>
-    {% if 'termination_a_device' in form.fields %}
-      {% render_field form.termination_a_device %}
-    {% endif %}
-    {% if 'termination_a_powerpanel' in form.fields %}
-      {% render_field form.termination_a_powerpanel %}
-    {% endif %}
-    {% if 'termination_a_circuit' in form.fields %}
-      {% render_field form.termination_a_circuit %}
-    {% endif %}
-    {% render_field form.a_terminations %}
-  </div>
-
-  {# B side termination #}
-  <div class="field-group mb-5">
-    <div class="row">
-      <h5 class="col-9 offset-3">{% trans "B Side" %}</h5>
-    </div>
-    {% if 'termination_b_device' in form.fields %}
-      {% render_field form.termination_b_device %}
-    {% endif %}
-    {% if 'termination_b_powerpanel' in form.fields %}
-      {% render_field form.termination_b_powerpanel %}
-    {% endif %}
-    {% if 'termination_b_circuit' in form.fields %}
-      {% render_field form.termination_b_circuit %}
-    {% endif %}
-    {% render_field form.b_terminations %}
-  </div>
-
-  {# Cable attributes #}
-  <div class="field-group mb-5">
-    <div class="row">
-      <h5 class="col-9 offset-3">{% trans "Cable" %}</h5>
-    </div>
-    {% render_field form.status %}
-    {% render_field form.type %}
-    {% render_field form.label %}
-    {% render_field form.description %}
-    {% render_field form.color %}
-    <div class="row mb-3">
-      <label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
-      <div class="col-md-5">
-        {{ form.length }}
-      </div>
-      <div class="col-md-4">
-        {{ form.length_unit }}
-      </div>
-      <div class="invalid-feedback"></div>
-    </div>
-    {% render_field form.tags %}
-  </div>
-
-  <div class="field-group mb-5">
-    <div class="row">
-      <h5 class="col-9 offset-3">{% trans "Tenancy" %}</h5>
-    </div>
-    {% render_field form.tenant_group %}
-    {% render_field form.tenant %}
-  </div>
-
-  {% if form.custom_fields %}
-    <div class="field-group mb-5">
-      <div class="row">
-        <h5 class="col-9 offset-3">{% trans "Custom Fields" %}</h5>
-      </div>
-      {% render_custom_fields form %}
-    </div>
-  {% endif %}
-
-  {% if form.comments %}
-    <div class="field-group mb-5">
-      <h5 class="text-center">{% trans "Comments" %}</h5>
-      {% render_field form.comments %}
-    </div>
-  {% endif %}
-
+  {%  include 'dcim/htmx/cable_edit.html' %}
 {% endblock %}

+ 92 - 0
netbox/templates/dcim/htmx/cable_edit.html

@@ -0,0 +1,92 @@
+{% load static %}
+{% load helpers %}
+{% load form_helpers %}
+{% load i18n %}
+
+
+{# A side termination #}
+<div class="field-group mb-5">
+  <div class="row">
+    <h5 class="col-9 offset-3">{% trans "A Side" %}</h5>
+  </div>
+  {% render_field form.a_terminations_type %}
+  {% if 'termination_a_device' in form.fields %}
+    {% render_field form.termination_a_device %}
+  {% endif %}
+  {% if 'termination_a_powerpanel' in form.fields %}
+    {% render_field form.termination_a_powerpanel %}
+  {% endif %}
+  {% if 'termination_a_circuit' in form.fields %}
+    {% render_field form.termination_a_circuit %}
+  {% endif %}
+  {% if 'a_terminations' in form.fields %}
+    {% render_field form.a_terminations %}
+  {% endif %}
+</div>
+
+{# B side termination #}
+<div class="field-group mb-5">
+  <div class="row">
+    <h5 class="col-9 offset-3">{% trans "B Side" %}</h5>
+  </div>
+  {% render_field form.b_terminations_type %}
+  {% if 'termination_b_device' in form.fields %}
+    {% render_field form.termination_b_device %}
+  {% endif %}
+  {% if 'termination_b_powerpanel' in form.fields %}
+    {% render_field form.termination_b_powerpanel %}
+  {% endif %}
+  {% if 'termination_b_circuit' in form.fields %}
+    {% render_field form.termination_b_circuit %}
+  {% endif %}
+  {% if 'b_terminations' in form.fields %}
+    {% render_field form.b_terminations %}
+  {% endif %}
+</div>
+
+{# Cable attributes #}
+<div class="field-group mb-5">
+  <div class="row">
+    <h5 class="col-9 offset-3">{% trans "Cable" %}</h5>
+  </div>
+  {% render_field form.status %}
+  {% render_field form.type %}
+  {% render_field form.label %}
+  {% render_field form.description %}
+  {% render_field form.color %}
+  <div class="row mb-3">
+    <label class="col-sm-3 col-form-label text-lg-end">{{ form.length.label }}</label>
+    <div class="col-md-5">
+      {{ form.length }}
+    </div>
+    <div class="col-md-4">
+      {{ form.length_unit }}
+    </div>
+    <div class="invalid-feedback"></div>
+  </div>
+  {% render_field form.tags %}
+</div>
+
+<div class="field-group mb-5">
+  <div class="row">
+    <h5 class="col-9 offset-3">{% trans "Tenancy" %}</h5>
+  </div>
+  {% render_field form.tenant_group %}
+  {% render_field form.tenant %}
+</div>
+
+{% if form.custom_fields %}
+  <div class="field-group mb-5">
+    <div class="row">
+      <h5 class="col-9 offset-3">{% trans "Custom Fields" %}</h5>
+    </div>
+    {% render_custom_fields form %}
+  </div>
+{% endif %}
+
+{% if form.comments %}
+  <div class="field-group mb-5">
+    <h5 class="text-center">{% trans "Comments" %}</h5>
+    {% render_field form.comments %}
+  </div>
+{% endif %}

+ 6 - 9
netbox/templates/dcim/inc/cable_toggle_buttons.html

@@ -1,12 +1,9 @@
 {% load i18n %}
 {% if perms.dcim.change_cable %}
-    {% if cable.status == 'connected' %}
-        <button type="button" class="btn btn-warning btn-sm cable-toggle connected" title="{% trans "Mark Planned" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
-            <i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
-        </button>
-    {% else %}
-        <button type="button" class="btn btn-info btn-sm cable-toggle" title="{% trans "Mark Installed" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
-            <i class="mdi mdi-lan-connect" aria-hidden="true"></i>
-        </button>
-    {% endif %}
+    <button type="button" class="btn btn-warning btn-sm mark-planned" title="{% trans "Mark Planned" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
+    <i class="mdi mdi-lan-disconnect" aria-hidden="true"></i>
+    </button>
+    <button type="button" class="btn btn-info btn-sm mark-installed" title="{% trans "Mark Installed" %}" data-url="{% url 'dcim-api:cable-detail' pk=cable.pk %}">
+    <i class="mdi mdi-lan-connect" aria-hidden="true"></i>
+    </button>
 {% endif %}

Diferenças do arquivo suprimidas por serem muito extensas
+ 183 - 138
netbox/translations/en/LC_MESSAGES/django.po


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

@@ -74,7 +74,7 @@ class L2VPNTerminationTable(NetBoxTable):
         verbose_name=_('Object Site')
     )
     tags = columns.TagColumn(
-        url_name='ipam:l2vpntermination_list'
+        url_name='vpn:l2vpntermination_list'
     )
 
     class Meta(NetBoxTable.Meta):

+ 4 - 4
requirements.txt

@@ -15,20 +15,20 @@ django-tables2==2.7.0
 django-timezone-field==6.1.0
 djangorestframework==3.15.1
 drf-spectacular==0.27.2
-drf-spectacular-sidecar==2024.4.1
+drf-spectacular-sidecar==2024.5.1
 feedparser==6.0.11
 gunicorn==22.0.0
 Jinja2==3.1.3
 Markdown==3.6
-mkdocs-material==9.5.18
-mkdocstrings[python-legacy]==0.24.3
+mkdocs-material==9.5.20
+mkdocstrings[python-legacy]==0.25.0
 netaddr==1.2.1
 nh3==0.2.17
 Pillow==10.3.0
 psycopg[c,pool]==3.1.18
 PyYAML==6.0.1
 requests==2.31.0
-social-auth-app-django==5.4.0
+social-auth-app-django==5.4.1
 social-auth-core==4.5.4
 strawberry-graphql==0.227.2
 strawberry-graphql-django==0.34.0

Alguns arquivos não foram mostrados porque muitos arquivos mudaram nesse diff