mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-20 02:06:42 -06:00
Merge pull request #6755 from netbox-community/6000-cable-trace-svg
Closes #6000: SVG rendering for cable tracing
This commit is contained in:
commit
2bfdaf08ee
@ -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
|
||||
|
@ -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
|
@ -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
|
||||
|
506
netbox/dcim/svg.py
Normal file
506
netbox/dcim/svg.py
Normal file
@ -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
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
@ -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
|
||||
|
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,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"]}
|
BIN
netbox/project-static/dist/netbox-dark.css
vendored
BIN
netbox/project-static/dist/netbox-dark.css
vendored
Binary file not shown.
File diff suppressed because one or more lines are too long
BIN
netbox/project-static/dist/netbox-light.css
vendored
BIN
netbox/project-static/dist/netbox-light.css
vendored
Binary file not shown.
File diff suppressed because one or more lines are too long
39
netbox/project-static/styles/_cable_trace.scss
Normal file
39
netbox/project-static/styles/_cable_trace.scss
Normal file
@ -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;
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -1,55 +1,19 @@
|
||||
{% 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>
|
||||
@ -69,7 +33,7 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="trace-end">
|
||||
<h3{% if far_end %} class="text-success"{% endif %}>Trace Completed</h3>
|
||||
<h3 class="text-success">Trace Completed</h3>
|
||||
<h5>Total Segments: {{ traced_path|length }}</h5>
|
||||
<h5>Total Length:
|
||||
{% if total_length %}
|
||||
@ -81,9 +45,6 @@
|
||||
</h5>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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):
|
||||
|
Loading…
Reference in New Issue
Block a user