Parcourir la source

Merge pull request #9606 from netbox-community/4434-rack-device-highlights

Closes #4434: Enable highlighting devices within rack elevations
Jeremy Stretch il y a 3 ans
Parent
commit
f6c52e0616

+ 1 - 0
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

+ 10 - 1
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')
 

+ 4 - 2
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)

+ 39 - 18
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)
 

+ 6 - 0
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,
         }
 
 

+ 1 - 1
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)}

+ 7 - 0
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;

+ 5 - 0
netbox/templates/dcim/device.html

@@ -49,6 +49,11 @@
                             <td>
                                 {% if object.rack %}
                                     <a href="{% url 'dcim:rack' pk=object.rack.pk %}">{{ object.rack }}</a>
+                                    <div class="float-end noprint">
+                                      <a href="{% url 'dcim:rack' pk=object.rack.pk %}?device={{ object.pk }}" class="btn btn-primary btn-sm" title="Highlight device">
+                                        <i class="mdi mdi-view-day-outline"></i>
+                                      </a>
+                                    </div>
                                 {% else %}
                                     {{ ''|placeholder }}
                                 {% endif %}

+ 2 - 2
netbox/templates/dcim/inc/rack_elevation.html

@@ -1,8 +1,8 @@
 <div style="margin-left: -30px">
-    <object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg" class="rack_elevation"></object>
+    <object data="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}" class="rack_elevation"></object>
 </div>
 <div class="text-center mt-3">
-    <a class="btn btn-outline-primary btn-sm" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg">
+    <a class="btn btn-outline-primary btn-sm" href="{% url 'dcim-api:rack-elevation' pk=object.pk %}?face={{face}}&render=svg{% if extra_params %}&{{ extra_params }}{% endif %}">
         <i class="mdi mdi-file-download"></i> Download SVG
     </a>
 </div>

+ 2 - 2
netbox/templates/dcim/rack.html

@@ -250,13 +250,13 @@
             <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
               <div style="margin-left: 30px">
                 <h4>Front</h4>
-                {% include 'dcim/inc/rack_elevation.html' with face='front' %}
+                {% include 'dcim/inc/rack_elevation.html' with face='front' extra_params=svg_extra %}
               </div>
             </div>
             <div class="col col-md-6 col-sm-6 col-xs-12 text-center">
               <div style="margin-left: 30px">
                 <h4>Rear</h4>
-                {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
+                {% include 'dcim/inc/rack_elevation.html' with face='rear' extra_params=svg_extra %}
               </div>
             </div>
         </div>