feat: add fullscreen topology and wireless links
This commit is contained in:
@@ -16,6 +16,8 @@ Rückseite und Verkabelung.
|
|||||||
- Rackübergreifende und externe Kabelverbindungen erkennen
|
- Rackübergreifende und externe Kabelverbindungen erkennen
|
||||||
- Geräte in der Kabeltopologie frei verschieben
|
- Geräte in der Kabeltopologie frei verschieben
|
||||||
- Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren
|
- Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren
|
||||||
|
- Kabeltopologie in den Browser-Vollbildmodus schalten
|
||||||
|
- WLAN-Verbindungen als violette, gestrichelte Funkstrecken darstellen
|
||||||
- Manuelle Positionen pro Rack-Auswahl im Browser speichern
|
- Manuelle Positionen pro Rack-Auswahl im Browser speichern
|
||||||
- Verbindungen eines Geräts beim Hover weiß hervorheben
|
- Verbindungen eines Geräts beim Hover weiß hervorheben
|
||||||
- Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen
|
- Alle verbundenen Ports und Gegenstellen eines Geräts anzeigen
|
||||||
|
|||||||
@@ -66,7 +66,7 @@ html[data-netbox-color-mode="dark"] .rack-device {
|
|||||||
width: 15rem;
|
width: 15rem;
|
||||||
}
|
}
|
||||||
.rack-topology-column h4 { text-align: center; font-size: .85rem; }
|
.rack-topology-column h4 { text-align: center; font-size: .85rem; }
|
||||||
.rack-topology-device-list { display: flex; flex-direction: column; gap: .75rem; }
|
.rack-topology-device-list { display: flex; flex-direction: column; gap: 14px; }
|
||||||
.rack-topology-node {
|
.rack-topology-node {
|
||||||
display: block;
|
display: block;
|
||||||
padding: .55rem .75rem;
|
padding: .55rem .75rem;
|
||||||
@@ -106,6 +106,18 @@ html[data-netbox-color-mode="dark"] .rack-device {
|
|||||||
filter: drop-shadow(0 0 5px #fff);
|
filter: drop-shadow(0 0 5px #fff);
|
||||||
}
|
}
|
||||||
.rack-topology-edge.is-dimmed { opacity: .12; }
|
.rack-topology-edge.is-dimmed { opacity: .12; }
|
||||||
|
.rack-topology-edge.is-wireless {
|
||||||
|
stroke-dasharray: 7 7;
|
||||||
|
stroke-linecap: round;
|
||||||
|
}
|
||||||
|
#rack-topology-card:fullscreen {
|
||||||
|
margin: 0 !important;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: var(--tblr-bg-surface, #fff);
|
||||||
|
}
|
||||||
|
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; }
|
||||||
|
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
|
||||||
.rack-topology-tooltip {
|
.rack-topology-tooltip {
|
||||||
display: none;
|
display: none;
|
||||||
position: absolute;
|
position: absolute;
|
||||||
|
|||||||
@@ -76,7 +76,11 @@ html[data-netbox-color-mode=dark] #visgraph {
|
|||||||
background-color: #fff;
|
background-color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rack-topology-device-list { display: flex; flex-direction: column; gap: .75rem; }
|
.rack-topology-device-list { display: flex; flex-direction: column; gap: 14px; }
|
||||||
|
.rack-topology-edge.is-wireless { stroke-dasharray: 7 7; stroke-linecap: round; }
|
||||||
|
#rack-topology-card:fullscreen { margin: 0 !important; border: 0; border-radius: 0; background: var(--tblr-bg-surface, #fff); }
|
||||||
|
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; }
|
||||||
|
#rack-topology-card:fullscreen .rack-topology { height: calc(100vh - 5.5rem); }
|
||||||
|
|
||||||
html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] {
|
html[data-bs-theme="dark"], body[data-bs-theme="dark"], html[data-netbox-color-mode="dark"] {
|
||||||
.rack-frame { border-color: #3b4654; background-color: #212b36; }
|
.rack-frame { border-color: #3b4654; background-color: #212b36; }
|
||||||
|
|||||||
@@ -82,7 +82,7 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card mt-4">
|
<div id="rack-topology-card" class="card mt-4">
|
||||||
<div class="card-header d-flex justify-content-between align-items-center">
|
<div class="card-header d-flex justify-content-between align-items-center">
|
||||||
<strong>Kabeltopologie</strong>
|
<strong>Kabeltopologie</strong>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
@@ -94,6 +94,9 @@
|
|||||||
<button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button">
|
<button id="rack-topology-reset" class="btn btn-sm btn-outline-secondary" type="button">
|
||||||
<i class="mdi mdi-restore"></i> Positionen zurücksetzen
|
<i class="mdi mdi-restore"></i> Positionen zurücksetzen
|
||||||
</button>
|
</button>
|
||||||
|
<button id="rack-topology-fullscreen" class="btn btn-sm btn-outline-secondary" type="button">
|
||||||
|
<i class="mdi mdi-fullscreen"></i> Vollbild
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
@@ -144,6 +147,8 @@
|
|||||||
const svg = root.querySelector('svg');
|
const svg = root.querySelector('svg');
|
||||||
const tooltip = root.querySelector('.rack-topology-tooltip');
|
const tooltip = root.querySelector('.rack-topology-tooltip');
|
||||||
const resetButton = document.getElementById('rack-topology-reset');
|
const resetButton = document.getElementById('rack-topology-reset');
|
||||||
|
const fullscreenButton = document.getElementById('rack-topology-fullscreen');
|
||||||
|
const topologyCard = document.getElementById('rack-topology-card');
|
||||||
const storageKey = `netbox-rack-topology-v2:${window.location.pathname}${window.location.search}`;
|
const storageKey = `netbox-rack-topology-v2:${window.location.pathname}${window.location.search}`;
|
||||||
let storedPositions = {};
|
let storedPositions = {};
|
||||||
try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {}
|
try { storedPositions = JSON.parse(localStorage.getItem(storageKey) || '{}'); } catch (_) {}
|
||||||
@@ -224,6 +229,7 @@
|
|||||||
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
|
path.setAttribute('d', `M ${x1} ${y1} C ${x1 + bend} ${y1}, ${x2 - bend} ${y2}, ${x2} ${y2}`);
|
||||||
path.setAttribute('stroke', edge.color);
|
path.setAttribute('stroke', edge.color);
|
||||||
path.classList.add('rack-topology-edge');
|
path.classList.add('rack-topology-edge');
|
||||||
|
if (edge.kind === 'wireless') path.classList.add('is-wireless');
|
||||||
path.dataset.from = edge.from;
|
path.dataset.from = edge.from;
|
||||||
path.dataset.to = edge.to;
|
path.dataset.to = edge.to;
|
||||||
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
|
||||||
@@ -233,7 +239,7 @@
|
|||||||
path.addEventListener('mouseenter', event => {
|
path.addEventListener('mouseenter', event => {
|
||||||
path.classList.add('is-highlighted');
|
path.classList.add('is-highlighted');
|
||||||
showTooltip(
|
showTooltip(
|
||||||
`<strong>${escapeHtml(edge.label)}</strong>` +
|
`<strong>${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</strong>` +
|
||||||
`<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` +
|
`<span>${escapeHtml(edge.from_device)} · ${escapeHtml(edge.from_port)}</span>` +
|
||||||
`<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`,
|
`<span>↔ ${escapeHtml(edge.to_device)} · ${escapeHtml(edge.to_port)}</span>`,
|
||||||
event.target
|
event.target
|
||||||
@@ -261,7 +267,7 @@
|
|||||||
const localPort = outgoing ? edge.from_port : edge.to_port;
|
const localPort = outgoing ? edge.from_port : edge.to_port;
|
||||||
const remoteDevice = outgoing ? edge.to_device : edge.from_device;
|
const remoteDevice = outgoing ? edge.to_device : edge.from_device;
|
||||||
const remotePort = outgoing ? edge.to_port : edge.from_port;
|
const remotePort = outgoing ? edge.to_port : edge.from_port;
|
||||||
return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${escapeHtml(edge.label)}</small></li>`;
|
return `<li><strong>${escapeHtml(localPort)}</strong><span>→ ${escapeHtml(remoteDevice)} · ${escapeHtml(remotePort)}</span><small>${edge.kind === 'wireless' ? 'WLAN · ' : ''}${escapeHtml(edge.label)}</small></li>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node);
|
showTooltip(`<strong>Verbindungen (${connections.length})</strong><ul>${rows || '<li>Keine Verbindungen</li>'}</ul>`, node);
|
||||||
});
|
});
|
||||||
@@ -311,6 +317,17 @@
|
|||||||
root.querySelectorAll('.rack-topology-node').forEach(node => node.style.transform = '');
|
root.querySelectorAll('.rack-topology-node').forEach(node => node.style.transform = '');
|
||||||
drawEdges();
|
drawEdges();
|
||||||
});
|
});
|
||||||
|
fullscreenButton?.addEventListener('click', async () => {
|
||||||
|
if (document.fullscreenElement === topologyCard) await document.exitFullscreen();
|
||||||
|
else await topologyCard.requestFullscreen();
|
||||||
|
});
|
||||||
|
document.addEventListener('fullscreenchange', () => {
|
||||||
|
const active = document.fullscreenElement === topologyCard;
|
||||||
|
fullscreenButton.innerHTML = active
|
||||||
|
? '<i class="mdi mdi-fullscreen-exit"></i> Vollbild verlassen'
|
||||||
|
: '<i class="mdi mdi-fullscreen"></i> Vollbild';
|
||||||
|
requestAnimationFrame(drawEdges);
|
||||||
|
});
|
||||||
|
|
||||||
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({
|
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({
|
||||||
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
'&': '&', '<': '<', '>': '>', '"': '"', "'": '''
|
||||||
@@ -458,10 +475,10 @@
|
|||||||
const a = positions.get(edge.from), b = positions.get(edge.to);
|
const a = positions.get(edge.from), b = positions.get(edge.to);
|
||||||
if (!a || !b) return;
|
if (!a || !b) return;
|
||||||
const bend = Math.max(30, Math.abs(b.x - a.x) * .35);
|
const bend = Math.max(30, Math.abs(b.x - a.x) * .35);
|
||||||
edgeContent += `<path d="M ${a.x} ${a.y} C ${a.x + bend} ${a.y}, ${b.x - bend} ${b.y}, ${b.x} ${b.y}" fill="none" stroke="${xmlEscape(edge.color)}" stroke-width="3"/>`;
|
edgeContent += `<path d="M ${a.x} ${a.y} C ${a.x + bend} ${a.y}, ${b.x - bend} ${b.y}, ${b.x} ${b.y}" fill="none" stroke="${xmlEscape(edge.color)}" stroke-width="3"${edge.kind === 'wireless' ? ' stroke-dasharray="7 7"' : ''}/>`;
|
||||||
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
|
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
|
||||||
if (sourceId && targetId) {
|
if (sourceId && targetId) {
|
||||||
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(edge.label)}" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=${xmlEscape(edge.color)};strokeWidth=2;fontSize=9;" edge="1" parent="1" source="${sourceId}" target="${targetId}"><mxGeometry relative="1" as="geometry"/></mxCell>`;
|
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(edge.kind === 'wireless' ? `WLAN · ${edge.label}` : edge.label)}" style="edgeStyle=orthogonalEdgeStyle;rounded=1;orthogonalLoop=1;jettySize=auto;html=1;endArrow=none;endFill=0;strokeColor=${xmlEscape(edge.color)};strokeWidth=2;fontSize=9;${edge.kind === 'wireless' ? 'dashed=1;dashPattern=7 7;' : ''}" edge="1" parent="1" source="${sourceId}" target="${targetId}"><mxGeometry relative="1" as="geometry"/></mxCell>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
|
return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
|
||||||
|
|||||||
@@ -1017,8 +1017,30 @@ class RackElevationView(PermissionRequiredMixin, View):
|
|||||||
"label": cable.label or str(cable),
|
"label": cable.label or str(cable),
|
||||||
"color": f"#{cable.color}" if cable.color else "#1685fc",
|
"color": f"#{cable.color}" if cable.color else "#1685fc",
|
||||||
"url": cable.get_absolute_url(),
|
"url": cable.get_absolute_url(),
|
||||||
|
"kind": "cable",
|
||||||
})
|
})
|
||||||
|
|
||||||
|
wireless_links = WirelessLink.objects.restrict(request.user, "view").filter(
|
||||||
|
Q(_interface_a_device_id__in=device_ids) |
|
||||||
|
Q(_interface_b_device_id__in=device_ids)
|
||||||
|
).select_related(
|
||||||
|
"interface_a__device__rack", "interface_a__device__role", "interface_a__device__device_type",
|
||||||
|
"interface_b__device__rack", "interface_b__device__role", "interface_b__device__device_type",
|
||||||
|
)
|
||||||
|
for wireless_link in wireless_links:
|
||||||
|
a = _rack_endpoint(wireless_link.interface_a)
|
||||||
|
b = _rack_endpoint(wireless_link.interface_b)
|
||||||
|
topology_nodes[a["node_id"]] = a
|
||||||
|
topology_nodes[b["node_id"]] = b
|
||||||
|
topology_edges.append({
|
||||||
|
"from": a["node_id"], "to": b["node_id"],
|
||||||
|
"from_device": a["device_name"], "to_device": b["device_name"],
|
||||||
|
"from_port": a["name"], "to_port": b["name"],
|
||||||
|
"label": wireless_link.ssid or str(wireless_link),
|
||||||
|
"color": "#8b5cf6", "url": wireless_link.get_absolute_url(),
|
||||||
|
"kind": "wireless",
|
||||||
|
})
|
||||||
|
|
||||||
return render(request, "netbox_topology_views/rack_elevation.html", {
|
return render(request, "netbox_topology_views/rack_elevation.html", {
|
||||||
"filter_form": RackElevationFilterForm(request.GET or None),
|
"filter_form": RackElevationFilterForm(request.GET or None),
|
||||||
"racks": rack_list, "cables": cable_rows,
|
"racks": rack_list, "cables": cable_rows,
|
||||||
|
|||||||
Reference in New Issue
Block a user