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 collections import OrderedDict
|
||||||
|
|
||||||
from django.conf import settings
|
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.http import HttpResponseForbidden, HttpResponse
|
||||||
from django.shortcuts import get_object_or_404
|
from django.shortcuts import get_object_or_404
|
||||||
from drf_yasg import openapi
|
from drf_yasg import openapi
|
||||||
from drf_yasg.openapi import Parameter
|
from drf_yasg.openapi import Parameter
|
||||||
from drf_yasg.utils import swagger_auto_schema
|
from drf_yasg.utils import swagger_auto_schema
|
||||||
from rest_framework.decorators import action
|
from rest_framework.decorators import action
|
||||||
from rest_framework.mixins import ListModelMixin
|
|
||||||
from rest_framework.response import Response
|
from rest_framework.response import Response
|
||||||
from rest_framework.routers import APIRootView
|
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 circuits.models import Circuit
|
||||||
from dcim import filtersets
|
from dcim import filtersets
|
||||||
@ -53,6 +50,13 @@ class PathEndpointMixin(object):
|
|||||||
# Initialize the path array
|
# Initialize the path array
|
||||||
path = []
|
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():
|
for near_end, cable, far_end in obj.trace():
|
||||||
if near_end is None:
|
if near_end is None:
|
||||||
# Split paths
|
# 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.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.fields import MACAddressField
|
from dcim.fields import MACAddressField
|
||||||
|
from dcim.svg import CableTraceSVG
|
||||||
from extras.utils import extras_features
|
from extras.utils import extras_features
|
||||||
from netbox.models import PrimaryModel
|
from netbox.models import PrimaryModel
|
||||||
from utilities.fields import ColorField, NaturalOrderingField
|
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 the path as a list of three-tuples (A termination, cable, B termination)
|
||||||
return list(zip(*[iter(path)] * 3))
|
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
|
@property
|
||||||
def path(self):
|
def path(self):
|
||||||
return self._path
|
return self._path
|
||||||
|
@ -13,7 +13,7 @@ from django.urls import reverse
|
|||||||
|
|
||||||
from dcim.choices import *
|
from dcim.choices import *
|
||||||
from dcim.constants import *
|
from dcim.constants import *
|
||||||
from dcim.elevations import RackElevationSVG
|
from dcim.svg import RackElevationSVG
|
||||||
from extras.utils import extras_features
|
from extras.utils import extras_features
|
||||||
from netbox.models import OrganizationalModel, PrimaryModel
|
from netbox.models import OrganizationalModel, PrimaryModel
|
||||||
from utilities.choices import ColorChoices
|
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
|
import logging
|
||||||
from copy import deepcopy
|
|
||||||
from collections import OrderedDict
|
from collections import OrderedDict
|
||||||
|
|
||||||
from django.contrib import messages
|
from django.contrib import messages
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.core.exceptions import ObjectDoesNotExist
|
|
||||||
from django.core.paginator import EmptyPage, PageNotAnInteger
|
from django.core.paginator import EmptyPage, PageNotAnInteger
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import F, Prefetch
|
from django.db.models import F, Prefetch
|
||||||
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
from django.forms import ModelMultipleChoiceField, MultipleHiddenInput, modelformset_factory
|
||||||
from django.shortcuts import get_object_or_404, redirect, render
|
from django.shortcuts import get_object_or_404, redirect, render
|
||||||
|
from django.urls import reverse
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.views.generic import View
|
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.paginator import EnhancedPaginator, get_paginate_count
|
||||||
from utilities.permissions import get_permission_for_model
|
from utilities.permissions import get_permission_for_model
|
||||||
from utilities.tables import paginate_table
|
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 utilities.views import GetReturnURLMixin, ObjectPermissionRequiredMixin
|
||||||
from virtualization.models import VirtualMachine
|
from virtualization.models import VirtualMachine
|
||||||
from . import filtersets, forms, tables
|
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)
|
# 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)
|
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 {
|
return {
|
||||||
'path': path,
|
'path': path,
|
||||||
'related_paths': related_paths,
|
'related_paths': related_paths,
|
||||||
'total_length': total_length,
|
'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/_light.scss', 'netbox-light.css'],
|
||||||
['styles/_dark.scss', 'netbox-dark.css'],
|
['styles/_dark.scss', 'netbox-dark.css'],
|
||||||
['styles/_elevations.scss', 'rack_elevation.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
|
// 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 {
|
pre.change-data {
|
||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
@ -1,89 +1,50 @@
|
|||||||
{% extends 'base/layout.html' %}
|
{% extends 'base/layout.html' %}
|
||||||
{% load helpers %}
|
{% load helpers %}
|
||||||
|
|
||||||
{% block header %}
|
{% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}
|
||||||
<h1>{% block title %}Cable Trace for {{ object|meta:"verbose_name"|bettertitle }} {{ object }}{% endblock %}</h1>
|
|
||||||
{% endblock %}
|
|
||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col col-md-5">
|
<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">
|
<div class="cable-trace">
|
||||||
{% with traced_path=path.origin.trace %}
|
{% with traced_path=path.origin.trace %}
|
||||||
{% for near_end, cable, far_end in traced_path %}
|
{% if path.is_split %}
|
||||||
|
<div class="trace-end">
|
||||||
{# Near end #}
|
<h3 class="text-danger">Path split!</h3>
|
||||||
{% if near_end.device %}
|
<p>Select a node below to continue:</p>
|
||||||
{% include 'dcim/trace/device.html' with device=near_end.device %}
|
<ul class="text-start">
|
||||||
{% include 'dcim/trace/termination.html' with termination=near_end %}
|
{% for next_node in path.get_split_nodes %}
|
||||||
{% elif near_end.power_panel %}
|
{% if next_node.cable %}
|
||||||
{% include 'dcim/trace/powerpanel.html' with powerpanel=near_end.power_panel %}
|
<li>
|
||||||
{% include 'dcim/trace/termination.html' with termination=far_end%}
|
<a href="{% url 'dcim:frontport_trace' pk=next_node.pk %}">{{ next_node }}</a>
|
||||||
{% elif near_end.circuit %}
|
(Cable <a href="{{ next_node.cable.get_absolute_url }}">{{ next_node.cable }}</a>)
|
||||||
{% include 'dcim/trace/circuit.html' with circuit=near_end.circuit %}
|
</li>
|
||||||
{% include 'dcim/trace/termination.html' with termination=near_end %}
|
{% else %}
|
||||||
{% endif %}
|
<li class="text-muted">{{ next_node }}</li>
|
||||||
|
{% endif %}
|
||||||
{# Cable #}
|
{% endfor %}
|
||||||
{% if cable %}
|
</ul>
|
||||||
{% include 'dcim/trace/cable.html' %}
|
</div>
|
||||||
{% elif far_end %}
|
{% else %}
|
||||||
{% include 'dcim/trace/attachment.html' %}
|
<div class="trace-end">
|
||||||
{% endif %}
|
<h3 class="text-success">Trace Completed</h3>
|
||||||
|
<h5>Total Segments: {{ traced_path|length }}</h5>
|
||||||
{# Far end #}
|
<h5>Total Length:
|
||||||
{% if far_end.device %}
|
{% if total_length %}
|
||||||
{% include 'dcim/trace/termination.html' with termination=far_end %}
|
{{ total_length|floatformat:"-2" }}{% if not is_definitive %}+{% endif %} Meters /
|
||||||
{% if forloop.last %}
|
{{ total_length|meters_to_feet|floatformat:"-2" }} Feet
|
||||||
{% include 'dcim/trace/device.html' with device=far_end.device %}
|
{% else %}
|
||||||
{% endif %}
|
<span class="text-muted">N/A</span>
|
||||||
{% elif far_end.power_panel %}
|
{% endif %}
|
||||||
{% include 'dcim/trace/termination.html' with termination=far_end %}
|
</h5>
|
||||||
{% include 'dcim/trace/powerpanel.html' with powerpanel=far_end.power_panel %}
|
</div>
|
||||||
{% elif far_end.circuit %}
|
{% endif %}
|
||||||
{% 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 %}
|
|
||||||
{% endwith %}
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -44,16 +44,19 @@ def csv_format(data):
|
|||||||
return ','.join(csv)
|
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('#')
|
bg_color = bg_color.strip('#')
|
||||||
r, g, b = [int(bg_color[c:c + 2], 16) for c in (0, 2, 4)]
|
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:
|
if r * 0.299 + g * 0.587 + b * 0.114 > 186:
|
||||||
return '000000'
|
return dark
|
||||||
else:
|
else:
|
||||||
return 'ffffff'
|
return light
|
||||||
|
|
||||||
|
|
||||||
def dynamic_import(name):
|
def dynamic_import(name):
|
||||||
|
Loading…
Reference in New Issue
Block a user