Jelajahi Sumber

Merge pull request #6755 from netbox-community/6000-cable-trace-svg

Closes #6000: SVG rendering for cable tracing
Jeremy Stretch 4 tahun lalu
induk
melakukan
2bfdaf08ee

+ 8 - 4
netbox/dcim/api/views.py

@@ -2,18 +2,15 @@ import socket
 from collections import OrderedDict
 
 from django.conf import settings
-from django.contrib.contenttypes.models import ContentType
-from django.db.models import F
 from django.http import HttpResponseForbidden, HttpResponse
 from django.shortcuts import get_object_or_404
 from drf_yasg import openapi
 from drf_yasg.openapi import Parameter
 from drf_yasg.utils import swagger_auto_schema
 from rest_framework.decorators import action
-from rest_framework.mixins import ListModelMixin
 from rest_framework.response import Response
 from rest_framework.routers import APIRootView
-from rest_framework.viewsets import GenericViewSet, ViewSet
+from rest_framework.viewsets import ViewSet
 
 from circuits.models import Circuit
 from dcim import filtersets
@@ -53,6 +50,13 @@ class PathEndpointMixin(object):
         # Initialize the path array
         path = []
 
+        if request.GET.get('render', None) == 'svg':
+            # Render SVG
+            drawing = obj.get_trace_svg(
+                base_url=request.build_absolute_uri('/')
+            )
+            return HttpResponse(drawing.tostring(), content_type='image/svg+xml')
+
         for near_end, cable, far_end in obj.trace():
             if near_end is None:
                 # Split paths

+ 0 - 233
netbox/dcim/elevations.py

