diff --git a/base_requirements.txt b/base_requirements.txt index 71e05502f..7e221f40b 100644 --- a/base_requirements.txt +++ b/base_requirements.txt @@ -82,3 +82,7 @@ pycryptodome # In-memory key/value store used for caching and queuing # https://github.com/andymccurdy/redis-py redis + +# Python Package to write SVG files - used for rack elevations +# https://github.com/mozman/svgwrite +svgwrite diff --git a/docs/release-notes/version-2.7.md b/docs/release-notes/version-2.7.md index c681fc245..be369275e 100644 --- a/docs/release-notes/version-2.7.md +++ b/docs/release-notes/version-2.7.md @@ -76,6 +76,38 @@ STORAGE_CONFIG = { ## Changes +### Rack Elevations Rendered via SVG ([#2248](https://github.com/netbox-community/netbox/issues/2248)) + +v2.7.0 introduces a new method of rendering rack elevations as an [SVG](https://en.wikipedia.org/wiki/Scalable_Vector_Graphics) via a REST API endpoint. This replaces the prior method of rendering elevations using pure HTML which was cumbersome and had several shortcomings. Allowing elevations to be rendered as an SVG in the API allows users to retrieve and make use of the drawings in their own tooling. This also opens the door to other feature requests related to rack elevations in the NetBox backlog. + +This feature implements a new REST API endpoint: + +``` +/api/dcim/racks//elevation/ +``` + +By default, this endpoint returns a paginated JSON response representing each rack unit in the given elevation. This is the same response returned by the rack units detail endpoint and for this reason the rack units endpoint has been deprecated and will be removed in v2.8 (see [#3753](https://github.com/netbox-community/netbox/issues/3753)): + +``` +/api/dcim/racks//units/ +``` + +In order to render the elevation as an SVG, include the `render_format=svg` query parameter in the request. You may also control the width of the elevation drawing in pixels with `unit_width=` and the height of each rack unit with `unit_height=`. The `unit_width` defaults to `230` and the `unit_height` default to `20` which produces elevations the same size as those that appear in the NetBox Web UI. The query parameter `face` is used to request either the `front` or `rear` of the elevation and defaults to `front`. + +Here is an example of the request url for an SVG rendering using the default parameters to render the front of the elevation: + +``` +/api/dcim/racks//elevation/?render_format=svg +``` + +Here is an example of the request url for an SVG rendering of the rear of the elevation having a width of 300 pixels and per unit height of 35 pixels: + +``` +/api/dcim/racks//elevation/?render_format=svg&face=rear&unit_width=300&unit_height=35 +``` + +Thanks to [@hellerve](https://github.com/hellerve) for doing the heavy lifting on this! + ### Topology Maps Removed ([#2745](https://github.com/netbox-community/netbox/issues/2745)) The topology maps feature has been removed to help focus NetBox development efforts. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index defc6c8ca..62684506c 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -157,7 +157,7 @@ class RackUnitSerializer(serializers.Serializer): """ id = serializers.IntegerField(read_only=True) name = serializers.CharField(read_only=True) - face = serializers.IntegerField(read_only=True) + face = ChoiceField(choices=DeviceFaceChoices, read_only=True) device = NestedDeviceSerializer(read_only=True) @@ -171,6 +171,18 @@ class RackReservationSerializer(ValidatedModelSerializer): fields = ['id', 'rack', 'units', 'created', 'user', 'tenant', 'description'] +class RackElevationDetailFilterSerializer(serializers.Serializer): + face = serializers.ChoiceField(choices=DeviceFaceChoices, default=DeviceFaceChoices.FACE_FRONT) + render_format = serializers.ChoiceField( + choices=RackElecationDetailRenderFormatChoices, + default=RackElecationDetailRenderFormatChoices.RENDER_FORMAT_SVG + ) + unit_width = serializers.IntegerField(default=RACK_ELEVATION_UNIT_WIDTH_DEFAULT) + unit_height = serializers.IntegerField(default=RACK_ELEVATION_UNIT_HEIGHT_DEFAULT) + exclude = serializers.IntegerField(required=False, default=None) + expand_devices = serializers.BooleanField(required=False, default=True) + + # # Device types # diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index bffe4b9e4..e286026a5 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -2,8 +2,8 @@ from collections import OrderedDict from django.conf import settings from django.db.models import Count, F -from django.http import HttpResponseForbidden -from django.shortcuts import get_object_or_404 +from django.http import HttpResponseForbidden, HttpResponseBadRequest, HttpResponse +from django.shortcuts import get_object_or_404, reverse from drf_yasg import openapi from drf_yasg.openapi import Parameter from drf_yasg.utils import swagger_auto_schema @@ -13,7 +13,7 @@ from rest_framework.response import Response from rest_framework.viewsets import GenericViewSet, ViewSet from circuits.models import Circuit -from dcim import filters +from dcim import constants, filters from dcim.models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -28,6 +28,7 @@ from ipam.models import Prefix, VLAN from utilities.api import ( get_serializer_for_model, IsAuthenticatedOrLoginNotRequired, FieldChoicesViewSet, ModelViewSet, ServiceUnavailable, ) +from utilities.custom_inspectors import NullablePaginatorInspector from utilities.utils import get_subquery from virtualization.models import VirtualMachine from . import serializers @@ -175,13 +176,15 @@ class RackViewSet(CustomFieldModelViewSet): serializer_class = serializers.RackSerializer filterset_class = filters.RackFilter + @swagger_auto_schema(deprecated=True) @action(detail=True) def units(self, request, pk=None): """ List rack units (by rack) """ + # TODO: Remove this action detail route in v2.8 rack = get_object_or_404(Rack, pk=pk) - face = request.GET.get('face', 0) + face = request.GET.get('face', 'front') exclude_pk = request.GET.get('exclude', None) if exclude_pk is not None: try: @@ -200,6 +203,39 @@ class RackViewSet(CustomFieldModelViewSet): rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) return self.get_paginated_response(rack_units.data) + @swagger_auto_schema( + responses={200: serializers.RackUnitSerializer(many=True)}, + query_serializer=serializers.RackElevationDetailFilterSerializer + ) + @action(detail=True) + def elevation(self, request, pk=None): + """ + Rack elevation representing the list of rack units. Also supports rendering the elevation as an SVG. + """ + rack = get_object_or_404(Rack, pk=pk) + serializer = serializers.RackElevationDetailFilterSerializer(data=request.GET) + if not serializer.is_valid(): + return Response(serializer.errors, 400) + data = serializer.validated_data + + if data['render_format'] == 'svg': + # Render and return the elevation as an SVG drawing with the correct content type + drawing = rack.get_elevation_svg(data['face'], data['unit_width'], data['unit_height']) + return HttpResponse(drawing.tostring(), content_type='image/svg+xml') + + else: + # Return a JSON representation of the rack units in the elevation + elevation = rack.get_rack_units( + face=data['face'], + exclude=data['exclude'], + expand_devices=data['expand_devices'] + ) + + page = self.paginate_queryset(elevation) + if page is not None: + rack_units = serializers.RackUnitSerializer(page, many=True, context={'request': request}) + return self.get_paginated_response(rack_units.data) + # # Rack reservations diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 408e49888..15b3ce680 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -105,6 +105,17 @@ class RackDimensionUnitChoices(ChoiceSet): } +class RackElecationDetailRenderFormatChoices(ChoiceSet): + + RENDER_FORMAT_JSON = 'json' + RENDER_FORMAT_SVG = 'svg' + + CHOICES = ( + (RENDER_FORMAT_JSON, 'json'), + (RENDER_FORMAT_SVG, 'svg') + ) + + # # DeviceTypes # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index af009f481..be290116c 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -55,3 +55,63 @@ COMPATIBLE_TERMINATION_TYPES = { 'rearport': ['consoleport', 'consoleserverport', 'interface', 'frontport', 'rearport', 'circuittermination'], 'circuittermination': ['interface', 'frontport', 'rearport'], } + + +RACK_ELEVATION_STYLE = """ +* { + font-family: 'Helvetica Neue'; + font-size: 13px; +} +rect { + box-sizing: border-box; +} +text { + text-anchor: middle; + dominant-baseline: middle; +} +.rack { + background-color: #f0f0f0; + fill: none; + stroke: black; + stroke-width: 3px; +} +.slot { + fill: #f7f7f7; + stroke: #a0a0a0; +} +.slot:hover { + fill: #fff; +} +.slot+.add-device { + fill: none; +} +.slot:hover+.add-device { + fill: blue; +} +.reserved { + fill: url(#reserved); +} +.reserved:hover { + fill: url(#reserved); +} +.occupied { + fill: url(#occupied); +} +.occupied:hover { + fill: url(#occupied); +} +.blocked { + fill: url(#blocked); +} +.blocked:hover { + fill: url(#blocked); +} +.blocked:hover+.add-device { + fill: none; +} +""" + + +# Rack Elevation SVG Size +RACK_ELEVATION_UNIT_WIDTH_DEFAULT = 230 +RACK_ELEVATION_UNIT_HEIGHT_DEFAULT = 20 diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 5f6a3e695..e80383cc6 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1475,7 +1475,7 @@ class DeviceForm(BootstrapMixin, TenancyForm, CustomFieldForm): empty_value=None, help_text="The lowest-numbered unit occupied by the device", widget=APISelect( - api_url='/api/dcim/racks/{{rack}}/units/', + api_url='/api/dcim/racks/{{rack}}/elevation/', disabled_indicator='device' ) ) diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d29a1e729..061824968 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -1,6 +1,7 @@ from collections import OrderedDict from itertools import count, groupby +import svgwrite from django.conf import settings from django.contrib.auth.models import User from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation @@ -11,6 +12,7 @@ from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models from django.db.models import Count, Q, Sum from django.urls import reverse +from django.utils.http import urlencode from mptt.models import MPTTModel, TreeForeignKey from taggit.managers import TaggableManager from timezone_field import TimeZoneField @@ -19,7 +21,8 @@ from extras.models import ConfigContextModel, CustomFieldModel, ObjectChange, Ta from utilities.fields import ColorField from utilities.managers import NaturalOrderingManager from utilities.models import ChangeLoggedModel -from utilities.utils import serialize_object, to_meters +from utilities.utils import foreground_color, serialize_object, to_meters + from .choices import * from .constants import * from .exceptions import LoopDetected @@ -458,7 +461,126 @@ class RackRole(ChangeLoggedModel): ) -class Rack(ChangeLoggedModel, CustomFieldModel): +class RackElevationHelperMixin: + """ + Utility class that renders rack elevations. Contains helper methods for rendering elevations as a list of + rack units represented as dictionaries, or an SVG of the elevation. + """ + + @staticmethod + def _add_gradient(drawing, id_, color): + gradient = drawing.linearGradient( + start=('0', '20%'), + end=('0', '40%'), + spreadMethod='repeat', + id_=id_, + gradientTransform='rotate(80)' + ) + 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 + drawing.defs.add(drawing.style(RACK_ELEVATION_STYLE)) + + # add gradients + RackElevationHelperMixin._add_gradient(drawing, 'reserved', '#c7c7ff') + RackElevationHelperMixin._add_gradient(drawing, 'occupied', '#f0f0f0') + RackElevationHelperMixin._add_gradient(drawing, 'blocked', '#ffc7c7') + + return drawing + + @staticmethod + def _draw_device_front(drawing, device, start, end, text): + color = device.device_role.color + link = drawing.add( + drawing.a( + reverse('dcim:device', kwargs={'pk': device.pk}), fill='black' + ) + ) + link.add(drawing.rect(start, end, fill='#{}'.format(color))) + hex_color = '#{}'.format(foreground_color(color)) + link.add(drawing.text(device.name, insert=text, fill=hex_color)) + + @staticmethod + def _draw_device_rear(drawing, device, start, end, text): + drawing.add(drawing.rect(start, end, class_="blocked")) + drawing.add(drawing.text(device.name, insert=text)) + + @staticmethod + def _draw_empty(drawing, rack, start, end, text, id_, face_id, class_): + link = drawing.add( + drawing.a('{}?{}'.format( + reverse('dcim:device_add'), + urlencode({'rack': rack.pk, 'site': rack.site.pk, 'face': face_id, 'position': id_}) + )) + ) + link.add(drawing.rect(start, end, class_=class_)) + link.add(drawing.text("add device", insert=text, class_='add-device')) + + def _draw_elevations(self, elevation, reserved_units, face, unit_width, unit_height): + drawing = self._setup_drawing(unit_width, unit_height * self.u_height) + + unit_cursor = 0 + total_units = len(elevation) + while unit_cursor < total_units: + # Loop through all units in the elevation + unit = elevation[unit_cursor] + device = unit['device'] + height = unit.get('height', 1) + + # Setup drawing cordinates + start_y = unit_cursor * unit_height + end_y = unit_height * height + start_cordinates = (0, start_y) + end_cordinates = (unit_width, end_y) + text_cordinates = (unit_width / 2, start_y + end_y / 2) + + # Draw the device + if device and device.face == face: + self._draw_device_front(drawing, device, start_cordinates, end_cordinates, text_cordinates) + elif device and device.device_type.is_full_depth: + self._draw_device_rear(drawing, device, start_cordinates, end_cordinates, text_cordinates) + else: + # Draw shallow devices, reservations, or empty units + class_ = 'slot' + if device: + class_ += ' occupied' + if unit["id"] in reserved_units: + class_ += ' reserved' + self._draw_empty( + drawing, self, start_cordinates, end_cordinates, text_cordinates, unit["id"], face, class_ + ) + + unit_cursor += height + + # Wrap the drawing with a border + drawing.add(drawing.rect((0, 0), (unit_width, self.u_height * unit_height), class_='rack')) + + return drawing + + def get_elevation_svg(self, face=DeviceFaceChoices.FACE_FRONT, unit_width=230, unit_height=20): + """ + Return an SVG of the rack elevation + + :param face: Enum of [front, rear] representing the desired side of the rack elevation to render + :param width: Width in pixles for the rendered drawing + :param unit_height: Height of each rack unit for the rendered drawing. Note this is not the total + height of the elevation + """ + elevation = self.get_rack_units(face=face, expand_devices=False) + reserved_units = self.get_reserved_units().keys() + + return self._draw_elevations(elevation, reserved_units, face, unit_width, unit_height) + + +class Rack(ChangeLoggedModel, CustomFieldModel, RackElevationHelperMixin): """ Devices are housed within Racks. Each rack has a defined height measured in rack units, and a front and rear face. Each Rack is assigned to a Site and (optionally) a RackGroup. @@ -677,14 +799,16 @@ class Rack(ChangeLoggedModel, CustomFieldModel): def get_status_class(self): return self.STATUS_CLASS_MAP.get(self.status) - def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None, remove_redundant=False): + def get_rack_units(self, face=DeviceFaceChoices.FACE_FRONT, exclude=None, expand_devices=True): """ Return a list of rack units as dictionaries. Example: {'device': None, 'face': 0, 'id': 48, 'name': 'U48'} Each key 'device' is either a Device or None. By default, multi-U devices are repeated for each U they occupy. :param face: Rack face (front or rear) :param exclude: PK of a Device to exclude (optional); helpful when relocating a Device within a Rack - :param remove_redundant: If True, rack units occupied by a device already listed will be omitted + :param expand_devices: When True, all units that a device occupies will be listed with each containing a + reference to the device. When False, only the bottom most unit for a device is included and that unit + contains a height attribute for the device """ elevation = OrderedDict() @@ -693,27 +817,32 @@ class Rack(ChangeLoggedModel, CustomFieldModel): # Add devices to rack units list if self.pk: - for device in Device.objects.prefetch_related('device_type__manufacturer', 'device_role')\ - .annotate(devicebay_count=Count('device_bays'))\ - .exclude(pk=exclude)\ - .filter(rack=self, position__gt=0)\ - .filter(Q(face=face) | Q(device_type__is_full_depth=True)): - if remove_redundant: - elevation[device.position]['device'] = device - for u in range(device.position + 1, device.position + device.device_type.u_height): - elevation.pop(u, None) - else: + queryset = Device.objects.prefetch_related( + 'device_type', + 'device_type__manufacturer', + 'device_role' + ).annotate( + devicebay_count=Count('device_bays') + ).exclude( + pk=exclude + ).filter( + rack=self, + position__gt=0 + ).filter( + Q(face=face) | Q(device_type__is_full_depth=True) + ) + for device in queryset: + if expand_devices: for u in range(device.position, device.position + device.device_type.u_height): elevation[u]['device'] = device + else: + elevation[device.position]['device'] = device + elevation[device.position]['height'] = device.device_type.u_height + for u in range(device.position + 1, device.position + device.device_type.u_height): + elevation.pop(u, None) return [u for u in elevation.values()] - def get_front_elevation(self): - return self.get_rack_units(face=DeviceFaceChoices.FACE_FRONT, remove_redundant=True) - - def get_rear_elevation(self): - return self.get_rack_units(face=DeviceFaceChoices.FACE_REAR, remove_redundant=True) - def get_available_units(self, u_height=1, rack_face=None, exclude=list()): """ Return a list of units within the rack available to accommodate a device of a given U height (default 1). diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 6af5fc6d6..5cc3d0309 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -126,14 +126,14 @@ class RackTestCase(TestCase): self.assertEqual(list(self.rack.units), list(reversed(range(1, 43)))) # Validate inventory (front face) - rack1_inventory_front = self.rack.get_front_elevation() + rack1_inventory_front = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_FRONT) self.assertEqual(rack1_inventory_front[-10]['device'], device1) del(rack1_inventory_front[-10]) for u in rack1_inventory_front: self.assertIsNone(u['device']) # Validate inventory (rear face) - rack1_inventory_rear = self.rack.get_rear_elevation() + rack1_inventory_rear = self.rack.get_rack_units(face=DeviceFaceChoices.FACE_REAR) self.assertEqual(rack1_inventory_rear[-10]['device'], device1) del(rack1_inventory_rear[-10]) for u in rack1_inventory_rear: diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index ec16713fa..5a15198ba 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -419,8 +419,6 @@ class RackView(PermissionRequiredMixin, View): 'nonracked_devices': nonracked_devices, 'next_rack': next_rack, 'prev_rack': prev_rack, - 'front_elevation': rack.get_front_elevation(), - 'rear_elevation': rack.get_rear_elevation(), }) diff --git a/netbox/project-static/css/base.css b/netbox/project-static/css/base.css index 9d4c099f4..45babe70b 100644 --- a/netbox/project-static/css/base.css +++ b/netbox/project-static/css/base.css @@ -187,194 +187,6 @@ ul.rack_legend li { padding: 5px 0; text-align: right; } -div.rack_frame { - float: left; - position: relative; -} -ul.rack { - border: 2px solid #404040; - float: left; - list-style-type: none; - padding: 0; - position: absolute; - width: 230px; -} -ul.rack li { - border-top: 1px solid #e0e0e0; - display: block; - font-size: 13px; - height: 20px; - overflow: hidden; - text-align: center; -} -ul.rack li.h2u { height: 40px; } -ul.rack li.h2u a, ul.rack li.h2u span { padding: 10px 0; } -ul.rack li.h3u { height: 60px; } -ul.rack li.h3u a, ul.rack li.h3u span { padding: 20px 0; } -ul.rack li.h4u { height: 80px; } -ul.rack li.h4u a, ul.rack li.h4u span { padding: 30px 0; } -ul.rack li.h5u { height: 100px; } -ul.rack li.h5u a, ul.rack li.h5u span { padding: 40px 0; } -ul.rack li.h6u { height: 120px; } -ul.rack li.h6u a, ul.rack li.h6u span { padding: 50px 0; } -ul.rack li.h7u { height: 140px; } -ul.rack li.h7u a, ul.rack li.h7u span { padding: 60px 0; } -ul.rack li.h8u { height: 160px; } -ul.rack li.h8u a, ul.rack li.h8u span { padding: 70px 0; } -ul.rack li.h9u { height: 180px; } -ul.rack li.h9u a, ul.rack li.h9u span { padding: 80px 0; } -ul.rack li.h10u { height: 200px; } -ul.rack li.h10u a, ul.rack li.h10u span { padding: 90px 0; } -ul.rack li.h11u { height: 220px; } -ul.rack li.h11u a, ul.rack li.h11u span { padding: 100px 0; } -ul.rack li.h12u { height: 240px; } -ul.rack li.h12u a, ul.rack li.h12u span { padding: 110px 0; } -ul.rack li.h13u { height: 260px; } -ul.rack li.h13u a, ul.rack li.h13u span { padding: 120px 0; } -ul.rack li.h14u { height: 280px; } -ul.rack li.h14u a, ul.rack li.h14u span { padding: 130px 0; } -ul.rack li.h15u { height: 300px; } -ul.rack li.h15u a, ul.rack li.h15u span { padding: 140px 0; } -ul.rack li.h16u { height: 320px; } -ul.rack li.h16u a, ul.rack li.h16u span { padding: 150px 0; } -ul.rack li.h17u { height: 340px; } -ul.rack li.h17u a, ul.rack li.h17u span { padding: 160px 0; } -ul.rack li.h18u { height: 360px; } -ul.rack li.h18u a, ul.rack li.h18u span { padding: 170px 0; } -ul.rack li.h19u { height: 380px; } -ul.rack li.h19u a, ul.rack li.h19u span { padding: 180px 0; } -ul.rack li.h20u { height: 400px; } -ul.rack li.h20u a, ul.rack li.h20u span { padding: 190px 0; } -ul.rack li.h21u { height: 420px; } -ul.rack li.h21u a, ul.rack li.h21u span { padding: 200px 0; } -ul.rack li.h22u { height: 440px; } -ul.rack li.h22u a, ul.rack li.h22u span { padding: 210px 0; } -ul.rack li.h23u { height: 460px; } -ul.rack li.h23u a, ul.rack li.h23u span { padding: 220px 0; } -ul.rack li.h24u { height: 480px; } -ul.rack li.h24u a, ul.rack li.h24u span { padding: 230px 0; } -ul.rack li.h25u { height: 500px; } -ul.rack li.h25u a, ul.rack li.h25u span { padding: 240px 0; } -ul.rack li.h26u { height: 520px; } -ul.rack li.h26u a, ul.rack li.h26u span { padding: 250px 0; } -ul.rack li.h27u { height: 540px; } -ul.rack li.h27u a, ul.rack li.h27u span { padding: 260px 0; } -ul.rack li.h28u { height: 560px; } -ul.rack li.h28u a, ul.rack li.h28u span { padding: 270px 0; } -ul.rack li.h29u { height: 580px; } -ul.rack li.h29u a, ul.rack li.h29u span { padding: 280px 0; } -ul.rack li.h30u { height: 600px; } -ul.rack li.h30u a, ul.rack li.h30u span { padding: 290px 0; } -ul.rack li.h31u { height: 620px; } -ul.rack li.h31u a, ul.rack li.h31u span { padding: 300px 0; } -ul.rack li.h32u { height: 640px; } -ul.rack li.h32u a, ul.rack li.h32u span { padding: 310px 0; } -ul.rack li.h33u { height: 660px; } -ul.rack li.h33u a, ul.rack li.h33u span { padding: 320px 0; } -ul.rack li.h34u { height: 680px; } -ul.rack li.h34u a, ul.rack li.h34u span { padding: 330px 0; } -ul.rack li.h35u { height: 700px; } -ul.rack li.h35u a, ul.rack li.h35u span { padding: 340px 0; } -ul.rack li.h36u { height: 720px; } -ul.rack li.h36u a, ul.rack li.h36u span { padding: 350px 0; } -ul.rack li.h37u { height: 740px; } -ul.rack li.h37u a, ul.rack li.h37u span { padding: 360px 0; } -ul.rack li.h38u { height: 760px; } -ul.rack li.h38u a, ul.rack li.h38u span { padding: 370px 0; } -ul.rack li.h39u { height: 780px; } -ul.rack li.h39u a, ul.rack li.h39u span { padding: 380px 0; } -ul.rack li.h40u { height: 800px; } -ul.rack li.h40u a, ul.rack li.h40u span { padding: 390px 0; } -ul.rack li.h41u { height: 820px; } -ul.rack li.h41u a, ul.rack li.h41u span { padding: 400px 0; } -ul.rack li.h42u { height: 840px; } -ul.rack li.h42u a, ul.rack li.h42u span { padding: 410px 0; } -ul.rack li.h43u { height: 860px; } -ul.rack li.h43u a, ul.rack li.h43u span { padding: 420px 0; } -ul.rack li.h44u { height: 880px; } -ul.rack li.h44u a, ul.rack li.h44u span { padding: 430px 0; } -ul.rack li.h45u { height: 900px; } -ul.rack li.h45u a, ul.rack li.h45u span { padding: 440px 0; } -ul.rack li.h46u { height: 920px; } -ul.rack li.h46u a, ul.rack li.h46u span { padding: 450px 0; } -ul.rack li.h47u { height: 940px; } -ul.rack li.h47u a, ul.rack li.h47u span { padding: 460px 0; } -ul.rack li.h48u { height: 960px; } -ul.rack li.h48u a, ul.rack li.h48u span { padding: 470px 0; } -ul.rack li.h49u { height: 980px; } -ul.rack li.h49u a, ul.rack li.h49u span { padding: 480px 0; } -ul.rack li.h50u { height: 1000px; } -ul.rack li.h50u a, ul.rack li.h50u span { padding: 490px 0; } -ul.rack_far_face { - background-color: #f7f7f7; - z-index: 100; -} -ul.rack_far_face li.occupied { - background: repeating-linear-gradient( - 45deg, - #f7f7f7, - #f7f7f7 7px, - #f0f0f0 7px, - #f0f0f0 14px - ); -} -ul.rack_far_face li.blocked { - background: repeating-linear-gradient( - 45deg, - #f7f7f7, - #f7f7f7 7px, - #ffc7c7 7px, - #ffc7c7 14px - ); -} -ul.rack_near_face li.reserved { - background: repeating-linear-gradient( - 45deg, - #f7f7f7, - #f7f7f7 7px, - #c7c7ff 7px, - #c7c7ff 14px - ); -} -ul.rack_near_face { - z-index: 200; -} -ul.rack_near_face li.occupied { - border-top: 1px solid #474747; - color: #474747; -} -ul.rack_near_face li.occupied:hover { - background-image: url('../img/tint_20.png'); -} -ul.rack_near_face li:first-child { - border-top: 0; -} -ul.rack_near_face li.available a { - color: #0000ff; - display: none; - text-decoration: none; -} -ul.rack_near_face li.available:hover { - background-color: #ffffff; -} -ul.rack_near_face li.available:hover a { - display: block; -} -ul.rack li.occupied a { - color: #ffffff; - display: block; - font-weight: bold; -} -ul.rack li.occupied a:hover { - text-decoration: none; -} -ul.rack li.occupied span { - cursor: default; - display: block; -} -li.occupied + li.available { - border-top: 1px solid #474747; -} /* Devices */ table.component-list td.subtable { diff --git a/netbox/templates/dcim/inc/rack_elevation.html b/netbox/templates/dcim/inc/rack_elevation.html index fdb6f9a36..8f8204f9e 100644 --- a/netbox/templates/dcim/inc/rack_elevation.html +++ b/netbox/templates/dcim/inc/rack_elevation.html @@ -8,47 +8,6 @@
- -
    - {% for u in secondary_face %} - {% if u.device %} -
  • - {% else %} -
  • - {% endif %} - {% endfor %} -
