feat: improve rack readability and add cable topology

This commit is contained in:
2026-07-22 09:41:03 +02:00
parent a967f88401
commit d8069edd73
4 changed files with 190 additions and 3 deletions
@@ -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;
}
+14 -3
View File
@@ -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; }
@@ -6,6 +6,7 @@
{% block head %}
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/app.css' %}">
<link rel="stylesheet" href="{% static 'netbox_topology_views/css/rack_elevation.css' %}">
{% endblock %}
{% block content %}
@@ -67,6 +68,17 @@
{% endfor %}
</div>
<div class="card mt-4">
<div class="card-header"><strong>Kabeltopologie</strong></div>
<div class="card-body">
<div id="rack-topology" class="rack-topology" aria-label="Topologie der ausgewählten Racks">
<svg class="rack-topology-lines" aria-hidden="true"></svg>
<div class="rack-topology-columns"></div>
</div>
</div>
</div>
{{ rack_topology|json_script:"rack-topology-data" }}
<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">
@@ -91,3 +103,77 @@
<div class="alert alert-info">Wähle ein oder mehrere Racks oder grenze die Ansicht über Standort und Location ein.</div>
{% endif %}
{% endblock %}
{% block javascript %}
<script>
(() => {
const root = document.getElementById('rack-topology');
const source = document.getElementById('rack-topology-data');
if (!root || !source) return;
const data = JSON.parse(source.textContent);
const columns = root.querySelector('.rack-topology-columns');
const svg = root.querySelector('svg');
const groups = new Map();
data.nodes.forEach(node => {
const groupName = node.rack_name || 'Extern';
if (!groups.has(groupName)) groups.set(groupName, []);
groups.get(groupName).push(node);
});
groups.forEach((nodes, name) => {
const column = document.createElement('section');
column.className = 'rack-topology-column';
const heading = document.createElement('h4');
heading.textContent = name;
column.appendChild(heading);
nodes.forEach(node => {
const item = document.createElement(node.url ? 'a' : 'div');
item.className = 'rack-topology-node';
item.id = `topology-${node.node_id}`;
item.style.setProperty('--node-color', `#${node.color}`);
if (node.url) item.href = node.url;
const nameElement = document.createElement('strong');
nameElement.textContent = node.device_name;
const portElement = document.createElement('small');
portElement.textContent = node.name;
item.append(nameElement, portElement);
column.appendChild(item);
});
columns.appendChild(column);
});
const drawEdges = () => {
const bounds = root.getBoundingClientRect();
const width = Math.max(root.clientWidth, columns.scrollWidth + 32);
const height = Math.max(root.clientHeight, columns.scrollHeight + 32);
svg.style.width = `${width}px`;
svg.style.height = `${height}px`;
svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
svg.replaceChildren();
data.edges.forEach(edge => {
const from = document.getElementById(`topology-${edge.from}`);
const to = document.getElementById(`topology-${edge.to}`);
if (!from || !to) return;
const a = from.getBoundingClientRect(), b = to.getBoundingClientRect();
const x1 = a.left + a.width / 2 - bounds.left;
const y1 = a.top + a.height / 2 - bounds.top;
const x2 = b.left + b.width / 2 - bounds.left;
const y2 = b.top + b.height / 2 - bounds.top;
const bend = Math.max(30, Math.abs(x2 - x1) * .35);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
path.setAttribute('stroke', edge.color);
path.classList.add('rack-topology-edge');
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
title.textContent = edge.label;
path.appendChild(title);
if (edge.url) path.addEventListener('click', () => window.location.href = edge.url);
svg.appendChild(path);
});
};
requestAnimationFrame(drawEdges);
window.addEventListener('resize', drawEdges);
})();
</script>
{% endblock %}
+18
View File
@@ -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),
})