diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml
index 8fc9bc205..3679e5db9 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yaml
+++ b/.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
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yaml b/.github/ISSUE_TEMPLATE/feature_request.yaml
index 3e7372484..0cf522960 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yaml
+++ b/.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
diff --git a/contrib/gunicorn.py b/contrib/gunicorn.py
index 89d6943b4..4b2b7c6b0 100644
--- a/contrib/gunicorn.py
+++ b/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'
diff --git a/docs/administration/authentication/overview.md b/docs/administration/authentication/overview.md
index 3a3b9efc2..a6c3a3159 100644
--- a/docs/administration/authentication/overview.md
+++ b/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)
diff --git a/docs/configuration/remote-authentication.md b/docs/configuration/remote-authentication.md
index e7fe56a09..5f28d987f 100644
--- a/docs/configuration/remote-authentication.md
+++ b/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
diff --git a/docs/customization/custom-scripts.md b/docs/customization/custom-scripts.md
index bdc3f9104..21ae20f05 100644
--- a/docs/customization/custom-scripts.md
+++ b/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
diff --git a/docs/release-notes/version-3.7.md b/docs/release-notes/version-3.7.md
index 64fdc7dfe..337526c14 100644
--- a/docs/release-notes/version-3.7.md
+++ b/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
diff --git a/netbox/dcim/forms/connections.py b/netbox/dcim/forms/connections.py
index 67ef360fe..b57f4ad2c 100644
--- a/netbox/dcim/forms/connections.py
+++ b/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
diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py
index 81c1b17c7..d5cc0e856 100644
--- a/netbox/dcim/forms/model_forms.py
+++ b/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': {
diff --git a/netbox/dcim/svg/cables.py b/netbox/dcim/svg/cables.py
index aaa9e24ed..f07b21747 100644
--- a/netbox/dcim/svg/cables.py
+++ b/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
+
+ def draw_terminations(self, terminations, parent_object_nodes):
+ """
+ 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
+
+ # 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]
+
+ # Width and offset(position) for each termination box
+ width = parent.box['width'] / len(parent_terms)
+ offset_x = parent.box['x']
+
+ result, nodes_height = self.draw_object_terminations(parent_terms, offset_x, width)
+ nodes.extend(result)
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_far_objects(self, obj_list, terminations):
"""
- 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 the far-end objects and its terminations and return all created nodes
"""
+ # Make sure elements are sorted by name for readability
+ objects = sorted(obj_list, key=lambda x: x.name)
+ width = self.width / len(objects)
- # 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}']
+ # Max-height of created terminations
+ terms_height = 0
+ term_nodes = []
- # Include the label and the status description in the tooltip
- description = [
- f'Cable {cable}',
- cable.get_status_display()
- ]
+ # 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))
- 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()}')
+ terms_height = max(terms_height, result_nodes_height)
+ term_nodes.extend(result)
- # 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)
+ # Update cursor and draw the objects
+ self.cursor += terms_height
+ self.terminations.extend(term_nodes)
+ object_nodes = self.draw_parent_objects(objects)
- # 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
- )
+ return object_nodes, term_nodes
- # Set the cursor position
- self.cursor += connector.height
-
- return connector
-
- def draw_wirelesslink(self, wirelesslink):
+ def draw_fanin(self, target, terminations, color):
"""
- Draw a line with labels representing a WirelessLink.
+ Draw the fan-in-lines from each of the terminations to the targetpoint
"""
- group = Group(class_='connector')
+ 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}'),
+ ))
- labels = [
- f'Wireless link {wirelesslink}',
- wirelesslink.get_status_display()
- ]
- if wirelesslink.ssid:
- labels.append(wirelesslink.ssid)
-
- # 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)
-
- self.cursor += PADDING * 2
-
- # Add link
- link = Hyperlink(href=f'{self.base_url}{wirelesslink.get_absolute_url()}', target='_parent')
-
- # 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)
-
- group.add(link)
- self.cursor += PADDING * 2
-
- 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)
+ 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'
- # Draw fan-ins
- if len(near_ends) > 1 and fanin:
- for term in terminations:
- if term.object.cable == link:
- self.draw_fanin(term, cable)
+ # 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]
- # WirelessLink
- elif type(link) is WirelessLink:
- wirelesslink = self.draw_wirelesslink(link)
- self.connectors.append(wirelesslink)
+ # Select most-probable start and end position
+ start = near[0].bottom_center
+ end = far[0].top_center
+ text_offset = 0
- # 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
+ 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
- # Far end parent
- parent_objects = set(end.parent_object for end in far_ends)
- self.draw_parent_objects(parent_objects)
+ # 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(
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 98dcfcb3c..800b55bf1 100644
--- a/netbox/dcim/tables/devices.py
+++ b/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
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
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
}
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index 120bbcb59..48a8656e8 100644
--- a/netbox/dcim/views.py
+++ b/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):
diff --git a/netbox/extras/migrations/0087_squashed_0098.py b/netbox/extras/migrations/0087_squashed_0098.py
index 57ad3af00..55f276ecd 100644
--- a/netbox/extras/migrations/0087_squashed_0098.py
+++ b/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': [],
},
diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py
index 676c6c66a..974affb2e 100644
--- a/netbox/extras/models/customfields.py
+++ b/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:
diff --git a/netbox/extras/models/scripts.py b/netbox/extras/models/scripts.py
index b0344443c..9118ab2ca 100644
--- a/netbox/extras/models/scripts.py
+++ b/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')
diff --git a/netbox/extras/scripts.py b/netbox/extras/scripts.py
index 71faa47e2..0e74c3f0d 100644
--- a/netbox/extras/scripts.py
+++ b/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.
diff --git a/netbox/extras/tables/tables.py b/netbox/extras/tables/tables.py
index c448c2a31..8da3ea93a 100644
--- a/netbox/extras/tables/tables.py
+++ b/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',
)
diff --git a/netbox/extras/tests/test_scripts.py b/netbox/extras/tests/test_scripts.py
index 64971f1dc..bed8f0fc5 100644
--- a/netbox/extras/tests/test_scripts.py
+++ b/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)
diff --git a/netbox/extras/views.py b/netbox/extras/views.py
index be3937512..ff147cc31 100644
--- a/netbox/extras/views.py
+++ b/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,
diff --git a/netbox/ipam/models/ip.py b/netbox/ipam/models/ip.py
index 422c5ba37..fb1c123c3 100644
--- a/netbox/ipam/models/ip.py
+++ b/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):
diff --git a/netbox/netbox/forms/base.py b/netbox/netbox/forms/base.py
index 21cdf891d..d59f61ef9 100644
--- a/netbox/netbox/forms/base.py
+++ b/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()
diff --git a/netbox/netbox/urls.py b/netbox/netbox/urls.py
index 1ce929513..a5e5e36f4 100644
--- a/netbox/netbox/urls.py
+++ b/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'),
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index d9ac2e9ff..65ab490e0 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/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)
diff --git a/netbox/project-static/dist/netbox-dark.css b/netbox/project-static/dist/netbox-dark.css
index 84d1600e3..42f40aeb8 100644
Binary files a/netbox/project-static/dist/netbox-dark.css and b/netbox/project-static/dist/netbox-dark.css differ
diff --git a/netbox/project-static/dist/netbox-light.css b/netbox/project-static/dist/netbox-light.css
index 9048a3286..df5874b48 100644
Binary files a/netbox/project-static/dist/netbox-light.css and b/netbox/project-static/dist/netbox-light.css differ
diff --git a/netbox/project-static/dist/netbox.css b/netbox/project-static/dist/netbox.css
index 1a73196a1..8d39b302d 100644
Binary files a/netbox/project-static/dist/netbox.css and b/netbox/project-static/dist/netbox.css differ
diff --git a/netbox/project-static/dist/netbox.js b/netbox/project-static/dist/netbox.js
index af0d70c07..5a8a9e833 100644
Binary files a/netbox/project-static/dist/netbox.js and b/netbox/project-static/dist/netbox.js differ
diff --git a/netbox/project-static/dist/netbox.js.map b/netbox/project-static/dist/netbox.js.map
index 9d18c575e..c44bc6481 100644
Binary files a/netbox/project-static/dist/netbox.js.map and b/netbox/project-static/dist/netbox.js.map differ
diff --git a/netbox/project-static/src/buttons/connectionToggle.ts b/netbox/project-static/src/buttons/connectionToggle.ts
index 74b32dc3a..ed119f738 100644
--- a/netbox/project-static/src/buttons/connectionToggle.ts
+++ b/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('button.cable-toggle')) {
- element.addEventListener('click', () => toggleConnection(element));
+ for (const element of getElements('button.mark-planned')) {
+ element.addEventListener('click', () => setConnectionStatus(element, 'planned'));
+ }
+ for (const element of getElements('button.mark-installed')) {
+ element.addEventListener('click', () => setConnectionStatus(element, 'connected'));
}
}
diff --git a/netbox/project-static/styles/custom/_interfaces.scss b/netbox/project-static/styles/custom/_interfaces.scss
new file mode 100644
index 000000000..2c363e7ff
--- /dev/null
+++ b/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;
+}
diff --git a/netbox/project-static/styles/netbox.scss b/netbox/project-static/styles/netbox.scss
index aa7150be7..b04b85fc9 100644
--- a/netbox/project-static/styles/netbox.scss
+++ b/netbox/project-static/styles/netbox.scss
@@ -20,5 +20,6 @@
// Custom styling
@import 'custom/code';
+@import 'custom/interfaces';
@import 'custom/markdown';
@import 'custom/misc';
diff --git a/netbox/templates/base/layout.html b/netbox/templates/base/layout.html
index a9df01cd5..d53591cb4 100644
--- a/netbox/templates/base/layout.html
+++ b/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 #}
diff --git a/netbox/templates/dcim/cable_edit.html b/netbox/templates/dcim/cable_edit.html
index b5c1f8752..fbe877d87 100644
--- a/netbox/templates/dcim/cable_edit.html
+++ b/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 #}
-
-
-
{% trans "A Side" %}
-
- {% 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 %}
-
-
- {# B side termination #}
-
-
-
{% trans "B Side" %}
-
- {% 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 %}
-