@@ -1,233 +0,0 @@
-import svgwrite
-
-from django.conf import settings
-from django.urls import reverse
-from django.utils.http import urlencode
-
-from utilities.utils import foreground_color
-from .choices import DeviceFaceChoices
-from .constants import RACK_ELEVATION_BORDER_WIDTH
-
-
-class RackElevationSVG:
-    """
-    Use this class to render a rack elevation as an SVG image.
-
-    :param rack: A NetBox Rack instance
-    :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
-    :param include_images: If true, the SVG document will embed front/rear device face images, where available
-    :param base_url: Base URL for links within the SVG document. If none, links will be relative.
-    """
-    def __init__(self, rack, user=None, include_images=True, base_url=None):
-        self.rack = rack
-        self.include_images = include_images
-        if base_url is not None:
-            self.base_url = base_url.rstrip('/')
-        else:
-            self.base_url = ''
-
-        # Determine the subset of devices within this rack that are viewable by the user, if any
-        permitted_devices = self.rack.devices
-        if user is not None:
-            permitted_devices = permitted_devices.restrict(user, 'view')
-        self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
-
-    @staticmethod
-    def _get_device_description(device):
-        return '{} ({}) — {} {} ({}U) {} {}'.format(
-            device.name,
-            device.device_role,
-            device.device_type.manufacturer.name,
-            device.device_type.model,
-            device.device_type.u_height,
-            device.asset_tag or '',
-            device.serial or ''
-        )
-
-    @staticmethod
-    def _add_gradient(drawing, id_, color):
-        gradient = drawing.linearGradient(
-            start=(0, 0),
-            end=(0, 25),
-            spreadMethod='repeat',
-            id_=id_,
-            gradientTransform='rotate(45, 0, 0)',
-            gradientUnits='userSpaceOnUse'
-        )
-        gradient.add_stop_color(offset='0%', color='#f7f7f7')
-        gradient.add_stop_color(offset='50%', color='#f7f7f7')
-        gradient.add_stop_color(offset='50%', color=color)
-        gradient.add_stop_color(offset='100%', color=color)
-        drawing.defs.add(gradient)
-
-    @staticmethod
-    def _setup_drawing(width, height):
-        drawing = svgwrite.Drawing(size=(width, height))
-
-        # add the stylesheet
-        with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
-            drawing.defs.add(drawing.style(css_file.read()))
-
-        # add gradients
-        RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
-        RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
-        RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
-
-        return drawing
-
-    def _draw_device_front(self, drawing, device, start, end, text):
-        name = str(device)
-        if device.devicebay_count:
-            name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
-
-        color = device.device_role.color
-        link = drawing.add(
-            drawing.a(
-                href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
-                target='_top',
-                fill='black'
-            )
-        )
-        link.set_desc(self._get_device_description(device))
-        link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
-        hex_color = '#{}'.format(foreground_color(color))
-        link.add(drawing.text(str(name), insert=text, fill=hex_color))
-
-        # Embed front device type image if one exists
-        if self.include_images and device.device_type.front_image:
-            image = drawing.image(
-                href=device.device_type.front_image.url,
-                insert=start,
-                size=end,
-                class_='device-image'
-            )
-            image.fit(scale='slice')
-            link.add(image)
-
-    def _draw_device_rear(self, drawing, device, start, end, text):
-        rect = drawing.rect(start, end, class_="slot blocked")
-        rect.set_desc(self._get_device_description(device))
-        drawing.add(rect)
-        drawing.add(drawing.text(str(device), insert=text))
-
-        # Embed rear device type image if one exists
-        if self.include_images and device.device_type.rear_image:
-            image = drawing.image(
-                href=device.device_type.rear_image.url,
-                insert=start,
-                size=end,
-                class_='device-image'
-            )
-            image.fit(scale='slice')
-            drawing.add(image)
-
-    @staticmethod
-    def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
-        link = drawing.add(
-            drawing.a(
-                href='{}?{}'.format(
-                    reverse('dcim:device_add'),
-                    urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
-                ),
-                target='_top'
-            )
-        )
-        if reservation:
-            link.set_desc('{} — {} · {}'.format(
-                reservation.description, reservation.user, reservation.created
-            ))
-        link.add(drawing.rect(start, end, class_=class_))
-        link.add(drawing.text("add device", insert=text, class_='add-device'))
-
-    def merge_elevations(self, face):
-        elevation = self.rack.get_rack_units(face=face, expand_devices=False)
-        if face == DeviceFaceChoices.FACE_REAR:
-            other_face = DeviceFaceChoices.FACE_FRONT
-        else:
-            other_face = DeviceFaceChoices.FACE_REAR
-        other = self.rack.get_rack_units(face=other_face)
-
-        unit_cursor = 0
-        for u in elevation:
-            o = other[unit_cursor]
-            if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
-                u['device'] = o['device']
-                u['height'] = 1
-            unit_cursor += u.get('height', 1)
-
-        return elevation
-
-    def render(self, face, unit_width, unit_height, legend_width):
-        """
-        Return an SVG document representing a rack elevation.
-        """
-        drawing = self._setup_drawing(
-            unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
-            unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
-        )
-        reserved_units = self.rack.get_reserved_units()
-
-        unit_cursor = 0
-        for ru in range(0, self.rack.u_height):
-            start_y = ru * unit_height
-            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
-            unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
-            drawing.add(
-                drawing.text(str(unit), position_coordinates, class_="unit")
-            )
-
-        for unit in self.merge_elevations(face):
-
-            # Loop through all units in the elevation
-            device = unit['device']
-            height = unit.get('height', 1)
-
-            # Setup drawing coordinates
-            x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
-            y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
-            end_y = unit_height * height
-            start_cordinates = (x_offset, y_offset)
-            end_cordinates = (unit_width, end_y)
-            text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
-
-            # Draw the device
-            if device and device.face == face and device.pk in self.permitted_device_ids:
-                self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
-            elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
-                self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
-            elif device:
-                # Devices which the user does not have permission to view are rendered only as unavailable space
-                drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
-            else:
-                # Draw shallow devices, reservations, or empty units
-                class_ = 'slot'
-                reservation = reserved_units.get(unit["id"])
-                if device:
-                    class_ += ' occupied'
-                if reservation:
-                    class_ += ' reserved'
-                self._draw_empty(
-                    drawing,
-                    self.rack,
-                    start_cordinates,
-                    end_cordinates,
-                    text_cordinates,
-                    unit["id"],
-                    face,
-                    class_,
-                    reservation
-                )
-
-            unit_cursor += height
-
-        # Wrap the drawing with a border
-        border_width = RACK_ELEVATION_BORDER_WIDTH
-        border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
-        frame = drawing.rect(
-            insert=(legend_width + border_offset, border_offset),
-            size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
-            class_='rack'
-        )
-        drawing.add(frame)
-
-        return drawing

+ 5 - 0
netbox/dcim/models/device_components.py

@@ -10,6 +10,7 @@ from mptt.models import MPTTModel, TreeForeignKey
 from dcim.choices import *
 from dcim.constants import *
 from dcim.fields import MACAddressField
+from dcim.svg import CableTraceSVG
 from extras.utils import extras_features
 from netbox.models import PrimaryModel
 from utilities.fields import ColorField, NaturalOrderingField
@@ -193,6 +194,10 @@ class PathEndpoint(models.Model):
         # Return the path as a list of three-tuples (A termination, cable, B termination)
         return list(zip(*[iter(path)] * 3))
 
+    def get_trace_svg(self, base_url=None):
+        trace = CableTraceSVG(self, base_url=base_url)
+        return trace.render()
+
     @property
     def path(self):
         return self._path

+ 1 - 1
netbox/dcim/models/racks.py

@@ -13,7 +13,7 @@ from django.urls import reverse
 
 from dcim.choices import *
 from dcim.constants import *
-from dcim.elevations import RackElevationSVG
+from dcim.svg import RackElevationSVG
 from extras.utils import extras_features
 from netbox.models import OrganizationalModel, PrimaryModel
 from utilities.choices import ColorChoices

+ 506 - 0
netbox/dcim/svg.py

