feat: improve rack readability and add cable topology
This commit is contained in:
@@ -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;
|
||||
}
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user