feat: add multi-rack elevation view and switch icons
- 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
This commit is contained in:
@@ -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")),
|
||||
|
||||
@@ -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']),
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#fff4e5" stroke="#e58a16" stroke-width="6"/><rect x="20" y="48" width="88" height="42" rx="7" fill="#513717"/><g fill="#ffc66f"><path d="M28 58h10v8H28zm14 0h10v8H42zm14 0h10v8H56zm14 0h10v8H70zm14 0h16v8H84zM28 71h10v8H28zm14 0h10v8H42zm14 0h10v8H56zm14 0h10v8H70zm14 0h16v8H84z"/></g><path d="M34 27h25m10 0h25M59 27l10 0M64 27v17" fill="none" stroke="#e58a16" stroke-width="7" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 510 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#eafff4" stroke="#20a86b" stroke-width="6"/><rect x="20" y="49" width="88" height="39" rx="7" fill="#174637"/><g fill="#8cf0bf"><path d="M29 59h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h6v7h-6zM29 70h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h9v7h-9zm13 0h6v7h-6z"/></g><path d="M64 20c-14 8-22 17-22 27 14 1 23-5 26-19 7 9 12 15 20 18-1-12-9-21-24-26z" fill="#20a86b"/></svg>
|
||||
|
After Width: | Height: | Size: 508 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#e8f4ff" stroke="#1685fc" stroke-width="6"/><rect x="22" y="42" width="84" height="45" rx="7" fill="#183a5a"/><path d="M28 52h72v25H28z" fill="#10283e"/><g fill="#52d273"><circle cx="34" cy="64" r="3"/><circle cx="43" cy="64" r="3"/></g><g fill="#8fc8ff"><path d="M55 55h10v7H55zm14 0h10v7H69zm14 0h10v7H83zM55 67h10v7H55zm14 0h10v7H69zm14 0h10v7H83z"/></g><path d="m35 33 9-8v6h40v-6l9 8-9 8v-6H44v6z" fill="#1685fc"/></svg>
|
||||
|
After Width: | Height: | Size: 525 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><circle cx="64" cy="64" r="59" fill="#f2ecff" stroke="#7654d6" stroke-width="6"/><rect x="20" y="48" width="88" height="42" rx="7" fill="#302657"/><g fill="#bca9ff"><path d="M28 58h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h7v8h-7zM28 71h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h9v8h-9zm13 0h7v8h-7z"/></g><path d="M64 45V20M64 25 44 39M64 25l20 14" fill="none" stroke="#7654d6" stroke-width="7" stroke-linecap="round"/></svg>
|
||||
|
After Width: | Height: | Size: 522 B |
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
{% extends 'generic/_base.html' %}
|
||||
{% load helpers %}
|
||||
{% load static %}
|
||||
|
||||
{% block title %}Rack-Ansicht{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="card mb-3">
|
||||
<div class="card-header"><strong>Racks auswählen</strong></div>
|
||||
<div class="card-body">
|
||||
<form method="get">
|
||||
<div class="row g-3">
|
||||
{% for field in filter_form %}
|
||||
<div class="col-md-3">
|
||||
<label class="form-label" for="{{ field.id_for_label }}">{{ field.label }}</label>
|
||||
{{ field }}
|
||||
{% if field.help_text %}<div class="form-hint">{{ field.help_text }}</div>{% endif %}
|
||||
{% for error in field.errors %}<div class="text-danger small">{{ error }}</div>{% endfor %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<button class="btn btn-primary" type="submit"><i class="mdi mdi-magnify"></i> Anzeigen</button>
|
||||
<a class="btn btn-outline-secondary" href="{% url 'plugins:netbox_topology_views:rack_elevation' %}">Zurücksetzen</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if racks %}
|
||||
<div class="rack-elevation-grid">
|
||||
{% for rack in racks %}
|
||||
<section class="rack-card">
|
||||
<header>
|
||||
<a href="{{ rack.get_absolute_url }}"><strong>{{ rack.name }}</strong></a>
|
||||
<small>{{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U</small>
|
||||
</header>
|
||||
<div class="rack-faces">
|
||||
{% for face, face_label in faces %}
|
||||
<div class="rack-face">
|
||||
<div class="rack-face-title">{{ face_label }}</div>
|
||||
<div class="rack-frame" style="--rack-units: {{ rack.u_height }}">
|
||||
<div class="rack-unit-labels">
|
||||
{% for unit in rack.units %}<span>{{ unit }}</span>{% endfor %}
|
||||
</div>
|
||||
<div class="rack-device-area">
|
||||
{% for device in rack.elevation_devices %}
|
||||
{% if device.elevation_face == face %}
|
||||
<a class="rack-device" href="{{ device.get_absolute_url }}"
|
||||
style="bottom: {{ device.elevation_bottom }}%; height: {{ device.elevation_height }}%; border-color: #{{ device.role.color }}"
|
||||
title="{{ device.name }} · {{ device.device_type }} · U{{ device.position }}">
|
||||
<span>{{ device.name|default:device.device_type.model }}</span>
|
||||
<small>U{{ device.position }} · {{ device.device_type }}</small>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header"><strong>Verkabelung der angezeigten Racks</strong> <span class="badge bg-secondary">{{ cables|length }}</span></div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead><tr><th>Kabel</th><th>A-Seite</th><th>B-Seite</th><th>Typ</th><th>Status</th></tr></thead>
|
||||
<tbody>
|
||||
{% for row in cables %}
|
||||
<tr>
|
||||
<td><a href="{{ row.cable.get_absolute_url }}">{{ row.cable.label|default:row.cable }}</a></td>
|
||||
<td>{% if row.a %}<strong>{{ row.a.device_name }}</strong><br><small>{{ row.a.name }}</small>{% else %}—{% endif %}</td>
|
||||
<td>{% if row.b %}<strong>{{ row.b.device_name }}</strong><br><small>{{ row.b.name }}</small>{% else %}—{% endif %}</td>
|
||||
<td>{{ row.cable.type|default:'—' }}</td><td>{{ row.cable.get_status_display }}</td>
|
||||
</tr>
|
||||
{% empty %}<tr><td colspan="5" class="text-muted text-center py-4">Keine Verkabelung gefunden.</td></tr>{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% elif has_selection %}
|
||||
<div class="alert alert-warning">Für diese Auswahl wurden keine belegten Racks gefunden. Aktiviere „Leere Racks einschließen“, um auch leere Racks zu sehen.</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">Wähle ein oder mehrere Racks oder grenze die Ansicht über Standort und Location ein.</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -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"),
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user