From fc02e15fb10049df56ad0371ffb206abc15ff2a8 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 24 Jun 2022 11:04:38 -0400 Subject: [PATCH] Closes #4434: Enable highlighting devices within rack elevations --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/api/views.py | 11 +++- netbox/dcim/models/racks.py | 6 +- netbox/dcim/svg/racks.py | 57 +++++++++++++------ netbox/dcim/views.py | 6 ++ netbox/project-static/dist/rack_elevation.css | 2 +- .../project-static/styles/rack-elevation.scss | 7 +++ netbox/templates/dcim/device.html | 5 ++ netbox/templates/dcim/inc/rack_elevation.html | 4 +- netbox/templates/dcim/rack.html | 4 +- 10 files changed, 77 insertions(+), 26 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index e9125fea0..8deee0370 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -19,6 +19,7 @@ * [#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 +* [#4434](https://github.com/netbox-community/netbox/issues/4434) - Enable highlighting devices within rack elevations * [#5303](https://github.com/netbox-community/netbox/issues/5303) - A virtual machine may be assigned to a site and/or cluster * [#7120](https://github.com/netbox-community/netbox/issues/7120) - Add `termination_date` field to Circuit * [#7744](https://github.com/netbox-community/netbox/issues/7744) - Add `status` field to Location diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index c4c25f654..fdf53053e 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -215,6 +215,14 @@ class RackViewSet(NetBoxModelViewSet): data = serializer.validated_data if data['render'] == 'svg': + # Determine attributes for highlighting devices (if any) + highlight_params = [] + for param in request.GET.getlist('highlight'): + try: + highlight_params.append(param.split(':', 1)) + except ValueError: + pass + # Render and return the elevation as an SVG drawing with the correct content type drawing = rack.get_elevation_svg( face=data['face'], @@ -223,7 +231,8 @@ class RackViewSet(NetBoxModelViewSet): unit_height=data['unit_height'], legend_width=data['legend_width'], include_images=data['include_images'], - base_url=request.build_absolute_uri('/') + base_url=request.build_absolute_uri('/'), + highlight_params=highlight_params ) return HttpResponse(drawing.tostring(), content_type='image/svg+xml') diff --git a/netbox/dcim/models/racks.py b/netbox/dcim/models/racks.py index 39e01cae3..31fbb71de 100644 --- a/netbox/dcim/models/racks.py +++ b/netbox/dcim/models/racks.py @@ -370,7 +370,8 @@ class Rack(NetBoxModel): legend_width=RACK_ELEVATION_DEFAULT_LEGEND_WIDTH, margin_width=RACK_ELEVATION_DEFAULT_MARGIN_WIDTH, include_images=True, - base_url=None + base_url=None, + highlight_params=None ): """ Return an SVG of the rack elevation @@ -394,7 +395,8 @@ class Rack(NetBoxModel): margin_width=margin_width, user=user, include_images=include_images, - base_url=base_url + base_url=base_url, + highlight_params=highlight_params ) return elevation.render(face) diff --git a/netbox/dcim/svg/racks.py b/netbox/dcim/svg/racks.py index b344aad0a..920bd662f 100644 --- a/netbox/dcim/svg/racks.py +++ b/netbox/dcim/svg/racks.py @@ -7,12 +7,13 @@ from svgwrite.shapes import Rect from svgwrite.text import Text from django.conf import settings +from django.core.exceptions import FieldError +from django.db.models import Q from django.urls import reverse from django.utils.http import urlencode from netbox.config import get_config from utilities.utils import foreground_color, array_to_ranges -from dcim.choices import DeviceFaceChoices from dcim.constants import RACK_ELEVATION_BORDER_WIDTH @@ -51,12 +52,17 @@ class RackElevationSVG: Use this class to render a rack elevation as an SVG image. :param rack: A NetBox Rack instance + :param unit_width: Rendered unit width, in pixels + :param unit_height: Rendered unit height, in pixels + :param legend_width: Legend width, in pixels (where the unit labels appear) + :param margin_width: Margin width, in pixels (where reservations appear) :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. + :param highlight_params: Iterable of two-tuples which identifies attributes of devices to highlight """ def __init__(self, rack, unit_height=None, unit_width=None, legend_width=None, margin_width=None, user=None, - include_images=True, base_url=None): + include_images=True, base_url=None, highlight_params=None): self.rack = rack self.include_images = include_images self.base_url = base_url.rstrip('/') if base_url is not None else '' @@ -74,6 +80,17 @@ class RackElevationSVG: permitted_devices = permitted_devices.restrict(user, 'view') self.permitted_device_ids = permitted_devices.values_list('pk', flat=True) + # Determine device(s) to highlight within the elevation (if any) + self.highlight_devices = [] + if highlight_params: + q = Q() + for k, v in highlight_params: + q |= Q(**{k: v}) + try: + self.highlight_devices = permitted_devices.filter(q) + except FieldError: + pass + @staticmethod def _add_gradient(drawing, id_, color): gradient = LinearGradient( @@ -123,40 +140,44 @@ class RackElevationSVG: def _draw_device(self, device, coords, size, color=None, image=None): name = get_device_name(device) description = get_device_description(device) + text_color = f'#{foreground_color(color)}' if color else '#000000' text_coords = ( coords[0] + size[0] / 2, coords[1] + size[1] / 2 ) - text_color = f'#{foreground_color(color)}' if color else '#000000' + + # Determine whether highlighting is in use, and if so, whether to shade this device + is_shaded = self.highlight_devices and device not in self.highlight_devices + css_extra = ' shaded' if is_shaded else '' # Create hyperlink element - link = Hyperlink( - href='{}{}'.format( - self.base_url, - reverse('dcim:device', kwargs={'pk': device.pk}) - ), - target='_blank', - ) + link = Hyperlink(href=f'{self.base_url}{device.get_absolute_url()}', target='_blank') link.set_desc(description) + + # Add rect element to hyperlink if color: - link.add(Rect(coords, size, style=f'fill: #{color}', class_='slot')) + link.add(Rect(coords, size, style=f'fill: #{color}', class_=f'slot{css_extra}')) else: - link.add(Rect(coords, size, class_='slot blocked')) - link.add(Text(name, insert=text_coords, fill=text_color)) + link.add(Rect(coords, size, class_=f'slot blocked{css_extra}')) + link.add(Text(name, insert=text_coords, fill=text_color, class_=f'label{css_extra}')) # Embed device type image if provided if self.include_images and image: image = Image( - href='{}{}'.format(self.base_url, image.url), + href=f'{self.base_url}{image.url}', insert=coords, size=size, - class_='device-image' + class_=f'device-image{css_extra}' ) image.fit(scale='slice') link.add(image) - link.add(Text(name, insert=text_coords, stroke='black', - stroke_width='0.2em', stroke_linejoin='round', class_='device-image-label')) - link.add(Text(name, insert=text_coords, fill='white', class_='device-image-label')) + link.add( + Text(name, insert=text_coords, stroke='black', stroke_width='0.2em', stroke_linejoin='round', + class_=f'device-image-label{css_extra}') + ) + link.add( + Text(name, insert=text_coords, fill='white', class_=f'device-image-label{css_extra}') + ) self.drawing.add(link) diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 35a1056b2..8e8ffbd82 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -639,6 +639,11 @@ class RackView(generic.ObjectView): device_count = Device.objects.restrict(request.user, 'view').filter(rack=instance).count() + # Determine any additional parameters to pass when embedding the rack elevations + svg_extra = '&'.join([ + f'highlight=id:{pk}' for pk in request.GET.getlist('device') + ]) + return { 'device_count': device_count, 'reservations': reservations, @@ -646,6 +651,7 @@ class RackView(generic.ObjectView): 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, + 'svg_extra': svg_extra, } diff --git a/netbox/project-static/dist/rack_elevation.css b/netbox/project-static/dist/rack_elevation.css index bfeed4150..229ea2f97 100644 --- a/netbox/project-static/dist/rack_elevation.css +++ b/netbox/project-static/dist/rack_elevation.css @@ -1 +1 @@ -svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)} +svg{--nbx-rack-bg: #e9ecef;--nbx-rack-border: #000;--nbx-rack-slot-bg: #e9ecef;--nbx-rack-slot-border: #adb5bd;--nbx-rack-slot-hover-bg: #ced4da;--nbx-rack-link-color: #0d6efd;--nbx-rack-unit-color: #6c757d}svg[data-netbox-color-mode=dark]{--nbx-rack-bg: #343a40;--nbx-rack-border: #6c757d;--nbx-rack-slot-bg: #343a40;--nbx-rack-slot-border: #495057;--nbx-rack-slot-hover-bg: #212529;--nbx-rack-link-color: #9ec5fe;--nbx-rack-unit-color: #6c757d}*{font-family:system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,"Noto Sans","Liberation Sans",sans-serif,"Apple Color Emoji","Segoe UI Emoji","Segoe UI Symbol","Noto Color Emoji";font-size:.875rem}rect{box-sizing:border-box}text{text-anchor:middle;dominant-baseline:middle}svg .unit{margin:0;padding:5px 0;fill:var(--nbx-rack-unit-color)}svg .hidden{visibility:hidden}svg rect.shaded,svg image.shaded{opacity:25%}svg text.shaded{opacity:50%}svg .rack{fill:none;stroke-width:2px;stroke:var(--nbx-rack-border);background-color:var(--nbx-rack-bg)}svg .slot{fill:var(--nbx-rack-slot-bg);stroke:var(--nbx-rack-slot-border)}svg .slot:hover{fill:var(--nbx-rack-slot-hover-bg)}svg .slot+.add-device{fill:var(--nbx-rack-link-color);opacity:0;pointer-events:none}svg .slot:hover+.add-device{opacity:1}svg .slot.occupied[class],svg .slot.occupied:hover[class]{fill:url(#occupied)}svg .slot.blocked[class],svg .slot.blocked:hover[class]{fill:url(#blocked)}svg .slot.blocked:hover+.add-device{opacity:0}svg .reservation[class]{fill:url(#reserved)} diff --git a/netbox/project-static/styles/rack-elevation.scss b/netbox/project-static/styles/rack-elevation.scss index bc02995dd..8d6bdddb9 100644 --- a/netbox/project-static/styles/rack-elevation.scss +++ b/netbox/project-static/styles/rack-elevation.scss @@ -48,6 +48,13 @@ svg { visibility: hidden; } + rect.shaded, image.shaded { + opacity: 25%; + } + text.shaded { + opacity: 50%; + } + // Rack elevation container. .rack { fill: none; diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index d3d6f03dc..2ee1e1154 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -49,6 +49,11 @@ {% if object.rack %} {{ object.rack }} +
+ + + +
{% else %} {{ ''|placeholder }} {% endif %} diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index 27372193d..d2c4e4e08 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -1,8 +1,8 @@
- +
- + Download SVG
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index 42f6a8e99..51e873ffa 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -250,13 +250,13 @@

Front

- {% include 'dcim/inc/rack_elevation.html' with face='front' %} + {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}

Rear

- {% include 'dcim/inc/rack_elevation.html' with face='rear' %} + {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}