@@ -0,0 +1,506 @@
+import svgwrite
+from svgwrite.container import Group, Hyperlink
+from svgwrite.shapes import Line, Rect
+from svgwrite.text import Text
+
+from django.conf import settings
+from django.urls import reverse
+from django.utils.http import urlencode
+
+from utilities.utils import foreground_color
+from .choices import DeviceFaceChoices
+from .constants import RACK_ELEVATION_BORDER_WIDTH
+
+
+__all__ = (
+    'CableTraceSVG',
+    'RackElevationSVG',
+)
+
+
+class RackElevationSVG:
+    """
+    Use this class to render a rack elevation as an SVG image.
+
+    :param rack: A NetBox Rack instance
+    :param user: User instance. If specified, only devices viewable by this user will be fully displayed.
+    :param include_images: If true, the SVG document will embed front/rear device face images, where available
+    :param base_url: Base URL for links within the SVG document. If none, links will be relative.
+    """
+    def __init__(self, rack, user=None, include_images=True, base_url=None):
+        self.rack = rack
+        self.include_images = include_images
+        if base_url is not None:
+            self.base_url = base_url.rstrip('/')
+        else:
+            self.base_url = ''
+
+        # Determine the subset of devices within this rack that are viewable by the user, if any
+        permitted_devices = self.rack.devices
+        if user is not None:
+            permitted_devices = permitted_devices.restrict(user, 'view')
+        self.permitted_device_ids = permitted_devices.values_list('pk', flat=True)
+
+    @staticmethod
+    def _get_device_description(device):
+        return '{} ({}) — {} {} ({}U) {} {}'.format(
+            device.name,
+            device.device_role,
+            device.device_type.manufacturer.name,
+            device.device_type.model,
+            device.device_type.u_height,
+            device.asset_tag or '',
+            device.serial or ''
+        )
+
+    @staticmethod
+    def _add_gradient(drawing, id_, color):
+        gradient = drawing.linearGradient(
+            start=(0, 0),
+            end=(0, 25),
+            spreadMethod='repeat',
+            id_=id_,
+            gradientTransform='rotate(45, 0, 0)',
+            gradientUnits='userSpaceOnUse'
+        )
+        gradient.add_stop_color(offset='0%', color='#f7f7f7')
+        gradient.add_stop_color(offset='50%', color='#f7f7f7')
+        gradient.add_stop_color(offset='50%', color=color)
+        gradient.add_stop_color(offset='100%', color=color)
+        drawing.defs.add(gradient)
+
+    @staticmethod
+    def _setup_drawing(width, height):
+        drawing = svgwrite.Drawing(size=(width, height))
+
+        # add the stylesheet
+        with open('{}/rack_elevation.css'.format(settings.STATIC_ROOT)) as css_file:
+            drawing.defs.add(drawing.style(css_file.read()))
+
+        # add gradients
+        RackElevationSVG._add_gradient(drawing, 'reserved', '#c7c7ff')
+        RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7')
+        RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0')
+
+        return drawing
+
+    def _draw_device_front(self, drawing, device, start, end, text):
+        name = str(device)
+        if device.devicebay_count:
+            name += ' ({}/{})'.format(device.get_children().count(), device.devicebay_count)
+
+        color = device.device_role.color
+        link = drawing.add(
+            drawing.a(
+                href='{}{}'.format(self.base_url, reverse('dcim:device', kwargs={'pk': device.pk})),
+                target='_top',
+                fill='black'
+            )
+        )
+        link.set_desc(self._get_device_description(device))
+        link.add(drawing.rect(start, end, style='fill: #{}'.format(color), class_='slot'))
+        hex_color = '#{}'.format(foreground_color(color))
+        link.add(drawing.text(str(name), insert=text, fill=hex_color))
+
+        # Embed front device type image if one exists
+        if self.include_images and device.device_type.front_image:
+            image = drawing.image(
+                href=device.device_type.front_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
+            image.fit(scale='slice')
+            link.add(image)
+
+    def _draw_device_rear(self, drawing, device, start, end, text):
+        rect = drawing.rect(start, end, class_="slot blocked")
+        rect.set_desc(self._get_device_description(device))
+        drawing.add(rect)
+        drawing.add(drawing.text(str(device), insert=text))
+
+        # Embed rear device type image if one exists
+        if self.include_images and device.device_type.rear_image:
+            image = drawing.image(
+                href=device.device_type.rear_image.url,
+                insert=start,
+                size=end,
+                class_='device-image'
+            )
+            image.fit(scale='slice')
+            drawing.add(image)
+
+    @staticmethod
+    def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_, reservation):
+        link = drawing.add(
+            drawing.a(
+                href='{}?{}'.format(
+                    reverse('dcim:device_add'),
+                    urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_})
+                ),
+                target='_top'
+            )
+        )
+        if reservation:
+            link.set_desc('{} — {} · {}'.format(
+                reservation.description, reservation.user, reservation.created
+            ))
+        link.add(drawing.rect(start, end, class_=class_))
+        link.add(drawing.text("add device", insert=text, class_='add-device'))
+
+    def merge_elevations(self, face):
+        elevation = self.rack.get_rack_units(face=face, expand_devices=False)
+        if face == DeviceFaceChoices.FACE_REAR:
+            other_face = DeviceFaceChoices.FACE_FRONT
+        else:
+            other_face = DeviceFaceChoices.FACE_REAR
+        other = self.rack.get_rack_units(face=other_face)
+
+        unit_cursor = 0
+        for u in elevation:
+            o = other[unit_cursor]
+            if not u['device'] and o['device'] and o['device'].device_type.is_full_depth:
+                u['device'] = o['device']
+                u['height'] = 1
+            unit_cursor += u.get('height', 1)
+
+        return elevation
+
+    def render(self, face, unit_width, unit_height, legend_width):
+        """
+        Return an SVG document representing a rack elevation.
+        """
+        drawing = self._setup_drawing(
+            unit_width + legend_width + RACK_ELEVATION_BORDER_WIDTH * 2,
+            unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2
+        )
+        reserved_units = self.rack.get_reserved_units()
+
+        unit_cursor = 0
+        for ru in range(0, self.rack.u_height):
+            start_y = ru * unit_height
+            position_coordinates = (legend_width / 2, start_y + unit_height / 2 + RACK_ELEVATION_BORDER_WIDTH)
+            unit = ru + 1 if self.rack.desc_units else self.rack.u_height - ru
+            drawing.add(
+                drawing.text(str(unit), position_coordinates, class_="unit")
+            )
+
+        for unit in self.merge_elevations(face):
+
+            # Loop through all units in the elevation
+            device = unit['device']
+            height = unit.get('height', 1)
+
+            # Setup drawing coordinates
+            x_offset = legend_width + RACK_ELEVATION_BORDER_WIDTH
+            y_offset = unit_cursor * unit_height + RACK_ELEVATION_BORDER_WIDTH
+            end_y = unit_height * height
+            start_cordinates = (x_offset, y_offset)
+            end_cordinates = (unit_width, end_y)
+            text_cordinates = (x_offset + (unit_width / 2), y_offset + end_y / 2)
+
+            # Draw the device
+            if device and device.face == face and device.pk in self.permitted_device_ids:
+                self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates)
+            elif device and device.device_type.is_full_depth and device.pk in self.permitted_device_ids:
+                self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates)
+            elif device:
+                # Devices which the user does not have permission to view are rendered only as unavailable space
+                drawing.add(drawing.rect(start_cordinates, end_cordinates, class_='blocked'))
+            else:
+                # Draw shallow devices, reservations, or empty units
+                class_ = 'slot'
+                reservation = reserved_units.get(unit["id"])
+                if device:
+                    class_ += ' occupied'
+                if reservation:
+                    class_ += ' reserved'
+                self._draw_empty(
+                    drawing,
+                    self.rack,
+                    start_cordinates,
+                    end_cordinates,
+                    text_cordinates,
+                    unit["id"],
+                    face,
+                    class_,
+                    reservation
+                )
+
+            unit_cursor += height
+
+        # Wrap the drawing with a border
+        border_width = RACK_ELEVATION_BORDER_WIDTH
+        border_offset = RACK_ELEVATION_BORDER_WIDTH / 2
+        frame = drawing.rect(
+            insert=(legend_width + border_offset, border_offset),
+            size=(unit_width + border_width, self.rack.u_height * unit_height + border_width),
+            class_='rack'
+        )
+        drawing.add(frame)
+
+        return drawing
+
+
+OFFSET = 0.5
+PADDING = 10
+LINE_HEIGHT = 20
+
+
+class CableTraceSVG:
+    """
+    Generate a graphical representation of a CablePath in SVG format.
+
+    :param origin: The originating termination
+    :param width: Width of the generated image (in pixels)
+    :param base_url: Base URL for links within the SVG document. If none, links will be relative.
+    """
+    def __init__(self, origin, width=400, base_url=None):
+        self.origin = origin
+        self.width = width
+        self.base_url = base_url.rstrip('/') if base_url is not None else ''
+
+        # Establish a cursor to track position on the y axis
+        # Center edges on pixels to render sharp borders
+        self.cursor = OFFSET
+
+    @property
+    def center(self):
+        return self.width / 2
+
+    @classmethod
+    def _get_labels(cls, instance):
+        """
+        Return a list of text labels for the given instance based on model type.
+        """
+        labels = [str(instance)]
+        if instance._meta.model_name == 'device':
+            labels.append(f'{instance.device_type.manufacturer} {instance.device_type}')
+            location_label = f'{instance.site}'
+            if instance.location:
+                location_label += f' / {instance.location}'
+            if instance.rack:
+                location_label += f' / {instance.rack}'
+            labels.append(location_label)
+        elif instance._meta.model_name == 'circuit':
+            labels[0] = f'Circuit {instance}'
+            labels.append(instance.provider)
+        elif instance._meta.model_name == 'circuittermination':
+            if instance.xconnect_id:
+                labels.append(f'{instance.xconnect_id}')
+        elif instance._meta.model_name == 'providernetwork':
+            labels.append(instance.provider)
+
+        return labels
+
+    @classmethod
+    def _get_color(cls, instance):
+        """
+        Return the appropriate fill color for an object within a cable path.
+        """
+        if hasattr(instance, 'parent_object'):
+            # Termination
+            return 'f0f0f0'
+        if hasattr(instance, 'device_role'):
+            # Device
+            return instance.device_role.color
+        else:
+            # Other parent object
+            return 'e0e0e0'
+
+    def _draw_box(self, width, color, url, labels, y_indent=0, padding_multiplier=1, radius=10):
+        """
+        Return an SVG Link element containing a Rect and one or more text labels representing a
+        parent object or cable termination point.
+
+        :param width: Box width
+        :param color: Box fill color
+        :param url: Hyperlink URL
+        :param labels: Iterable of text labels
+        :param y_indent: Vertical indent (for overlapping other boxes) (default: 0)
+        :param padding_multiplier: Add extra vertical padding (default: 1)
+        :param radius: Box corner radius (default: 10)
+        """
+        self.cursor -= y_indent
+
+        # Create a hyperlink
+        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+
+        # Add the box
+        position = (
+            OFFSET + (self.width - width) / 2,
+            self.cursor
+        )
+        height = PADDING * padding_multiplier \
+            + LINE_HEIGHT * len(labels) \
+            + PADDING * padding_multiplier
+        box = Rect(position, (width - 2, height), rx=radius, class_='parent-object', style=f'fill: #{color}')
+        link.add(box)
+        self.cursor += PADDING * padding_multiplier
+
+        # Add text label(s)
+        for i, label in enumerate(labels):
+            self.cursor += LINE_HEIGHT
+            text_coords = (self.center, self.cursor - LINE_HEIGHT / 2)
+            text_color = f'#{foreground_color(color, dark="303030")}'
+            text = Text(label, insert=text_coords, fill=text_color, class_='bold' if not i else [])
+            link.add(text)
+
+        self.cursor += PADDING * padding_multiplier
+
+        return link
+
+    def _draw_cable(self, color, url, labels):
+        """
+        Return an SVG group containing a line element and text labels representing a Cable.
+
+        :param color: Cable (line) color
+        :param url: Hyperlink URL
+        :param labels: Iterable of text labels
+        """
+        group = Group(class_='connector')
+
+        # Draw a "shadow" line to give the cable a border
+        start = (OFFSET + self.center, self.cursor)
+        height = PADDING * 2 + LINE_HEIGHT * len(labels) + PADDING * 2
+        end = (start[0], start[1] + height)
+        cable_shadow = Line(start=start, end=end, class_='cable-shadow')
+        group.add(cable_shadow)
+
+        # Draw the cable
+        cable = Line(start=start, end=end, style=f'stroke: #{color}')
+        group.add(cable)
+
+        self.cursor += PADDING * 2
+
+        # Add link
+        link = Hyperlink(href=f'{self.base_url}{url}', target='_blank')
+
+        # 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_attachment(self):
+        """
+        Return an SVG group containing a line element and "Attachment" label.
+        """
+        group = Group(class_='connector')
+
+        # Draw attachment (line)
+        start = (OFFSET + self.center, OFFSET + self.cursor)
+        height = PADDING * 2 + LINE_HEIGHT + PADDING * 2
+        end = (start[0], start[1] + height)
+        line = Line(start=start, end=end, class_='attachment')
+        group.add(line)
+        self.cursor += PADDING * 4
+
+        return group
+
+    def render(self):
+        """
+        Return an SVG document representing a cable trace.
+        """
+        traced_path = self.origin.trace()
+
+        # Prep elements list
+        parent_objects = []
+        terminations = []
+        connectors = []
+
+        # Iterate through each (term, cable, term) segment in the path
+        for i, segment in enumerate(traced_path):
+            near_end, connector, far_end = segment
+
+            # Near end parent
+            if i == 0:
+                # If this is the first segment, draw the originating termination's parent object
+                parent_object = self._draw_box(
+                    width=self.width,
+                    color=self._get_color(near_end.parent_object),
+                    url=near_end.parent_object.get_absolute_url(),
+                    labels=self._get_labels(near_end.parent_object),
+                    padding_multiplier=2
+                )
+                parent_objects.append(parent_object)
+
+            # Near end termination
+            termination = self._draw_box(
+                width=self.width * .8,
+                color=self._get_color(near_end),
+                url=near_end.get_absolute_url(),
+                labels=self._get_labels(near_end),
+                y_indent=PADDING,
+                radius=5
+            )
+            terminations.append(termination)
+
+            # Connector (either a Cable or attachment to a ProviderNetwork)
+            if connector is not None:
+
+                # Cable
+                cable = self._draw_cable(
+                    color=connector.color or '000000',
+                    url=connector.get_absolute_url(),
+                    labels=[f'Cable {connector}', connector.get_status_display()]
+                )
+                connectors.append(cable)
+
+                # Far end termination
+                termination = self._draw_box(
+                    width=self.width * .8,
+                    color=self._get_color(far_end),
+                    url=far_end.get_absolute_url(),
+                    labels=self._get_labels(far_end),
+                    radius=5
+                )
+                terminations.append(termination)
+
+                # Far end parent
+                parent_object = self._draw_box(
+                    width=self.width,
+                    color=self._get_color(far_end.parent_object),
+                    url=far_end.parent_object.get_absolute_url(),
+                    labels=self._get_labels(far_end.parent_object),
+                    y_indent=PADDING,
+                    padding_multiplier=2
+                )
+                parent_objects.append(parent_object)
+
+            else:
+
+                # Attachment
+                attachment = self._draw_attachment()
+                connectors.append(attachment)
+
+                # ProviderNetwork
+                parent_object = self._draw_box(
+                    width=self.width,
+                    color=self._get_color(far_end),
+                    url=far_end.get_absolute_url(),
+                    labels=self._get_labels(far_end),
+                    padding_multiplier=2
+                )
+                parent_objects.append(parent_object)
+
+        # Determine drawing size
+        self.drawing = svgwrite.Drawing(
+            size=(self.width, self.cursor + 2)
+        )
+
+        # Attach CSS stylesheet
+        with open(f'{settings.STATIC_ROOT}/cable_trace.css') as css_file:
+            self.drawing.defs.add(self.drawing.style(css_file.read()))
+
+        # Add elements to the drawing in order of depth (Z axis)
+        for element in connectors + parent_objects + terminations:
+            self.drawing.add(element)
+
+        return self.drawing

