diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 229509b9c..fc8c24f4c 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -14,6 +14,7 @@ ### Enhancements * [#1202](https://github.com/netbox-community/netbox/issues/1202) - Support overlapping assignment of NAT IP addresses +* [#4350](https://github.com/netbox-community/netbox/issues/4350) - Illustrate reservations vertically alongside rack elevations * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#8222](https://github.com/netbox-community/netbox/issues/8222) - Enable the assignment of a VM to a specific host device within a cluster * [#8471](https://github.com/netbox-community/netbox/issues/8471) - Add `status` field to Cluster diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index ba7f661b5..401c9a901 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -252,7 +252,10 @@ class RackElevationDetailFilterSerializer(serializers.Serializer): default=ConfigItem('RACK_ELEVATION_DEFAULT_UNIT_HEIGHT') ) legend_width = serializers.IntegerField( - default=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + default=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + ) + margin_width = serializers.IntegerField( + default=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH ) exclude = serializers.IntegerField( required=False, diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 38bf16f0b..68bbd1dbe 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -13,7 +13,8 @@ DEVICETYPE_IMAGE_FORMATS = 'image/bmp,image/gif,image/jpeg,image/png,image/tiff, RACK_U_HEIGHT_DEFAULT = 42 RACK_ELEVATION_BORDER_WIDTH = 2 -RACK_ELEVATION_LEGEND_WIDTH_DEFAULT = 30 +RACK_ELEVATION_DEFAULT_LEGEND_WIDTH = 30 +RACK_ELEVATION_DEFAULT_MARGIN_WIDTH = 15 # diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 12cc4dd38..39e01cae3 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -367,7 +367,8 @@ class Rack(NetBoxModel): user=None, unit_width=None, unit_height=None, - legend_width=RACK_ELEVATION_LEGEND_WIDTH_DEFAULT, + legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH, + margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH, include_images=True, base_url=None ): @@ -381,6 +382,7 @@ class Rack(NetBoxModel): :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total height of the elevation :param legend_width: Width of the unit legend, in pixels + :param margin_width: Width of the rigth-hand margin, in pixels :param include_images: Embed front/rear device images where available :param base_url: Base URL for links and images. If none, URLs will be relative. """ @@ -389,6 +391,7 @@ class Rack(NetBoxModel): unit_width=unit_width, unit_height=unit_height, legend_width=legend_width, + margin_width=margin_width, user=user, include_images=include_images, base_url=base_url diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index 4d518adf1..b344aad0a 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -11,7 +11,7 @@ from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config -from utilities.utils import foreground_color +from utilities.utils import foreground_color, array_to_ranges from dcim.choices import DeviceFaceChoices from dcim.constants import RACK_ELEVATION_BORDER_WIDTH @@ -55,8 +55,8 @@ class RackElevationSVG: :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, unit_height=None, unit_width=None, legend_width=None, user=None, include_images=True, - base_url=None): + def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None, + include_images=True, base_url=None): self.rack = rack self.include_images = include_images self.base_url = base_url.rstrip('/') if base_url is not None else '' @@ -65,7 +65,8 @@ class RackElevationSVG: config = get_config() self.unit_width = unit_width or config.RACK_ELEVATION_DEFAULT_UNIT_WIDTH self.unit_height = unit_height or config.RACK_ELEVATION_DEFAULT_UNIT_HEIGHT - self.legend_width = legend_width or config.RACK_ELEVATION_LEGEND_WIDTH_DEFAULT + self.legend_width = legend_width or config.RACK_ELEVATION_DEFAULT_LEGEND_WIDTH + self.margin_width = margin_width or config.RACK_ELEVATION_DEFAULT_MARGIN_WIDTH # Determine the subset of devices within this rack that are viewable by the user, if any permitted_devices = self.rack.devices @@ -91,7 +92,7 @@ class RackElevationSVG: drawing.defs.add(gradient) def _setup_drawing(self): - width = self.unit_width + self.legend_width + RACK_ELEVATION_BORDER_WIDTH * 2 + width = self.unit_width + self.legend_width + self.margin_width + RACK_ELEVATION_BORDER_WIDTH * 2 height = self.unit_height * self.rack.u_height + RACK_ELEVATION_BORDER_WIDTH * 2 drawing = svgwrite.Drawing(size=(width, height)) @@ -100,6 +101,7 @@ class RackElevationSVG: drawing.defs.add(drawing.style(css_file.read())) # Add gradients + RackElevationSVG._add_gradient(drawing, 'reserved', '#b0b0ff') RackElevationSVG._add_gradient(drawing, 'occupied', '#d7d7d7') RackElevationSVG._add_gradient(drawing, 'blocked', '#ffc0c0') @@ -198,6 +200,29 @@ class RackElevationSVG: Text(str(unit), position_coordinates, class_='unit') ) + def draw_margin(self): + """ + Draw any rack reservations in the right-hand margin alongside the rack elevation. + """ + for reservation in self.rack.reservations.all(): + for segment in array_to_ranges(reservation.units): + u_height = 1 if len(segment) == 1 else segment[1] + 1 - segment[0] + coords = self._get_device_coords(segment[0], u_height) + coords = (coords[0] + self.unit_width + RACK_ELEVATION_BORDER_WIDTH * 2, coords[1]) + size = ( + self.margin_width, + u_height * self.unit_height + ) + link = Hyperlink( + href='{}{}'.format(self.base_url, reservation.get_absolute_url()), + target='_blank' + ) + link.set_desc(f'Reservation #{reservation.pk}: {reservation.description}') + link.add( + Rect(coords, size, class_='reservation') + ) + self.drawing.add(link) + def draw_background(self, face): """ Draw the rack unit placeholders which form the "background" of the rack elevation. @@ -261,16 +286,12 @@ class RackElevationSVG: # Initialize the drawing self.drawing = self._setup_drawing() - # Draw the empty rack & legend + # Draw the empty rack, legend, and margin self.draw_legend() self.draw_background(face) + self.draw_margin() - # Draw the opposite rack face first, then the near face - if face == DeviceFaceChoices.FACE_REAR: - opposite_face = DeviceFaceChoices.FACE_FRONT - else: - opposite_face = DeviceFaceChoices.FACE_REAR - # self.draw_face(opposite_face, opposite=True) + # Draw the rack face self.draw_face(face) # Draw the rack border last diff --git a/netbox/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index 4f9361489..bfeed4150 100644 Binary files a/netbox/project-static/dist/rack_elevation.css and b/netbox/project-static/dist/rack_elevation.css differ diff --git a/netbox/project-static/styles/rack-elevation.scss b/netbox/project-static/styles/rack-elevation.scss index bf8063110..bc02995dd 100644 --- a/netbox/project-static/styles/rack-elevation.scss +++ b/netbox/project-static/styles/rack-elevation.scss @@ -81,17 +81,6 @@ svg { opacity: 1; } - // When a reserved slot is hovered, use a more readable color for the 'Add Device' text. - &.reserved:hover[class] + .add-device { - fill: $black; - } - - // Reserved rack unit background color. - &.reserved[class], - &.reserved:hover[class] { - fill: url(#reserved); - } - // Occupied rack unit background color. &.occupied[class], &.occupied:hover[class] { @@ -108,4 +97,9 @@ svg { opacity: 0; } } + + // Reservation background color. + .reservation[class] { + fill: url(#reserved); + } } diff --git a/netbox/utilities/utils.py b/netbox/utilities/utils.py index 6a1b560e1..97ab165fe 100644 --- a/netbox/utilities/utils.py +++ b/netbox/utilities/utils.py @@ -341,14 +341,34 @@ def flatten_dict(d, prefix='', separator='.'): return ret +def array_to_ranges(array): + """ + Convert an arbitrary array of integers to a list of consecutive values. Nonconsecutive values are returned as + single-item tuples. For example: + [0, 1, 2, 10, 14, 15, 16] => [(0, 2), (10,), (14, 16)]" + """ + group = ( + list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x) + ) + return [ + (g[0], g[-1])[:len(g)] for g in group + ] + + def array_to_string(array): """ Generate an efficient, human-friendly string from a set of integers. Intended for use with ArrayField. For example: [0, 1, 2, 10, 14, 15, 16] => "0-2, 10, 14-16" """ - group = (list(x) for _, x in groupby(sorted(array), lambda x, c=count(): next(c) - x)) - return ', '.join('-'.join(map(str, (g[0], g[-1])[:len(g)])) for g in group) + ret = [] + ranges = array_to_ranges(array) + for value in ranges: + if len(value) == 1: + ret.append(str(value[0])) + else: + ret.append(f'{value[0]}-{value[1]}') + return ', '.join(ret) def content_type_name(ct):