From fccc54e75e877c511010d1a6f02af2162295c7b6 Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 22 Jul 2026 09:26:36 +0200 Subject: [PATCH] feat: add multi-rack elevation view and switch icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - add rack elevation view for one or multiple racks - render front and rear device positions by rack unit - show physical cabling below selected racks - add site, location, rack, and empty-rack filters - add network, leaf, spine, and aggregation switch icons - add “Weitere Ansichten” navigation category --- netbox_topology_views/forms.py | 22 ++++- netbox_topology_views/navigation.py | 5 + .../static/netbox_topology_views/css/app.css | 2 +- .../img/aggregation-switch.svg | 1 + .../netbox_topology_views/img/leaf-switch.svg | 1 + .../img/network-switch.svg | 1 + .../img/spine-switch.svg | 1 + netbox_topology_views/static_dev/css/app.scss | 29 ++++++ .../netbox_topology_views/rack_elevation.html | 93 +++++++++++++++++++ netbox_topology_views/urls.py | 1 + netbox_topology_views/views.py | 75 +++++++++++++++ 11 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 netbox_topology_views/static/netbox_topology_views/img/aggregation-switch.svg create mode 100644 netbox_topology_views/static/netbox_topology_views/img/leaf-switch.svg create mode 100644 netbox_topology_views/static/netbox_topology_views/img/network-switch.svg create mode 100644 netbox_topology_views/static/netbox_topology_views/img/spine-switch.svg create mode 100644 netbox_topology_views/templates/netbox_topology_views/rack_elevation.html diff --git a/netbox_topology_views/forms.py b/netbox_topology_views/forms.py index 5cbcd39..cca9f30 100644 --- a/netbox_topology_views/forms.py +++ b/netbox_topology_views/forms.py @@ -230,7 +230,6 @@ class DeviceFilterForm( choices=BOOLEAN_WITH_BLANK_CHOICES ) ) - tag = TagFilterField(model) # options @@ -361,6 +360,27 @@ class DeviceFilterForm( ) ) + +class RackElevationFilterForm(forms.Form): + """Compact selector for the rack elevation view.""" + + site_id = DynamicModelMultipleChoiceField( + queryset=Site.objects.all(), required=False, label=_("Site") + ) + location_id = DynamicModelMultipleChoiceField( + queryset=Location.objects.all(), required=False, label=_("Location"), + query_params={"site_id": "$site_id"}, + ) + rack_id = DynamicModelMultipleChoiceField( + queryset=Rack.objects.all(), required=False, label=_("Racks"), + query_params={"site_id": "$site_id", "location_id": "$location_id"}, + help_text=_("Select one or more racks. If empty, all matching racks are shown."), + ) + include_empty = forms.BooleanField( + required=False, initial=False, label=_("Include empty racks") + ) + + class CoordinateGroupsForm(NetBoxModelForm): fieldsets = ( FieldSet('name', 'description', name=_("Group Details")), diff --git a/netbox_topology_views/navigation.py b/netbox_topology_views/navigation.py index 3a338ee..d1e2af5 100644 --- a/netbox_topology_views/navigation.py +++ b/netbox_topology_views/navigation.py @@ -84,6 +84,11 @@ menu = PluginMenu( PluginMenuItem(link="plugins:netbox_topology_views:home", link_text="Topology", permissions=["dcim.view_site", "dcim.view_device"]), ), ), + ('WEITERE ANSICHTEN', + ( + PluginMenuItem(link="plugins:netbox_topology_views:rack_elevation", link_text="Rack-Ansicht", permissions=["dcim.view_rack", "dcim.view_device", "dcim.view_cable"]), + ), + ), ('COORDINATES', ( PluginMenuItem(link="plugins:netbox_topology_views:coordinategroup_list", link_text="Coordinate Groups", buttons=coordinategroup_buttons, permissions=['netbox_topology_views.view_coordinategroup']), diff --git a/netbox_topology_views/static/netbox_topology_views/css/app.css b/netbox_topology_views/static/netbox_topology_views/css/app.css index b9e95d1..c2e3159 100644 --- a/netbox_topology_views/static/netbox_topology_views/css/app.css +++ b/netbox_topology_views/static/netbox_topology_views/css/app.css @@ -1 +1 @@ -#visgraph{height:64vh;border:1px solid #ced4da}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529;border:1px solid #495057}.image-dropdown img{width:64px;height:64px}.image-dropdown-content{display:flex;flex-wrap:wrap;gap:.5rem;padding-inline:.5rem;width:50vw;max-width:32rem}.image-dropdown-content>img{cursor:pointer} +#visgraph{height:64vh;border:1px solid #ced4da}html[data-netbox-color-mode=dark] #visgraph{background-color:#212529;border:1px solid #495057}.image-dropdown img{width:64px;height:64px}.image-dropdown-content{display:flex;flex-wrap:wrap;gap:.5rem;padding-inline:.5rem;width:50vw;max-width:32rem}.image-dropdown-content>img{cursor:pointer}.rack-elevation-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(34rem,1fr));gap:1rem}.rack-card{border:1px solid var(--tblr-border-color,#ced4da);border-radius:.4rem;background:var(--tblr-bg-surface,#fff);padding:1rem}.rack-card>header{display:flex;justify-content:space-between;margin-bottom:.75rem}.rack-faces{display:grid;grid-template-columns:1fr 1fr;gap:.75rem}.rack-face-title{text-align:center;font-size:.75rem;text-transform:uppercase;margin-bottom:.25rem}.rack-frame{display:flex;height:calc(var(--rack-units)*14px);min-height:280px;max-height:70vh;border:4px solid #3b4654;background:repeating-linear-gradient(to top,rgba(127,127,127,.14) 0,rgba(127,127,127,.14) 1px,transparent 1px,transparent calc(100%/var(--rack-units)))}.rack-unit-labels{width:2rem;display:flex;flex-direction:column;justify-content:space-around;font-size:.55rem;text-align:center;color:var(--tblr-secondary,#667382)}.rack-device-area{position:relative;flex:1;border-left:1px solid #3b4654}.rack-device{position:absolute;left:3px;right:3px;min-height:2px;overflow:hidden;padding:1px 5px;border:2px solid #1685fc;border-radius:3px;background:#162e45;color:#fff;line-height:1.1;text-decoration:none}.rack-device:hover{filter:brightness(1.25);color:#fff;z-index:2}.rack-device span,.rack-device small{display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rack-device small{font-size:.6rem;opacity:.8}@media(max-width:700px){.rack-elevation-grid{grid-template-columns:1fr}.rack-faces{grid-template-columns:1fr}} diff --git a/netbox_topology_views/static/netbox_topology_views/img/aggregation-switch.svg b/netbox_topology_views/static/netbox_topology_views/img/aggregation-switch.svg new file mode 100644 index 0000000..ac744a2 --- /dev/null +++ b/netbox_topology_views/static/netbox_topology_views/img/aggregation-switch.svg @@ -0,0 +1 @@ + diff --git a/netbox_topology_views/static/netbox_topology_views/img/leaf-switch.svg b/netbox_topology_views/static/netbox_topology_views/img/leaf-switch.svg new file mode 100644 index 0000000..7c449b8 --- /dev/null +++ b/netbox_topology_views/static/netbox_topology_views/img/leaf-switch.svg @@ -0,0 +1 @@ + diff --git a/netbox_topology_views/static/netbox_topology_views/img/network-switch.svg b/netbox_topology_views/static/netbox_topology_views/img/network-switch.svg new file mode 100644 index 0000000..cf79aa0 --- /dev/null +++ b/netbox_topology_views/static/netbox_topology_views/img/network-switch.svg @@ -0,0 +1 @@ + diff --git a/netbox_topology_views/static/netbox_topology_views/img/spine-switch.svg b/netbox_topology_views/static/netbox_topology_views/img/spine-switch.svg new file mode 100644 index 0000000..b407ef4 --- /dev/null +++ b/netbox_topology_views/static/netbox_topology_views/img/spine-switch.svg @@ -0,0 +1 @@ + diff --git a/netbox_topology_views/static_dev/css/app.scss b/netbox_topology_views/static_dev/css/app.scss index 93a8afe..6f5bd6f 100644 --- a/netbox_topology_views/static_dev/css/app.scss +++ b/netbox_topology_views/static_dev/css/app.scss @@ -26,3 +26,32 @@ html[data-netbox-color-mode=dark] #visgraph { cursor: pointer; } } + +.rack-elevation-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(34rem, 1fr)); + gap: 1rem; +} + +.rack-card { + border: 1px solid var(--tblr-border-color, #ced4da); + border-radius: .4rem; + background: var(--tblr-bg-surface, #fff); + padding: 1rem; +} + +.rack-card > header { display: flex; justify-content: space-between; margin-bottom: .75rem; } +.rack-faces { display: grid; grid-template-columns: 1fr 1fr; gap: .75rem; } +.rack-face-title { text-align: center; font-size: .75rem; text-transform: uppercase; margin-bottom: .25rem; } +.rack-frame { display: flex; height: calc(var(--rack-units) * 14px); min-height: 280px; max-height: 70vh; border: 4px solid #3b4654; background: repeating-linear-gradient(to top, rgba(127,127,127,.14) 0, rgba(127,127,127,.14) 1px, transparent 1px, transparent calc(100% / var(--rack-units))); } +.rack-unit-labels { width: 2rem; display: flex; flex-direction: column; justify-content: space-around; font-size: .55rem; text-align: center; color: var(--tblr-secondary, #667382); } +.rack-device-area { position: relative; flex: 1; border-left: 1px solid #3b4654; } +.rack-device { position: absolute; left: 3px; right: 3px; min-height: 2px; overflow: hidden; padding: 1px 5px; border: 2px solid #1685fc; border-radius: 3px; background: #162e45; color: #fff; line-height: 1.1; text-decoration: none; } +.rack-device:hover { filter: brightness(1.25); color: #fff; z-index: 2; } +.rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rack-device small { font-size: .6rem; opacity: .8; } + +@media (max-width: 700px) { + .rack-elevation-grid { grid-template-columns: 1fr; } + .rack-faces { grid-template-columns: 1fr; } +} diff --git a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html new file mode 100644 index 0000000..35a930f --- /dev/null +++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html @@ -0,0 +1,93 @@ +{% extends 'generic/_base.html' %} +{% load helpers %} +{% load static %} + +{% block title %}Rack-Ansicht{% endblock %} + +{% block head %} + +{% endblock %} + +{% block content %} +
+
Racks auswählen
+
+
+
+ {% for field in filter_form %} +
+ + {{ field }} + {% if field.help_text %}
{{ field.help_text }}
{% endif %} + {% for error in field.errors %}
{{ error }}
{% endfor %} +
+ {% endfor %} +
+
+ + Zurücksetzen +
+
+
+
+ +{% if racks %} +
+ {% for rack in racks %} +
+
+ {{ rack.name }} + {{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U +
+
+ {% for face, face_label in faces %} +
+
{{ face_label }}
+
+
+ {% for unit in rack.units %}{{ unit }}{% endfor %} +
+
+ {% for device in rack.elevation_devices %} + {% if device.elevation_face == face %} + + {{ device.name|default:device.device_type.model }} + U{{ device.position }} · {{ device.device_type }} + + {% endif %} + {% endfor %} +
+
+
+ {% endfor %} +
+
+ {% endfor %} +
+ +
+
Verkabelung der angezeigten Racks {{ cables|length }}
+
+ + + + {% for row in cables %} + + + + + + + {% empty %}{% endfor %} + +
KabelA-SeiteB-SeiteTypStatus
{{ row.cable.label|default:row.cable }}{% if row.a %}{{ row.a.device_name }}
{{ row.a.name }}{% else %}—{% endif %}
{% if row.b %}{{ row.b.device_name }}
{{ row.b.name }}{% else %}—{% endif %}
{{ row.cable.type|default:'—' }}{{ row.cable.get_status_display }}
Keine Verkabelung gefunden.
+
+
+{% elif has_selection %} +
Für diese Auswahl wurden keine belegten Racks gefunden. Aktiviere „Leere Racks einschließen“, um auch leere Racks zu sehen.
+{% else %} +
Wähle ein oder mehrere Racks oder grenze die Ansicht über Standort und Location ein.
+{% endif %} +{% endblock %} diff --git a/netbox_topology_views/urls.py b/netbox_topology_views/urls.py index 04d3950..12bc298 100644 --- a/netbox_topology_views/urls.py +++ b/netbox_topology_views/urls.py @@ -8,6 +8,7 @@ from . import models, views urlpatterns = ( path("", RedirectView.as_view(url="topology/", permanent=True)), path("topology/", views.TopologyHomeView.as_view(), name="home"), + path("rack-elevation/", views.RackElevationView.as_view(), name="rack_elevation"), path("images/", views.TopologyImagesView.as_view(), name="images"), path("individualoptions/", views.TopologyIndividualOptionsView.as_view(), name="individualoptions"), diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index 6290410..e702062 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -15,6 +15,7 @@ from dcim.models import ( PowerFeed, PowerPanel, RearPort, + Rack, ) from django.conf import settings from django.contrib import messages @@ -38,6 +39,7 @@ from netbox.views.generic import ( from netbox_topology_views.filters import DeviceFilterSet, CoordinatesFilterSet, CircuitCoordinatesFilterSet, PowerPanelCoordinatesFilterSet, PowerFeedCoordinatesFilterSet from netbox_topology_views.forms import ( DeviceFilterForm, + RackElevationFilterForm, IndividualOptionsForm, CoordinateGroupsForm, CircuitCoordinatesForm, @@ -913,6 +915,79 @@ class TopologyHomeView(PermissionRequiredMixin, View): ) +def _rack_endpoint(termination): + """Return a template-friendly representation of any cable termination.""" + device = getattr(termination, "device", None) + return { + "device_name": getattr(device, "name", None) or "External", + "name": getattr(termination, "name", str(termination)), + "rack_id": getattr(device, "rack_id", None), + } + + +class RackElevationView(PermissionRequiredMixin, View): + """Render one or more complete rack elevations and their physical cabling.""" + + permission_required = ("dcim.view_rack", "dcim.view_device", "dcim.view_cable") + + def get(self, request): + racks = Rack.objects.restrict(request.user, "view").select_related("site", "location") + if not request.GET: + racks = racks.none() + selected_racks = request.GET.getlist("rack_id") + if selected_racks: + racks = racks.filter(pk__in=selected_racks) + else: + if request.GET.getlist("site_id"): + racks = racks.filter(site_id__in=request.GET.getlist("site_id")) + if request.GET.getlist("location_id"): + racks = racks.filter(location_id__in=request.GET.getlist("location_id")) + + devices = Device.objects.restrict(request.user, "view").filter( + rack_id__in=racks.values("pk") + ).select_related("device_type", "role", "rack") + if request.GET.get("include_empty") not in ("on", "True", "true", "1"): + racks = racks.filter(devices__in=devices).distinct() + + rack_list = list(racks.order_by("site__name", "location__name", "name")) + rack_ids = {rack.pk for rack in rack_list} + rack_devices = {rack_id: [] for rack_id in rack_ids} + device_ids = [] + for device in devices.filter(rack_id__in=rack_ids).order_by("rack_id", "position", "name"): + device_ids.append(device.pk) + height = max(getattr(device.device_type, "u_height", 1) or 1, 1) + rack_height = max(device.rack.u_height or 1, 1) + device.elevation_bottom = f"{(float(device.position or 1) - 1) / rack_height * 100:.4f}" + device.elevation_height = f"{min(height / rack_height * 100, 100):.4f}" + device.elevation_face = str(device.face or "front").lower() + rack_devices[device.rack_id].append(device) + + for rack in rack_list: + rack.elevation_devices = rack_devices[rack.pk] + rack.units = range(rack.u_height or 0, 0, -1) + + cable_rows = [] + if device_ids: + cable_ids = CableTermination.objects.filter( + _device_id__in=device_ids + ).values_list("cable_id", flat=True).distinct() + cables = Cable.objects.restrict(request.user, "view").filter(pk__in=cable_ids) + for cable in cables.order_by("label", "pk"): + a_terms, b_terms = list(cable.a_terminations), list(cable.b_terminations) + for index in range(max(len(a_terms), len(b_terms), 1)): + a = _rack_endpoint(a_terms[index]) if index < len(a_terms) else None + b = _rack_endpoint(b_terms[index]) if index < len(b_terms) else None + if (a and a["rack_id"] in rack_ids) or (b and b["rack_id"] in rack_ids): + cable_rows.append({"cable": cable, "a": a, "b": b}) + + return render(request, "netbox_topology_views/rack_elevation.html", { + "filter_form": RackElevationFilterForm(request.GET or None), + "racks": rack_list, "cables": cable_rows, + "faces": (("front", "Vorderseite"), ("rear", "Rückseite")), + "has_selection": bool(request.GET), + }) + + CONFIG = settings.PLUGINS_CONFIG["netbox_topology_views"] ADDITIONAL_ROLES = (PowerPanel, PowerFeed, Circuit)