+ 8 - 4
netbox/dcim/views.py

@@ -1,15 +1,14 @@
 import logging
-from copy import deepcopy
 from collections import OrderedDict
 
 from django.contrib import messages
 from django.contrib.contenttypes.models import ContentType
-from django.core.exceptions import ObjectDoesNotExist
 from django.core.paginator import EmptyPage, PageNotAnInteger
 from django.db import transaction
 from django.db.models import F, Prefetch
 from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
 from django.shortcuts import get_object_or_404, redirect, render
+from django.urls import reverse
 from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.views.generic import View
@@ -23,7 +22,7 @@ from utilities.forms import ConfirmationForm
 from utilities.paginator import EnhancedPaginator, get_paginate_count
 from utilities.permissions import get_permission_for_model
 from utilities.tables import paginate_table
-from utilities.utils import csv_format, count_related
+from utilities.utils import count_related
 from utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
 from virtualization.models import VirtualMachine
 from . import filtersets, forms, tables
@@ -2423,11 +2422,16 @@ class PathTraceView(generic.ObjectView):
         # Get the total length of the cable and whether the length is definitive (fully defined)
         total_length, is_definitive = path.get_total_length() if path else (None, False)
 
+        # Determine the path to the SVG trace image
+        api_viewname = f"{path.origin._meta.app_label}-api:{path.origin._meta.model_name}-trace"
+        svg_url = f"{reverse(api_viewname, kwargs={'pk': path.origin.pk})}?render=svg"
+
         return {
             'path': path,
             'related_paths': related_paths,
             'total_length': total_length,
-            'is_definitive': is_definitive
+            'is_definitive': is_definitive,
+            'svg_url': svg_url,
         }
 
 