- - - +
diff --git a/netbox/templates/dcim/rack.html b/netbox/templates/dcim/rack.html index f82ee0d4f..0cc261a27 100644 --- a/netbox/templates/dcim/rack.html +++ b/netbox/templates/dcim/rack.html @@ -317,13 +317,13 @@

Front

- {% include 'dcim/inc/rack_elevation.html' with primary_face=front_elevation secondary_face=rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face='front' %}

Rear

- {% include 'dcim/inc/rack_elevation.html' with primary_face=rear_elevation secondary_face=front_elevation face_id=1 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face='rear' %}
diff --git a/netbox/templates/dcim/rack_elevation_list.html b/netbox/templates/dcim/rack_elevation_list.html index a500bb3bb..de7c55919 100644 --- a/netbox/templates/dcim/rack_elevation_list.html +++ b/netbox/templates/dcim/rack_elevation_list.html @@ -18,9 +18,9 @@

{{ rack.facility_id|truncatechars:"30" }}

{% if face_id %} - {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_rear_elevation secondary_face=rack.get_front_elevation face_id=1 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face='rear' %} {% else %} - {% include 'dcim/inc/rack_elevation.html' with primary_face=rack.get_front_elevation secondary_face=rack.get_rear_elevation face_id=0 reserved_units=rack.get_reserved_units %} + {% include 'dcim/inc/rack_elevation.html' with face='front' %} {% endif %}
diff --git a/requirements.txt b/requirements.txt index f93f70e62..fae8f53d9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -20,3 +20,4 @@ psycopg2-binary==2.8.4 py-gfm==0.1.4 pycryptodome==3.9.4 redis==3.3.11 +svgwrite==1.1.9