diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 9592dabae..b3bac61d7 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -53,6 +53,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 diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 84a78c525..6e416e8f7 100644 --- a/netbox/dcim/models/device_components.py +++ b/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 diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 3370badc3..3b74a3d3d 100644 --- a/netbox/dcim/models/racks.py +++ b/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 diff --git a/netbox/dcim/elevations.py b/netbox/dcim/svg.py similarity index 55% rename from netbox/dcim/elevations.py rename to netbox/dcim/svg.py index 473b9608f..4676b474f 100644 --- a/netbox/dcim/elevations.py +++ b/netbox/dcim/svg.py @@ -1,4 +1,7 @@ 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 @@ -9,6 +12,12 @@ 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. @@ -231,3 +240,218 @@ class RackElevationSVG: drawing.add(frame) return drawing + + +OFFSET = 0.5 +PADDING = 10 +LINE_HEIGHT = 15 + + +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. + """ + PARENT_OBJECT_DEFAULT_COLOR = 'd0d0d0' + TERMINATION_DEFAULT_COLOR = 'c0c0c0' + + 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 hasattr(instance, 'device_type'): + labels.append(str(instance.device_type)) + elif hasattr(instance, 'provider'): + 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 cls.TERMINATION_DEFAULT_COLOR + if hasattr(instance, 'device_role'): + # Device + return instance.device_role.color + else: + # Other parent object + return cls.PARENT_OBJECT_DEFAULT_COLOR + + 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)}' + text = Text(label, insert=text_coords, fill=text_color) + 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() + + # Draw cable (line) + 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, style=f'stroke: #{color}') + group.add(line) + 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_='cable') + link.add(text) + + group.add(link) + self.cursor += PADDING * 2 + + return group + + def render(self): + """ + Return an SVG document representing a cable trace. + """ + traced_path = self.origin.trace() + + # Prep elements list + parent_objects = [] + terminations = [] + cables = [] + + # Iterate through each (term, cable, term) segment in the path + for i, segment in enumerate(traced_path): + near_end, cable, 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=[str(near_end)], + y_indent=PADDING, + radius=5 + ) + terminations.append(termination) + + # Cable + cable = self._draw_cable( + color=cable.color or '000000', + url=cable.get_absolute_url(), + labels=[f'Cable {cable}', cable.get_status_display()] + ) + cables.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=[str(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) + + # Determine drawing size + self.drawing = svgwrite.Drawing( + size=(self.width, self.cursor) + ) + + # 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 parent_objects + terminations + cables: + self.drawing.add(element) + + return self.drawing diff --git a/netbox/project-static/_cable_trace.scss b/netbox/project-static/_cable_trace.scss new file mode 100644 index 000000000..168fd2d4f --- /dev/null +++ b/netbox/project-static/_cable_trace.scss @@ -0,0 +1,25 @@ +* { + font-family: sans-serif; + font-size: 13px; +} +text { + text-anchor: middle; + dominant-baseline: middle; +} + +svg { + /* Boxes */ + rect { + stroke: #303030; + stroke-width: 1; + } + + /* Cables */ + line { + stroke-width: 3; + } + + text.cable { + text-anchor: start; + } +} diff --git a/netbox/project-static/bundle.js b/netbox/project-static/bundle.js index b12cb4261..e5191d43b 100644 --- a/netbox/project-static/bundle.js +++ b/netbox/project-static/bundle.js @@ -31,6 +31,7 @@ const styles = [ ['_light.scss', 'netbox-light.css'], ['_dark.scss', 'netbox-dark.css'], ['_elevations.scss', 'rack_elevation.css'], + ['_cable_trace.scss', 'cable_trace.css'], ]; // Script (JavaScript) bundle jobs. Generally, everything should be bundled into netbox.js from diff --git a/netbox/project-static/dist/cable_trace.css b/netbox/project-static/dist/cable_trace.css new file mode 100644 index 000000000..352434a45 Binary files /dev/null and b/netbox/project-static/dist/cable_trace.css differ diff --git a/netbox/project-static/dist/cable_trace.css.map b/netbox/project-static/dist/cable_trace.css.map new file mode 100644 index 000000000..b420bf81b --- /dev/null +++ b/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,yBAKF,SACE,cAAA,CACA,eAIF,SACE,eAGF,eACE","file":"cable_trace.css","sourceRoot":"..","sourcesContent":["* {\n font-family: sans-serif;\n font-size: 13px;\n}\ntext {\n text-anchor: middle;\n dominant-baseline: middle;\n}\n\nsvg {\n /* Boxes */\n rect {\n stroke: #303030;\n stroke-width: 1;\n }\n\n /* Cables */\n line {\n stroke-width: 3;\n }\n\n text.cable {\n text-anchor: start;\n }\n}\n"]} \ No newline at end of file