From d8069edd73d17148e23dbd23eb5ae302cfbc605b Mon Sep 17 00:00:00 2001 From: Louis Date: Wed, 22 Jul 2026 09:41:03 +0200 Subject: [PATCH] feat: improve rack readability and add cable topology --- .../css/rack_elevation.css | 72 ++++++++++++++++ netbox_topology_views/static_dev/css/app.scss | 17 +++- .../netbox_topology_views/rack_elevation.html | 86 +++++++++++++++++++ netbox_topology_views/views.py | 18 ++++ 4 files changed, 190 insertions(+), 3 deletions(-) create mode 100644 netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css diff --git a/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css new file mode 100644 index 0000000..84d9353 --- /dev/null +++ b/netbox_topology_views/static/netbox_topology_views/css/rack_elevation.css @@ -0,0 +1,72 @@ +.rack-frame { + height: calc(var(--rack-units) * 22px); + max-height: none; +} +.rack-device { + padding: 2px 6px; + font-size: .78rem; + line-height: 1.15; +} +.rack-device:hover { + min-height: 2.8rem; + overflow: visible; + z-index: 3; +} +.rack-device:hover span { white-space: normal; } +.rack-topology { + position: relative; + min-height: 18rem; + overflow-x: auto; + padding: 1rem; + background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05)); +} +.rack-topology-lines { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; + z-index: 1; +} +.rack-topology-columns { + position: relative; + z-index: 2; + display: flex; + align-items: flex-start; + justify-content: space-around; + gap: 5rem; + min-width: max-content; +} +.rack-topology-column { + display: flex; + flex-direction: column; + gap: .75rem; + width: 15rem; +} +.rack-topology-column h4 { text-align: center; font-size: .85rem; } +.rack-topology-node { + display: block; + padding: .55rem .75rem; + border: 2px solid var(--node-color); + border-radius: .35rem; + background: var(--tblr-bg-surface, #fff); + color: inherit; + text-decoration: none; + box-shadow: 0 2px 6px rgba(0, 0, 0, .12); +} +.rack-topology-node:hover { color: inherit; filter: brightness(.95); } +.rack-topology-node strong, +.rack-topology-node small { + display: block; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.rack-topology-node small { opacity: .7; } +.rack-topology-edge { + fill: none; + stroke-width: 3; + opacity: .8; + pointer-events: stroke; + cursor: pointer; +} diff --git a/netbox_topology_views/static_dev/css/app.scss b/netbox_topology_views/static_dev/css/app.scss index 6f5bd6f..e01b6ce 100644 --- a/netbox_topology_views/static_dev/css/app.scss +++ b/netbox_topology_views/static_dev/css/app.scss @@ -43,13 +43,24 @@ html[data-netbox-color-mode=dark] #visgraph { .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-frame { display: flex; height: calc(var(--rack-units) * 22px); min-height: 280px; 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 { position: absolute; left: 3px; right: 3px; min-height: 2px; overflow: hidden; padding: 2px 6px; border: 2px solid #1685fc; border-radius: 3px; background: #162e45; color: #fff; font-size: .78rem; line-height: 1.15; text-decoration: none; } +.rack-device:hover { min-height: 2.8rem; overflow: visible; filter: brightness(1.25); color: #fff; z-index: 3; } +.rack-device:hover span { white-space: normal; } .rack-device span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .rack-device small { font-size: .6rem; opacity: .8; } +.rack-topology { position: relative; min-height: 18rem; overflow-x: auto; padding: 1rem; background: var(--tblr-bg-surface-secondary, rgba(127,127,127,.05)); } +.rack-topology-lines { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; z-index: 1; } +.rack-topology-columns { position: relative; z-index: 2; display: flex; align-items: flex-start; justify-content: space-around; gap: 5rem; min-width: max-content; } +.rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 15rem; } +.rack-topology-column h4 { text-align: center; font-size: .85rem; } +.rack-topology-node { display: block; padding: .55rem .75rem; border: 2px solid var(--node-color); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); color: inherit; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,.12); } +.rack-topology-node:hover { color: inherit; filter: brightness(.95); } +.rack-topology-node strong, .rack-topology-node small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.rack-topology-node small { opacity: .7; } +.rack-topology-edge { fill: none; stroke-width: 3; opacity: .8; pointer-events: stroke; cursor: pointer; } @media (max-width: 700px) { .rack-elevation-grid { 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 index 2d2d123..32b1ad0 100644 --- a/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html +++ b/netbox_topology_views/templates/netbox_topology_views/rack_elevation.html @@ -6,6 +6,7 @@ {% block head %} + {% endblock %} {% block content %} @@ -67,6 +68,17 @@ {% endfor %} +
+
Kabeltopologie
+
+
+ +
+
+
+
+{{ rack_topology|json_script:"rack-topology-data" }} +
Verkabelung der angezeigten Racks {{ cables|length }}
@@ -91,3 +103,77 @@
Wähle ein oder mehrere Racks oder grenze die Ansicht über Standort und Location ein.
{% endif %} {% endblock %} + +{% block javascript %} + +{% endblock %} diff --git a/netbox_topology_views/views.py b/netbox_topology_views/views.py index 234bf4f..8b9aa6e 100644 --- a/netbox_topology_views/views.py +++ b/netbox_topology_views/views.py @@ -919,9 +919,14 @@ def _rack_endpoint(termination): """Return a template-friendly representation of any cable termination.""" device = getattr(termination, "device", None) return { + "node_id": f"device-{device.pk}" if device else f"external-{termination._meta.model_name}-{termination.pk}", + "device_id": getattr(device, "pk", None), "device_name": getattr(device, "name", None) or "External", "name": getattr(termination, "name", str(termination)), "rack_id": getattr(device, "rack_id", None), + "rack_name": getattr(getattr(device, "rack", None), "name", None) or "Extern", + "url": device.get_absolute_url() if device else None, + "color": getattr(getattr(device, "role", None), "color", None) or "1685fc", } @@ -967,6 +972,8 @@ class RackElevationView(PermissionRequiredMixin, View): rack.elevation_unit_labels = range(rack.u_height or 0, 0, -1) cable_rows = [] + topology_nodes = {} + topology_edges = [] if device_ids: cable_ids = CableTermination.objects.filter( _device_id__in=device_ids @@ -979,10 +986,21 @@ class RackElevationView(PermissionRequiredMixin, View): 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}) + for endpoint in (a, b): + if endpoint: + topology_nodes[endpoint["node_id"]] = endpoint + if a and b: + topology_edges.append({ + "from": a["node_id"], "to": b["node_id"], + "label": cable.label or str(cable), + "color": f"#{cable.color}" if cable.color else "#1685fc", + "url": cable.get_absolute_url(), + }) return render(request, "netbox_topology_views/rack_elevation.html", { "filter_form": RackElevationFilterForm(request.GET or None), "racks": rack_list, "cables": cable_rows, + "rack_topology": {"nodes": list(topology_nodes.values()), "edges": topology_edges}, "faces": (("front", "Vorderseite"), ("rear", "Rückseite")), "has_selection": bool(request.GET), })