+ 1 - 0
netbox/project-static/bundle.js

@@ -31,6 +31,7 @@ const styles = [
   ['styles/_light.scss', 'netbox-light.css'],
   ['styles/_dark.scss', 'netbox-dark.css'],
   ['styles/_elevations.scss', 'rack_elevation.css'],
+  ['styles/_cable_trace.scss', 'cable_trace.css'],
 ];
 
 // Script (JavaScript) bundle jobs. Generally, everything should be bundled into netbox.js from

+ 2 - 0
netbox/project-static/dist/cable_trace.css

@@ -0,0 +1,2 @@
+*{font-family:sans-serif;font-size:14px}text{text-anchor:middle;dominant-baseline:middle}text.bold{font-weight:700}svg rect{fill:#e0e0e0;stroke:#606060;stroke-width:1}svg rect .termination{fill:#f0f0f0}svg .connector text{text-anchor:start}svg line{stroke-width:5px}svg line.cable-shadow{stroke:#303030;stroke-width:7px}svg line.attachment{stroke:silver;stroke-dasharray:5px,5px}
+/*# sourceMappingURL=/static/cable_trace.css.map */

+ 1 - 0
netbox/project-static/dist/cable_trace.css.map

@@ -0,0 +1 @@
+{"version":3,"sources":["_cable_trace.scss"],"names":[],"mappings":"AAAA,EACI,sBAAA,CACA,eAEJ,KACI,kBAAA,CACA,yBAEJ,UACE,gBAKA,SACE,YAAA,CACA,cAAA,CACA,eACA,sBACE,aAKJ,oBACE,kBAEF,SACE,iBAEF,sBACE,cAAA,CACA,iBAEF,oBACE,aAAA,CACA","file":"cable_trace.css","sourceRoot":"../styles","sourcesContent":["* {\n    font-family: sans-serif;\n    font-size: 14px;\n}\ntext {\n    text-anchor: middle;\n    dominant-baseline: middle;\n}\ntext.bold {\n  font-weight: bold;\n}\n\nsvg {\n  /* Boxes */\n  rect {\n    fill: #e0e0e0;\n    stroke: #606060;\n    stroke-width: 1;\n    .termination {\n      fill: #f0f0f0;\n    }\n  }\n\n  /* Connectors */\n  .connector text {\n    text-anchor: start;\n  }\n  line {\n    stroke-width: 5px;\n  }\n  line.cable-shadow {\n    stroke: #303030;\n    stroke-width: 7px;\n  }\n  line.attachment {\n    stroke: #c0c0c0;\n    stroke-dasharray: 5px,5px;\n  }\n}\n"]}

File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-dark.css


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-dark.css.map


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-light.css


File diff ditekan karena terlalu besar
+ 0 - 0
netbox/project-static/dist/netbox-light.css.map


+ 39 - 0
netbox/project-static/styles/_cable_trace.scss

@@ -0,0 +1,39 @@
+* {
+    font-family: sans-serif;
+    font-size: 14px;
+}
+text {
+    text-anchor: middle;
+    dominant-baseline: middle;
+}
+text.bold {
+  font-weight: bold;
+}
+
+svg {
+  /* Boxes */
+  rect {
+    fill: #e0e0e0;
+    stroke: #606060;
+    stroke-width: 1;
+    .termination {
+      fill: #f0f0f0;
+    }
+  }
+
+  /* Connectors */
+  .connector text {
+    text-anchor: start;
+  }
+  line {
+    stroke-width: 5px;
+  }
+  line.cable-shadow {
+    stroke: #303030;
+    stroke-width: 7px;
+  }
+  line.attachment {
+    stroke: #c0c0c0;
+    stroke-dasharray: 5px,5px;
+  }
+}

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

@@ -824,47 +824,6 @@ table tbody {
   }
 }
 
