mirror of
https://github.com/netbox-community/netbox.git
synced 2024-05-10 07:54:54 +00:00
For local storage, URLs will always be relative, but some custom storage backends such as S3 may return absolute ones.
600 lines
22 KiB
Python
600 lines
22 KiB
Python
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',
|
|
)
|
|
|
|
|
|
def get_device_name(device):
|
|
if device.virtual_chassis:
|
|
return f'{device.virtual_chassis.name}:{device.vc_position}'
|
|
elif device.name:
|
|
return device.name
|
|
else:
|
|
return str(device.device_type)
|
|
|
|
|
|
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 = get_device_name(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:
|
|
url = device.device_type.front_image.url
|
|
# Convert any relative URLs to absolute
|
|
if url.startswith('/'):
|
|
url = '{}{}'.format(self.base_url, url)
|
|
image = drawing.image(
|
|
href=url,
|
|
insert=start,
|
|
size=end,
|
|
class_='device-image'
|
|
)
|
|
image.fit(scale='slice')
|
|
link.add(image)
|
|
link.add(drawing.text(str(name), insert=text, stroke='black',
|
|
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
|
link.add(drawing.text(str(name), insert=text, fill='white', class_='device-image-label'))
|
|
|
|
def _draw_device_rear(self, drawing, device, start, end, text):
|
|
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, class_="slot blocked"))
|
|
link.add(drawing.text(get_device_name(device), insert=text))
|
|
|
|
# Embed rear device type image if one exists
|
|
if self.include_images and device.device_type.rear_image:
|
|
url = device.device_type.rear_image.url
|
|
# Convert any relative URLs to absolute
|
|
if url.startswith('/'):
|
|
url = '{}{}'.format(self.base_url, url)
|
|
image = drawing.image(
|
|
href=url,
|
|
insert=start,
|
|
size=end,
|
|
class_='device-image'
|
|
)
|
|
image.fit(scale='slice')
|
|
link.add(image)
|
|
link.add(drawing.text(get_device_name(device), insert=text, stroke='black',
|
|
stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label'))
|
|
link.add(drawing.text(get_device_name(device), insert=text, fill='white', class_='device-image-label'))
|
|
|
|
def _draw_empty(self, drawing, rack, start, end, text, id_, face_id, class_, reservation):
|
|
link_url = '{}{}?{}'.format(
|
|
self.base_url,
|
|
reverse('dcim:device_add'),
|
|
urlencode({
|
|
'site': rack.site.pk,
|
|
'location': rack.location.pk if rack.location else '',
|
|
'rack': rack.pk,
|
|
'face': face_id,
|
|
'position': id_
|
|
})
|
|
)
|
|
link = drawing.add(
|
|
drawing.a(href=link_url, 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_wirelesslink(self, url, labels):
|
|
"""
|
|
Draw a line with labels representing a WirelessLink.
|
|
|
|
:param url: Hyperlink URL
|
|
:param labels: Iterable of text labels
|
|
"""
|
|
group = Group(class_='connector')
|
|
|
|
# Draw the wireless link
|
|
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, class_='wireless-link')
|
|
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_='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.
|
|
"""
|
|
from dcim.models import Cable
|
|
from wireless.models import WirelessLink
|
|
|
|
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
|
|
if near_end is not None:
|
|
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 (a Cable or WirelessLink)
|
|
if connector is not None:
|
|
|
|
# Cable
|
|
if type(connector) is Cable:
|
|
connector_labels = [
|
|
f'Cable {connector}',
|
|
connector.get_status_display()
|
|
]
|
|
if connector.type:
|
|
connector_labels.append(connector.get_type_display())
|
|
if connector.length and connector.length_unit:
|
|
connector_labels.append(f'{connector.length} {connector.get_length_unit_display()}')
|
|
cable = self._draw_cable(
|
|
color=connector.color or '000000',
|
|
url=connector.get_absolute_url(),
|
|
labels=connector_labels
|
|
)
|
|
connectors.append(cable)
|
|
|
|
# WirelessLink
|
|
elif type(connector) is WirelessLink:
|
|
connector_labels = [
|
|
f'Wireless link {connector}',
|
|
connector.get_status_display()
|
|
]
|
|
if connector.ssid:
|
|
connector_labels.append(connector.ssid)
|
|
wirelesslink = self._draw_wirelesslink(
|
|
url=connector.get_absolute_url(),
|
|
labels=connector_labels
|
|
)
|
|
connectors.append(wirelesslink)
|
|
|
|
# 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)
|
|
|
|
elif far_end:
|
|
|
|
# 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
|