mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-22 12:06:53 -06:00
Initial work on SVG support for cable tracing
This commit is contained in:
parent
faa993acfb
commit
ce7fa95546
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
25
netbox/project-static/_cable_trace.scss
Normal file
25
netbox/project-static/_cable_trace.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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
|
||||
|
BIN
netbox/project-static/dist/cable_trace.css
vendored
Normal file
BIN
netbox/project-static/dist/cable_trace.css
vendored
Normal file
Binary file not shown.
1
netbox/project-static/dist/cable_trace.css.map
vendored
Normal file
1
netbox/project-static/dist/cable_trace.css.map
vendored
Normal file
@ -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"]}
|
Loading…
Reference in New Issue
Block a user