-// Cable Tracing
-.cable-trace {
-  max-width: 38rem;
-  margin: 1rem auto;
-  text-align: center;
-}
-.cable-trace .node {
-  background-color: var(--nbx-cable-node-bg);
-  border: $border-width solid var(--nbx-cable-node-border-color);
-  border-radius: $border-radius;
-  padding: 1.5rem 1rem;
-  position: relative;
-  z-index: 1;
-}
-.cable-trace .termination {
-  background-color: var(--nbx-cable-termination-bg);
-  border: $border-width solid var(--nbx-cable-termination-border-color);
-  box-shadow: $box-shadow;
-  border-radius: $border-radius;
-  margin: -1rem auto;
-  padding: 0.5rem;
-  position: relative;
-  width: 60%;
-  z-index: 2;
-}
-.cable-trace .active {
-  border: 0.25rem solid $success;
-}
-.cable-trace .cable {
-  border-left-style: solid;
-  border-left-width: 0.25rem;
-  margin: 1rem 0 1rem 50%;
-  padding: 1.5rem;
-  text-align: left;
-  width: 50%;
-}
-.cable-trace .trace-end {
-  margin-top: 2rem;
-  text-align: center;
-}
-
 pre.change-data {
   padding-left: 0;
   padding-right: 0;

+ 38 - 77
netbox/templates/dcim/cable_trace.html

@@ -1,89 +1,50 @@
 {% extends 'base/layout.html' %}
 {% load helpers %}
 
-{% block header %}
-    <h1>{% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}</h1>
-{% endblock %}
+{% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}
 
 {% block content %}
     <div class="row">
         <div class="col col-md-5">
+            <object data="{{ svg_url }}" class="rack_elevation"></object>
+            <div class="text-center mt-3">
+                <a class="btn btn-outline-primary btn-sm" href="{{ svg_url }}">
+                    <i class="mdi mdi-file-download"></i> Download SVG
+                </a>
+            </div>
             <div class="cable-trace">
                 {% with traced_path=path.origin.trace %}
-                    {% for near_end, cable, far_end in traced_path %}
-
-                        {# Near end #}
-                        {% if near_end.device %}
-                            {% include 'dcim/trace/device.html' with device=near_end.device %}
-                            {% include 'dcim/trace/termination.html' with termination=near_end %}
-                        {% elif near_end.power_panel %}
-                            {% include 'dcim/trace/powerpanel.html' with powerpanel=near_end.power_panel %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end%}
-                        {% elif near_end.circuit %}
-                            {% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %}
-                            {% include 'dcim/trace/termination.html' with termination=near_end %}
-                        {% endif %}
-
-                        {# Cable #}
-                        {% if cable %}
-                            {% include 'dcim/trace/cable.html' %}
-                        {% elif far_end %}
-                            {% include 'dcim/trace/attachment.html' %}
-                        {% endif %}
-
-                        {# Far end #}
-                        {% if far_end.device %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end %}
-                            {% if forloop.last %}
-                                {% include 'dcim/trace/device.html' with device=far_end.device %}
-                            {% endif %}
-                        {% elif far_end.power_panel %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end %}
-                            {% include 'dcim/trace/powerpanel.html' with powerpanel=far_end.power_panel %}
-                        {% elif far_end.circuit %}
-                            {% include 'dcim/trace/termination.html' with termination=far_end %}
-                            {% if forloop.last %}
-                                {% include 'dcim/trace/circuit.html' with circuit=far_end.circuit %}
-                            {% endif %}
-                        {% elif far_end %}
-                            {% include 'dcim/trace/object.html' with object=far_end %}
-                        {% endif %}
-
-                        {% if forloop.last %}
-                            {% if path.is_split %}
-                                <div class="trace-end">
-                                    <h3 class="text-danger">Path split!</h3>
-                                    <p>Select a node below to continue:</p>
-                                    <ul class="text-start">
-                                        {% for next_node in path.get_split_nodes %}
-                                            {% if next_node.cable %}
-                                                <li>
-                                                    <a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
-                                                    (Cable <a href="{{ next_node.cable.get_absolute_url }}">{{ next_node.cable }}</a>)
-                                                </li>
-                                            {% else %}
-                                                <li class="text-muted">{{ next_node }}</li>
-                                            {% endif %}
-                                        {% endfor %}
-                                    </ul>
-                                </div>
-                            {% else %}
-                                <div class="trace-end">
-                                    <h3{% if far_end %} class="text-success"{% endif %}>Trace Completed</h3>
-                                    <h5>Total Segments: {{ traced_path|length }}</h5>
-                                    <h5>Total Length:
-                                        {% if total_length %}
-                                            {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
-                                            {{ total_length|meters_to_feet|floatformat:"-2" }} Feet
-                                        {% else %}
-                                            <span class="text-muted">N/A</span>
-                                        {% endif %}
-                                    </h5>
-                                </div>
-                            {% endif %}
-                        {% endif %}
-
-                    {% endfor %}
+                    {% if path.is_split %}
+                        <div class="trace-end">
+                            <h3 class="text-danger">Path split!</h3>
+                            <p>Select a node below to continue:</p>
+                            <ul class="text-start">
+                                {% for next_node in path.get_split_nodes %}
+                                    {% if next_node.cable %}
+                                        <li>
+                                            <a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
+                                            (Cable <a href="{{ next_node.cable.get_absolute_url }}">{{ next_node.cable }}</a>)
+                                        </li>
+                                    {% else %}
+                                        <li class="text-muted">{{ next_node }}</li>
+                                    {% endif %}
+                                {% endfor %}
+                            </ul>
+                        </div>
+                    {% else %}
+                        <div class="trace-end">
+                            <h3 class="text-success">Trace Completed</h3>
+                            <h5>Total Segments: {{ traced_path|length }}</h5>
+                            <h5>Total Length:
+                                {% if total_length %}
+                                    {{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
+                                    {{ total_length|meters_to_feet|floatformat:"-2" }} Feet
+                                {% else %}
+                                    <span class="text-muted">N/A</span>
+                                {% endif %}
+                            </h5>
+                        </div>
+                    {% endif %}
                 {% endwith %}
             </div>
         </div>

+ 7 - 4
netbox/utilities/utils.py

@@ -44,16 +44,19 @@ def csv_format(data):
     return ','.join(csv)
 
 
-def foreground_color(bg_color):
+def foreground_color(bg_color, dark='000000', light='ffffff'):
     """
-    Return the ideal foreground color (black or white) for a given background color in hexadecimal RGB format.
+    Return the ideal foreground color (dark or light) for a given background color in hexadecimal RGB format.
+
+    :param dark: RBG color code for dark text
+    :param light: RBG color code for light text
     """
     bg_color = bg_color.strip('#')
     r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
     if r * 0.299 + g * 0.587 + b * 0.114 > 186:
-        return '000000'
+        return dark
     else:
-        return 'ffffff'
+        return light
 
 
 def dynamic_import(name):

Beberapa file tidak ditampilkan karena terlalu banyak file yang berubah dalam diff ini