Compare commits

..
14 Commits
6 changed files with 401 additions and 62 deletions
+10 -1
View File
@@ -13,8 +13,14 @@ Rückseite und Verkabelung.
- Ein oder mehrere Racks gemeinsam anzeigen - Ein oder mehrere Racks gemeinsam anzeigen
- Geräte positionsgenau anhand ihrer Höheneinheiten darstellen - Geräte positionsgenau anhand ihrer Höheneinheiten darstellen
- Vorder- und Rückseite eines Racks getrennt betrachten - Vorder- und Rückseite eines Racks getrennt betrachten
- Auf- und absteigende HE-Nummerierung sowie individuelle Start-HE automatisch berücksichtigen
- 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
- Kabeltopologie in den Browser-Vollbildmodus schalten
- Kabeltopologie zwischen 50 und 200 Prozent zoomen und im Vollbild scrollen
- Geräte in der Kabeltopologie mit rund 5 mm Abstand darstellen
- P2P-WirelessLinks und Multi-Access-WLANs 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
@@ -22,6 +28,7 @@ Rückseite und Verkabelung.
- Geräte und Kabel direkt aus der Topologie in NetBox öffnen - Geräte und Kabel direkt aus der Topologie in NetBox öffnen
- Einzelne Racks, alle Racks oder die Kabeltopologie als SVG, PNG und draw.io exportieren - Einzelne Racks, alle Racks oder die Kabeltopologie als SVG, PNG und draw.io exportieren
- Exporte grundsätzlich mit transparentem Hintergrund erzeugen - Exporte grundsätzlich mit transparentem Hintergrund erzeugen
- Farben von Rackansicht und Export an den aktiven Light- oder Dark-Mode anpassen
- Topologie als PNG oder diagrams.net/draw.io-XML exportieren - Topologie als PNG oder diagrams.net/draw.io-XML exportieren
- Zusätzliche Icons für Network-, Leaf-, Spine- und Aggregation-Switche - Zusätzliche Icons für Network-, Leaf-, Spine- und Aggregation-Switche
- Unterstützung für helle und dunkle NetBox-Themes - Unterstützung für helle und dunkle NetBox-Themes
@@ -160,7 +167,9 @@ Ein Klick öffnet das jeweilige NetBox-Objekt.
Über die Export-Schaltflächen können ein einzelnes Rack, alle aktuell Über die Export-Schaltflächen können ein einzelnes Rack, alle aktuell
angezeigten Racks oder die interaktive Kabeltopologie als SVG, PNG oder angezeigten Racks oder die interaktive Kabeltopologie als SVG, PNG oder
draw.io-Datei gespeichert werden. Sämtliche Exportformate werden ohne draw.io-Datei gespeichert werden. Sämtliche Exportformate werden ohne
Hintergrundfläche erzeugt. Hintergrundfläche erzeugt. Im draw.io-Export bleiben Rack-Rahmen, Geräte und
Beschriftungen als einzelne Elemente bearbeitbar. Topologiekabel werden als
echte Kanten exportiert und folgen den Geräten beim Verschieben in draw.io.
Die Positionen werden im `localStorage` des Browsers gespeichert. Sie gelten Die Positionen werden im `localStorage` des Browsers gespeichert. Sie gelten
damit nur für den jeweiligen Browser und die konkrete Rack-/Filterauswahl. damit nur für den jeweiligen Browser und die konkrete Rack-/Filterauswahl.
+1 -1
View File
@@ -6,7 +6,7 @@ class TopologyViewsConfig(PluginConfig):
verbose_name = "Topology views" verbose_name = "Topology views"
description = "A plugin to render topology maps" description = "A plugin to render topology maps"
version = "4.5.1" version = "4.5.1"
author = "Mattijs Vanhaverbeke" author = "Mattijs Vanhaverbeke & LKE"
author_email = "author@example.com" author_email = "author@example.com"
base_url = "netbox_topology_views" base_url = "netbox_topology_views"
required_settings = [] required_settings = []
@@ -6,6 +6,8 @@
padding: 2px 6px; padding: 2px 6px;
font-size: .78rem; font-size: .78rem;
line-height: 1.15; line-height: 1.15;
background: #eef6ff;
color: #132238;
} }
.rack-device:hover { .rack-device:hover {
min-height: 2.8rem; min-height: 2.8rem;
@@ -13,10 +15,29 @@
z-index: 3; z-index: 3;
} }
.rack-device:hover span { white-space: normal; } .rack-device:hover span { white-space: normal; }
.rack-frame {
border-color: #718096;
background-color: #fff;
background-image: repeating-linear-gradient(to top, rgba(56, 73, 89, .16) 0, rgba(56, 73, 89, .16) 1px, transparent 1px, transparent calc(100% / var(--rack-units)));
}
.rack-device:hover { color: #132238; }
html[data-bs-theme="dark"] .rack-frame,
body[data-bs-theme="dark"] .rack-frame,
html[data-netbox-color-mode="dark"] .rack-frame {
border-color: #3b4654;
background-color: #212b36;
background-image: repeating-linear-gradient(to top, rgba(200, 215, 230, .14) 0, rgba(200, 215, 230, .14) 1px, transparent 1px, transparent calc(100% / var(--rack-units)));
}
html[data-bs-theme="dark"] .rack-device,
body[data-bs-theme="dark"] .rack-device,
html[data-netbox-color-mode="dark"] .rack-device {
background: #162e45;
color: #fff;
}
.rack-topology { .rack-topology {
position: relative; position: relative;
min-height: 18rem; min-height: 18rem;
overflow-x: auto; overflow: auto;
padding: 1rem; padding: 1rem;
background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05)); background: var(--tblr-bg-surface-secondary, rgba(127, 127, 127, .05));
} }
@@ -45,6 +66,7 @@
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: 40px; }
.rack-topology-node { .rack-topology-node {
display: block; display: block;
padding: .55rem .75rem; padding: .55rem .75rem;
@@ -78,12 +100,27 @@
transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s;
} }
.rack-topology-edge.is-highlighted { .rack-topology-edge.is-highlighted {
stroke: #fff !important; stroke: var(--topology-highlight-color, #16a34a) !important;
stroke-width: 5; stroke-width: 5;
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 5px #fff); filter: drop-shadow(0 0 5px var(--topology-highlight-color, #16a34a));
} }
.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);
display: flex;
flex-direction: column;
overflow: hidden;
}
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#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;
@@ -109,3 +146,10 @@
border-top: 1px solid var(--tblr-border-color, #ced4da); border-top: 1px solid var(--tblr-border-color, #ced4da);
} }
.rack-topology-tooltip small { opacity: .65; } .rack-topology-tooltip small { opacity: .65; }
.rack-cable-count {
min-width: 2rem;
margin-left: .35rem;
background-color: #52606d !important;
color: #fff !important;
text-align: center;
}
+20 -3
View File
@@ -46,12 +46,12 @@ html[data-netbox-color-mode=dark] #visgraph {
.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-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-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-area { position: relative; flex: 1; border-left: 1px solid #3b4654; }
.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 { position: absolute; left: 3px; right: 3px; min-height: 2px; overflow: hidden; padding: 2px 6px; border: 2px solid #1685fc; border-radius: 3px; background: #eef6ff; color: #132238; 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 { min-height: 2.8rem; overflow: visible; filter: brightness(1.25); color: #fff; z-index: 3; }
.rack-device:hover span { white-space: normal; } .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 span, .rack-device small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rack-device small { font-size: .6rem; opacity: .8; } .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 { position: relative; min-height: 18rem; overflow: 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-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; pointer-events: none; } .rack-topology-columns { position: relative; z-index: 2; display: flex; align-items: flex-start; justify-content: space-around; gap: 5rem; min-width: max-content; pointer-events: none; }
.rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 15rem; } .rack-topology-column { display: flex; flex-direction: column; gap: .75rem; width: 15rem; }
@@ -62,7 +62,7 @@ html[data-netbox-color-mode=dark] #visgraph {
.rack-topology-node strong, .rack-topology-node small { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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-node small { opacity: .7; }
.rack-topology-edge { fill: none; stroke-width: 3; opacity: .8; pointer-events: stroke; cursor: pointer; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; } .rack-topology-edge { fill: none; stroke-width: 3; opacity: .8; pointer-events: stroke; cursor: pointer; transition: opacity .12s, stroke .12s, filter .12s, stroke-width .12s; }
.rack-topology-edge.is-highlighted { stroke: #fff !important; stroke-width: 5; opacity: 1; filter: drop-shadow(0 0 5px #fff); } .rack-topology-edge.is-highlighted { stroke: var(--topology-highlight-color, #16a34a) !important; stroke-width: 5; opacity: 1; filter: drop-shadow(0 0 5px var(--topology-highlight-color, #16a34a)); }
.rack-topology-edge.is-dimmed { opacity: .12; } .rack-topology-edge.is-dimmed { opacity: .12; }
.rack-topology-tooltip { display: none; position: absolute; z-index: 10; width: max-content; max-width: 28rem; padding: .65rem .8rem; border: 1px solid var(--tblr-border-color, #ced4da); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); box-shadow: 0 4px 18px rgba(0,0,0,.25); pointer-events: none; } .rack-topology-tooltip { display: none; position: absolute; z-index: 10; width: max-content; max-width: 28rem; padding: .65rem .8rem; border: 1px solid var(--tblr-border-color, #ced4da); border-radius: .35rem; background: var(--tblr-bg-surface, #fff); box-shadow: 0 4px 18px rgba(0,0,0,.25); pointer-events: none; }
.rack-topology-tooltip.is-visible { display: block; } .rack-topology-tooltip.is-visible { display: block; }
@@ -70,6 +70,23 @@ html[data-netbox-color-mode=dark] #visgraph {
.rack-topology-tooltip ul { margin: .4rem 0 0; padding: 0; list-style: none; } .rack-topology-tooltip ul { margin: .4rem 0 0; padding: 0; list-style: none; }
.rack-topology-tooltip li + li { margin-top: .45rem; padding-top: .45rem; border-top: 1px solid var(--tblr-border-color, #ced4da); } .rack-topology-tooltip li + li { margin-top: .45rem; padding-top: .45rem; border-top: 1px solid var(--tblr-border-color, #ced4da); }
.rack-topology-tooltip small { opacity: .65; } .rack-topology-tooltip small { opacity: .65; }
.rack-cable-count { min-width: 2rem; margin-left: .35rem; background-color: #52606d !important; color: #fff !important; text-align: center; }
.rack-frame {
border-color: #718096;
background-color: #fff;
}
.rack-topology-device-list { display: flex; flex-direction: column; gap: 40px; }
.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); display: flex; flex-direction: column; overflow: hidden; }
#rack-topology-card:fullscreen .card-body { min-height: 0; flex: 1; overflow: hidden; }
#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"] {
.rack-frame { border-color: #3b4654; background-color: #212b36; }
.rack-device { background: #162e45; color: #fff; }
}
@media (max-width: 700px) { @media (max-width: 700px) {
.rack-elevation-grid { grid-template-columns: 1fr; } .rack-elevation-grid { grid-template-columns: 1fr; }
@@ -41,7 +41,7 @@
</div> </div>
<div class="rack-elevation-grid"> <div class="rack-elevation-grid">
{% for rack in racks %} {% for rack in racks %}
<section class="rack-card"> <section class="rack-card" data-rack-id="{{ rack.pk }}" data-rack-units="{{ rack.u_height }}">
<header> <header>
<div><a href="{{ rack.get_absolute_url }}"><strong>{{ rack.name }}</strong></a><br> <div><a href="{{ rack.get_absolute_url }}"><strong>{{ rack.name }}</strong></a><br>
<small>{{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U</small></div> <small>{{ rack.site }}{% if rack.location %} · {{ rack.location }}{% endif %} · {{ rack.u_height }}U</small></div>
@@ -63,6 +63,9 @@
{% for device in rack.elevation_devices %} {% for device in rack.elevation_devices %}
{% if device.elevation_face == face %} {% if device.elevation_face == face %}
<a class="rack-device" href="{{ device.get_absolute_url }}" <a class="rack-device" href="{{ device.get_absolute_url }}"
data-face="{{ face }}" data-device-name="{{ device.name|default:device.device_type.model }}"
data-device-type="{{ device.device_type }}" data-position="{{ device.position|default:1 }}"
data-device-height="{{ device.device_type.u_height|default:1 }}" data-device-color="#{{ device.role.color|default:'1685fc' }}"
style="bottom: {{ device.elevation_bottom }}%; height: {{ device.elevation_height }}%; border-color: #{{ device.role.color }}" style="bottom: {{ device.elevation_bottom }}%; height: {{ device.elevation_height }}%; border-color: #{{ device.role.color }}"
title="{{ device.name }} · {{ device.device_type }} · U{{ device.position }}"> title="{{ device.name }} · {{ device.device_type }} · U{{ device.position }}">
<span>{{ device.name|default:device.device_type.model }}</span> <span>{{ device.name|default:device.device_type.model }}</span>
@@ -79,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">
@@ -88,9 +91,17 @@
<button class="btn btn-sm btn-outline-primary topology-export" data-format="png">PNG</button> <button class="btn btn-sm btn-outline-primary topology-export" data-format="png">PNG</button>
<button class="btn btn-sm btn-outline-primary topology-export" data-format="drawio">draw.io</button> <button class="btn btn-sm btn-outline-primary topology-export" data-format="drawio">draw.io</button>
</div> </div>
<div class="btn-group" aria-label="Topologie zoomen">
<button id="rack-topology-zoom-out" class="btn btn-sm btn-outline-secondary" type="button" title="Verkleinern"><i class="mdi mdi-minus"></i></button>
<button id="rack-topology-zoom-reset" class="btn btn-sm btn-outline-secondary" type="button" title="Zoom zurücksetzen"><span id="rack-topology-zoom-label">100%</span></button>
<button id="rack-topology-zoom-in" class="btn btn-sm btn-outline-secondary" type="button" title="Vergrößern"><i class="mdi mdi-plus"></i></button>
</div>
<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">
@@ -105,7 +116,7 @@
{{ rack_export|json_script:"rack-export-data" }} {{ rack_export|json_script:"rack-export-data" }}
<div class="card mt-4"> <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="card-header"><strong>Verkabelung der angezeigten Racks</strong> <span class="badge rack-cable-count">{{ cables|length }}</span></div>
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-hover mb-0"> <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> <thead><tr><th>Kabel</th><th>A-Seite</th><th>B-Seite</th><th>Typ</th><th>Status</th></tr></thead>
@@ -136,29 +147,77 @@
const source = document.getElementById('rack-topology-data'); const source = document.getElementById('rack-topology-data');
if (!root || !source) return; if (!root || !source) return;
const data = JSON.parse(source.textContent); const data = JSON.parse(source.textContent);
const isWirelessEdge = edge => edge.kind === 'wireless' || edge.kind === 'wireless-lan';
const rackData = JSON.parse(document.getElementById('rack-export-data').textContent); const rackData = JSON.parse(document.getElementById('rack-export-data').textContent);
const columns = root.querySelector('.rack-topology-columns'); const columns = root.querySelector('.rack-topology-columns');
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 storageKey = `netbox-rack-topology:${window.location.pathname}${window.location.search}`; const fullscreenButton = document.getElementById('rack-topology-fullscreen');
const topologyCard = document.getElementById('rack-topology-card');
const zoomInButton = document.getElementById('rack-topology-zoom-in');
const zoomOutButton = document.getElementById('rack-topology-zoom-out');
const zoomResetButton = document.getElementById('rack-topology-zoom-reset');
const zoomLabel = document.getElementById('rack-topology-zoom-label');
let topologyZoom = 1;
const activeNetBoxTheme = () => {
for (const element of [topologyCard, document.body, document.documentElement]) {
const match = getComputedStyle(element).backgroundColor.match(/[\d.]+/g);
if (match && match.length >= 3 && (match.length < 4 || Number(match[3]) > 0)) {
const [red, green, blue] = match.map(Number);
return (.2126 * red + .7152 * green + .0722 * blue) < 140 ? 'dark' : 'light';
}
}
return document.body.dataset.bsTheme || document.documentElement.dataset.bsTheme || 'light';
};
const topologyHighlightColor = () => activeNetBoxTheme() === 'dark' ? '#ffffff' : '#16a34a';
const applyTopologyTheme = () => {
root.style.setProperty('--topology-highlight-color', topologyHighlightColor());
root.querySelectorAll('.rack-topology-edge.is-highlighted').forEach(path => {
path.style.setProperty('stroke', topologyHighlightColor(), 'important');
path.style.setProperty('filter', `drop-shadow(0 0 5px ${topologyHighlightColor()})`, 'important');
});
};
applyTopologyTheme();
const topologyThemeObserver = new MutationObserver(() => requestAnimationFrame(applyTopologyTheme));
topologyThemeObserver.observe(document.body, {attributes: true, attributeFilter: ['data-bs-theme']});
topologyThemeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-bs-theme', 'data-netbox-color-mode'],
});
const storageKey = `netbox-rack-topology-v3:${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 (_) {}
const groups = new Map(); const groups = new Map();
data.nodes.forEach(node => { data.nodes.forEach(node => {
const groupName = node.rack_name || 'Extern'; const groupKey = node.rack_id ? `rack-${node.rack_id}` : `external-${node.rack_name || 'Extern'}`;
if (!groups.has(groupName)) groups.set(groupName, []); if (!groups.has(groupKey)) groups.set(groupKey, {
groups.get(groupName).push(node); name: node.rack_name || 'Extern',
rackUnits: node.rack_u_height || null,
descUnits: Boolean(node.rack_desc_units),
nodes: [],
});
groups.get(groupKey).nodes.push(node);
}); });
groups.forEach((nodes, name) => { groups.forEach(group => {
const column = document.createElement('section'); const column = document.createElement('section');
column.className = 'rack-topology-column'; column.className = 'rack-topology-column';
const heading = document.createElement('h4'); const heading = document.createElement('h4');
heading.textContent = name; heading.textContent = group.name;
column.appendChild(heading); column.appendChild(heading);
nodes.forEach(node => { const nodeContainer = document.createElement('div');
nodeContainer.className = 'rack-topology-device-list';
nodeContainer.style.display = 'flex';
nodeContainer.style.flexDirection = 'column';
nodeContainer.style.gap = '20px';
nodeContainer.style.rowGap = '20px';
column.appendChild(nodeContainer);
group.nodes.sort((a, b) => group.descUnits
? (a.position || 0) - (b.position || 0)
: (b.position || 0) - (a.position || 0)
).forEach(node => {
const item = document.createElement(node.url ? 'a' : 'div'); const item = document.createElement(node.url ? 'a' : 'div');
item.className = 'rack-topology-node'; item.className = 'rack-topology-node';
item.id = `topology-${node.node_id}`; item.id = `topology-${node.node_id}`;
@@ -168,11 +227,11 @@
const nameElement = document.createElement('strong'); const nameElement = document.createElement('strong');
nameElement.textContent = node.device_name; nameElement.textContent = node.device_name;
const portElement = document.createElement('small'); const portElement = document.createElement('small');
portElement.textContent = node.name; portElement.textContent = node.subtitle || node.name;
item.append(nameElement, portElement); item.append(nameElement, portElement);
const saved = storedPositions[node.node_id]; const saved = storedPositions[node.node_id];
if (saved) item.style.transform = `translate(${saved.x}px, ${saved.y}px)`; if (saved) item.style.transform = `translate(${saved.x}px, ${saved.y}px)`;
column.appendChild(item); nodeContainer.appendChild(item);
}); });
columns.appendChild(column); columns.appendChild(column);
}); });
@@ -191,11 +250,21 @@
element.textContent = value == null ? '—' : String(value); element.textContent = value == null ? '—' : String(value);
return element.innerHTML; return element.innerHTML;
}; };
const setEdgeHighlighted = (path, highlighted) => {
path.classList.toggle('is-highlighted', highlighted);
if (highlighted) {
path.style.setProperty('stroke', topologyHighlightColor(), 'important');
path.style.setProperty('filter', `drop-shadow(0 0 5px ${topologyHighlightColor()})`, 'important');
} else {
path.style.removeProperty('stroke');
path.style.removeProperty('filter');
}
};
const drawEdges = () => { const drawEdges = () => {
const bounds = root.getBoundingClientRect(); const bounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth + 32); let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight + 32); let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32);
svg.style.width = `${width}px`; svg.style.width = `${width}px`;
svg.style.height = `${height}px`; svg.style.height = `${height}px`;
svg.setAttribute('viewBox', `0 0 ${width} ${height}`); svg.setAttribute('viewBox', `0 0 ${width} ${height}`);
@@ -205,15 +274,16 @@
const to = document.getElementById(`topology-${edge.to}`); const to = document.getElementById(`topology-${edge.to}`);
if (!from || !to) return; if (!from || !to) return;
const a = from.getBoundingClientRect(), b = to.getBoundingClientRect(); const a = from.getBoundingClientRect(), b = to.getBoundingClientRect();
const x1 = a.left + a.width / 2 - bounds.left; const x1 = a.left + a.width / 2 - bounds.left + root.scrollLeft;
const y1 = a.top + a.height / 2 - bounds.top; const y1 = a.top + a.height / 2 - bounds.top + root.scrollTop;
const x2 = b.left + b.width / 2 - bounds.left; const x2 = b.left + b.width / 2 - bounds.left + root.scrollLeft;
const y2 = b.top + b.height / 2 - bounds.top; const y2 = b.top + b.height / 2 - bounds.top + root.scrollTop;
const bend = Math.max(30, Math.abs(x2 - x1) * .35); const bend = Math.max(30, Math.abs(x2 - x1) * .35);
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path'); 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('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 (isWirelessEdge(edge)) 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');
@@ -221,16 +291,16 @@
path.appendChild(title); path.appendChild(title);
if (edge.url) path.addEventListener('click', () => window.location.href = edge.url); if (edge.url) path.addEventListener('click', () => window.location.href = edge.url);
path.addEventListener('mouseenter', event => { path.addEventListener('mouseenter', event => {
path.classList.add('is-highlighted'); setEdgeHighlighted(path, true);
showTooltip( showTooltip(
`<strong>${escapeHtml(edge.label)}</strong>` + `<strong>${isWirelessEdge(edge) ? '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
); );
}); });
path.addEventListener('mouseleave', () => { path.addEventListener('mouseleave', () => {
path.classList.remove('is-highlighted'); setEdgeHighlighted(path, false);
hideTooltip(); hideTooltip();
}); });
svg.appendChild(path); svg.appendChild(path);
@@ -243,20 +313,24 @@
node.addEventListener('mouseenter', () => { node.addEventListener('mouseenter', () => {
const connections = connectedEdges(nodeId); const connections = connectedEdges(nodeId);
svg.querySelectorAll('.rack-topology-edge').forEach(path => { svg.querySelectorAll('.rack-topology-edge').forEach(path => {
path.classList.toggle('is-highlighted', path.dataset.from === nodeId || path.dataset.to === nodeId); const connected = path.dataset.from === nodeId || path.dataset.to === nodeId;
path.classList.toggle('is-dimmed', path.dataset.from !== nodeId && path.dataset.to !== nodeId); setEdgeHighlighted(path, connected);
path.classList.toggle('is-dimmed', !connected);
}); });
const rows = connections.map(edge => { const rows = connections.map(edge => {
const outgoing = edge.from === nodeId; const outgoing = edge.from === nodeId;
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>${isWirelessEdge(edge) ? '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);
}); });
node.addEventListener('mouseleave', () => { node.addEventListener('mouseleave', () => {
svg.querySelectorAll('.rack-topology-edge').forEach(path => path.classList.remove('is-highlighted', 'is-dimmed')); svg.querySelectorAll('.rack-topology-edge').forEach(path => {
setEdgeHighlighted(path, false);
path.classList.remove('is-dimmed');
});
hideTooltip(); hideTooltip();
}); });
@@ -271,8 +345,8 @@
}); });
node.addEventListener('pointermove', event => { node.addEventListener('pointermove', event => {
if (!drag) return; if (!drag) return;
const x = drag.x + event.clientX - drag.startX; const x = drag.x + (event.clientX - drag.startX) / topologyZoom;
const y = drag.y + event.clientY - drag.startY; const y = drag.y + (event.clientY - drag.startY) / topologyZoom;
drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3; drag.moved ||= Math.abs(x - drag.x) > 3 || Math.abs(y - drag.y) > 3;
node.style.transform = `translate(${x}px, ${y}px)`; node.style.transform = `translate(${x}px, ${y}px)`;
storedPositions[nodeId] = {x, y}; storedPositions[nodeId] = {x, y};
@@ -301,15 +375,112 @@
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 setTopologyZoom = value => {
topologyZoom = Math.min(2, Math.max(.5, Math.round(value * 10) / 10));
columns.style.zoom = topologyZoom;
zoomLabel.textContent = `${Math.round(topologyZoom * 100)}%`;
requestAnimationFrame(drawEdges);
};
zoomInButton?.addEventListener('click', () => setTopologyZoom(topologyZoom + .1));
zoomOutButton?.addEventListener('click', () => setTopologyZoom(topologyZoom - .1));
zoomResetButton?.addEventListener('click', () => setTopologyZoom(1));
const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({ const xmlEscape = value => String(value ?? '').replace(/[&<>"']/g, char => ({
'&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;' '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&apos;'
})[char]); })[char]);
const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, ''); const safeName = value => String(value || 'export').replace(/[^a-z0-9._-]+/gi, '-').replace(/^-|-$/g, '');
const exportPalette = () => {
const declaredTheme = activeNetBoxTheme();
const dark = declaredTheme === 'dark' || (!declaredTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
return dark ? {
frame: '#52606d', grid: '#667382', heading: '#8fc8ff', muted: '#a9b7c6',
deviceFill: '#162e45', deviceText: '#ffffff', deviceSubtext: '#cad5df',
} : {
frame: '#718096', grid: '#8b99a8', heading: '#0b69b7', muted: '#52606d',
deviceFill: '#eef6ff', deviceText: '#132238', deviceSubtext: '#52606d',
};
};
const svgDocument = (width, height, content) => const svgDocument = (width, height, content) =>
`<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`; `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">${content}</svg>`;
const drawioDocument = cells =>
`<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none" grid="1" guides="1" connect="1"><root><mxCell id="0"/><mxCell id="1" parent="0"/>${cells}</root></mxGraphModel></diagram></mxfile>`;
const renderedRackData = racks => racks.map(rack => {
const card = document.querySelector(`.rack-card[data-rack-id="${rack.id}"]`);
if (!card) return rack;
const rackUnits = Number.parseFloat(card.dataset.rackUnits) || rack.u_height || 1;
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
const bottom = Number.parseFloat(device.style.bottom) || 0;
const height = Number.parseFloat(device.style.height) || (100 / rackUnits);
const deviceHeight = Math.max(height / 100 * rackUnits, 1);
const bottomUnits = bottom / 100 * rackUnits;
const startingUnit = Number(rack.starting_unit) || 1;
return {
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
type: device.dataset.deviceType || '',
position: rack.desc_units
? startingUnit + rackUnits - deviceHeight - bottomUnits
: startingUnit + bottomUnits,
height: deviceHeight,
face: device.dataset.face === 'rear' ? 'rear' : 'front',
color: device.dataset.deviceColor || '#1685fc',
};
});
return {...rack, devices};
});
const buildRackDrawio = racks => {
const palette = exportPalette();
const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap;
let cells = '', cellId = 2;
const vertex = (value, style, x, y, width, height) => {
const id = cellId++;
cells += `<mxCell id="${id}" value="${xmlEscape(value)}" style="${style}" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${width}" height="${height}" as="geometry"/></mxCell>`;
return id;
};
racks.forEach((rack, rackIndex) => {
const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rack.u_height * unit;
const startingUnit = Number(rack.starting_unit) || 1;
vertex(rack.name, `text;html=1;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;fontSize=16;fontStyle=1;fontColor=${palette.heading};`, baseX, 0, rackWidth, 25);
vertex(`${rack.site}${rack.location ? ` · ${rack.location}` : ''} · ${rack.u_height}U`, `text;html=1;strokeColor=none;fillColor=none;align=left;fontSize=10;fontColor=${palette.muted};`, baseX, 24, rackWidth, 20);
['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
vertex(face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE', `text;html=1;strokeColor=none;fillColor=none;align=center;fontSize=10;fontColor=${palette.muted};`, x, 38, faceWidth, 17);
vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, x, header, faceWidth, rackHeight);
for (let row = 0; row < rack.u_height; row++) {
const y = header + row * unit;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rack.u_height - 1 - row;
if (faceIndex === 0) vertex(String(unitLabel), `text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=${palette.muted};`, x - labelWidth, y, labelWidth - 4, unit);
}
rack.devices.filter(device => device.face === face).forEach(device => {
const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const h = Math.max(device.height * unit, 4);
const value = h >= 31 ? `${device.name}<br><font style="font-size:9px">U${device.position} · ${device.type}</font>` : device.name;
vertex(value, `rounded=1;html=1;whiteSpace=wrap;overflow=hidden;align=left;verticalAlign=top;spacingLeft=5;spacingTop=2;fillColor=${palette.deviceFill};strokeColor=${device.color};strokeWidth=2;fontColor=${palette.deviceText};fontSize=11;`, x + 3, y + 1, faceWidth - 6, h - 2);
});
});
});
return drawioDocument(cells);
};
const buildRackSvg = racks => { const buildRackSvg = racks => {
const palette = exportPalette();
const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55; const unit = 22, faceWidth = 250, labelWidth = 28, faceGap = 28, rackGap = 44, header = 55;
const rackWidth = labelWidth + faceWidth * 2 + faceGap; const rackWidth = labelWidth + faceWidth * 2 + faceGap;
const maxUnits = Math.max(...racks.map(rack => rack.u_height || 1), 1); const maxUnits = Math.max(...racks.map(rack => rack.u_height || 1), 1);
@@ -319,35 +490,51 @@
racks.forEach((rack, rackIndex) => { racks.forEach((rack, rackIndex) => {
const baseX = 10 + rackIndex * (rackWidth + rackGap); const baseX = 10 + rackIndex * (rackWidth + rackGap);
const rackHeight = rack.u_height * unit; const rackHeight = rack.u_height * unit;
content += `<text x="${baseX}" y="20" fill="#1685fc" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`; const startingUnit = Number(rack.starting_unit) || 1;
content += `<text x="${baseX}" y="39" fill="#778899" font-family="sans-serif" font-size="11">${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rack.u_height}U</text>`; content += `<text x="${baseX}" y="20" fill="${palette.heading}" font-family="sans-serif" font-size="16" font-weight="700">${xmlEscape(rack.name)}</text>`;
content += `<text x="${baseX}" y="39" fill="${palette.muted}" font-family="sans-serif" font-size="11">${xmlEscape(rack.site)}${rack.location ? ` · ${xmlEscape(rack.location)}` : ''} · ${rack.u_height}U</text>`;
['front', 'rear'].forEach((face, faceIndex) => { ['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap); const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap);
content += `<text x="${x + faceWidth / 2}" y="51" text-anchor="middle" fill="#778899" font-family="sans-serif" font-size="10">${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}</text>`; content += `<text x="${x + faceWidth / 2}" y="51" text-anchor="middle" fill="${palette.muted}" font-family="sans-serif" font-size="10">${face === 'front' ? 'VORDERSEITE' : 'RÜCKSEITE'}</text>`;
content += `<rect x="${x}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="#52606d" stroke-width="4"/>`; content += `<rect x="${x}" y="${header}" width="${faceWidth}" height="${rackHeight}" fill="none" stroke="${palette.frame}" stroke-width="4"/>`;
for (let u = 1; u <= rack.u_height; u++) { for (let row = 0; row < rack.u_height; row++) {
const y = header + rackHeight - u * unit; const y = header + row * unit;
content += `<line x1="${x}" y1="${y}" x2="${x + faceWidth}" y2="${y}" stroke="#667382" stroke-opacity=".35"/>`; const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rack.u_height - 1 - row;
if (faceIndex === 0) content += `<text x="${x - 6}" y="${y + 15}" text-anchor="end" fill="#778899" font-family="sans-serif" font-size="9">${u}</text>`; content += `<line x1="${x}" y1="${y}" x2="${x + faceWidth}" y2="${y}" stroke="${palette.grid}" stroke-opacity=".35"/>`;
if (faceIndex === 0) content += `<text x="${x - 6}" y="${y + 15}" text-anchor="end" fill="${palette.muted}" font-family="sans-serif" font-size="9">${unitLabel}</text>`;
} }
rack.devices.filter(device => device.face === face).forEach(device => { rack.devices.filter(device => device.face === face).forEach(device => {
const y = header + rackHeight - (device.position - 1 + device.height) * unit; const positionOffset = device.position - startingUnit;
const y = rack.desc_units
? header + positionOffset * unit
: header + rackHeight - (positionOffset + device.height) * unit;
const h = Math.max(device.height * unit, 4); const h = Math.max(device.height * unit, 4);
content += `<rect x="${x + 3}" y="${y + 1}" width="${faceWidth - 6}" height="${h - 2}" rx="3" fill="#162e45" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`; content += `<rect x="${x + 3}" y="${y + 1}" width="${faceWidth - 6}" height="${h - 2}" rx="3" fill="${palette.deviceFill}" stroke="${xmlEscape(device.color)}" stroke-width="2"/>`;
if (h >= 13) content += `<text x="${x + 9}" y="${y + Math.min(15, h - 4)}" fill="#fff" font-family="sans-serif" font-size="11" font-weight="600">${xmlEscape(device.name.slice(0, 34))}</text>`; if (h >= 13) content += `<text x="${x + 9}" y="${y + Math.min(15, h - 4)}" fill="${palette.deviceText}" font-family="sans-serif" font-size="11" font-weight="600">${xmlEscape(device.name.slice(0, 34))}</text>`;
if (h >= 31) content += `<text x="${x + 9}" y="${y + 28}" fill="#cad5df" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type.slice(0, 34))}</text>`; if (h >= 31) content += `<text x="${x + 9}" y="${y + 28}" fill="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">U${device.position} · ${xmlEscape(device.type.slice(0, 34))}</text>`;
}); });
}); });
}); });
return {svg: svgDocument(width, height, content), width, height}; return {svg: svgDocument(width, height, content), drawio: buildRackDrawio(racks), width, height};
}; };
const buildTopologySvg = () => { const buildTopologySvg = () => {
const palette = exportPalette();
const rootBounds = root.getBoundingClientRect(); const rootBounds = root.getBoundingClientRect();
let width = Math.max(root.clientWidth, columns.scrollWidth + 32); let width = Math.max(root.clientWidth, columns.scrollWidth * topologyZoom + 32);
let height = Math.max(root.clientHeight, columns.scrollHeight + 32); let height = Math.max(root.clientHeight, columns.scrollHeight * topologyZoom + 32);
const positions = new Map(); const positions = new Map();
const nodeCells = new Map();
let content = ''; let content = '';
let drawioCells = '', drawioId = 2;
root.querySelectorAll('.rack-topology-column').forEach(column => {
const heading = column.querySelector('h4');
const headingBounds = heading.getBoundingClientRect();
const headingX = headingBounds.left - rootBounds.left + root.scrollLeft;
const headingY = headingBounds.top - rootBounds.top + root.scrollTop;
content += `<text x="${headingX + headingBounds.width / 2}" y="${headingY + 15}" text-anchor="middle" fill="${palette.heading}" font-family="sans-serif" font-size="13" font-weight="600">${xmlEscape(heading.textContent)}</text>`;
drawioCells += `<mxCell id="group-${drawioId++}" value="${xmlEscape(heading.textContent)}" style="text;html=1;strokeColor=none;fillColor=none;align=center;fontStyle=1;fontColor=${palette.heading};" vertex="1" parent="1"><mxGeometry x="${headingX}" y="${headingY}" width="${headingBounds.width}" height="22" as="geometry"/></mxCell>`;
});
root.querySelectorAll('.rack-topology-node').forEach(node => { root.querySelectorAll('.rack-topology-node').forEach(node => {
const bounds = node.getBoundingClientRect(); const bounds = node.getBoundingClientRect();
const x = bounds.left - rootBounds.left + root.scrollLeft; const x = bounds.left - rootBounds.left + root.scrollLeft;
@@ -356,18 +543,25 @@
height = Math.max(height, y + bounds.height + 20); height = Math.max(height, y + bounds.height + 20);
positions.set(node.dataset.nodeId, {x: x + bounds.width / 2, y: y + bounds.height / 2}); positions.set(node.dataset.nodeId, {x: x + bounds.width / 2, y: y + bounds.height / 2});
const color = getComputedStyle(node).getPropertyValue('--node-color').trim() || '#1685fc'; const color = getComputedStyle(node).getPropertyValue('--node-color').trim() || '#1685fc';
content += `<rect x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" rx="6" fill="#162e45" stroke="${xmlEscape(color)}" stroke-width="2"/>`; const cellId = `node-${drawioId++}`;
content += `<text x="${x + 10}" y="${y + 20}" fill="#fff" font-family="sans-serif" font-size="12" font-weight="600">${xmlEscape(node.querySelector('strong').textContent)}</text>`; nodeCells.set(node.dataset.nodeId, cellId);
content += `<text x="${x + 10}" y="${y + 37}" fill="#cad5df" font-family="sans-serif" font-size="9">${xmlEscape(node.querySelector('small').textContent)}</text>`; drawioCells += `<mxCell id="${cellId}" value="${xmlEscape(node.querySelector('strong').textContent)}&lt;br&gt;&lt;font style=&quot;font-size:9px&quot;&gt;${xmlEscape(node.querySelector('small').textContent)}&lt;/font&gt;" style="rounded=1;html=1;whiteSpace=wrap;align=left;verticalAlign=middle;spacingLeft=8;fillColor=${palette.deviceFill};strokeColor=${xmlEscape(color)};strokeWidth=2;fontColor=${palette.deviceText};fontSize=12;" vertex="1" parent="1"><mxGeometry x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" as="geometry"/></mxCell>`;
content += `<rect x="${x}" y="${y}" width="${bounds.width}" height="${bounds.height}" rx="6" fill="${palette.deviceFill}" stroke="${xmlEscape(color)}" stroke-width="2"/>`;
content += `<text x="${x + 10}" y="${y + 20}" fill="${palette.deviceText}" font-family="sans-serif" font-size="12" font-weight="600">${xmlEscape(node.querySelector('strong').textContent)}</text>`;
content += `<text x="${x + 10}" y="${y + 37}" fill="${palette.deviceSubtext}" font-family="sans-serif" font-size="9">${xmlEscape(node.querySelector('small').textContent)}</text>`;
}); });
let edgeContent = ''; let edgeContent = '';
data.edges.forEach(edge => { data.edges.forEach(edge => {
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"${isWirelessEdge(edge) ? ' stroke-dasharray="7 7"' : ''}/>`;
const sourceId = nodeCells.get(edge.from), targetId = nodeCells.get(edge.to);
if (sourceId && targetId) {
drawioCells += `<mxCell id="edge-${drawioId++}" value="${xmlEscape(isWirelessEdge(edge) ? `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;${isWirelessEdge(edge) ? '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), width, height}; return {svg: svgDocument(width, height, edgeContent + content), drawio: drawioDocument(drawioCells), width, height};
}; };
const downloadBlob = (blob, filename) => { const downloadBlob = (blob, filename) => {
@@ -377,7 +571,8 @@
link.click(); link.click();
setTimeout(() => URL.revokeObjectURL(link.href), 1000); setTimeout(() => URL.revokeObjectURL(link.href), 1000);
}; };
const asDrawio = ({svg, width, height}) => { const asDrawio = ({svg, drawio, width, height}) => {
if (drawio) return drawio;
const image = `data:image/svg+xml,${encodeURIComponent(svg)}`; const image = `data:image/svg+xml,${encodeURIComponent(svg)}`;
return `<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="" style="shape=image;imageAspect=0;aspect=fixed;image=${xmlEscape(image)};" vertex="1" parent="1"><mxGeometry x="0" y="0" width="${width}" height="${height}" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`; return `<mxfile host="app.diagrams.net" type="device"><diagram name="Rack export"><mxGraphModel page="0" background="none"><root><mxCell id="0"/><mxCell id="1" parent="0"/><mxCell id="2" value="" style="shape=image;imageAspect=0;aspect=fixed;image=${xmlEscape(image)};" vertex="1" parent="1"><mxGeometry x="0" y="0" width="${width}" height="${height}" as="geometry"/></mxCell></root></mxGraphModel></diagram></mxfile>`;
}; };
@@ -406,7 +601,8 @@
}; };
document.querySelectorAll('.rack-export').forEach(button => button.addEventListener('click', () => { document.querySelectorAll('.rack-export').forEach(button => button.addEventListener('click', () => {
const racks = button.dataset.scope === 'all' ? rackData : rackData.filter(rack => String(rack.id) === button.dataset.scope); const selectedRacks = button.dataset.scope === 'all' ? rackData : rackData.filter(rack => String(rack.id) === button.dataset.scope);
const racks = renderedRackData(selectedRacks);
const name = button.dataset.scope === 'all' ? 'racks-gesamt' : `rack-${safeName(racks[0]?.name)}`; const name = button.dataset.scope === 'all' ? 'racks-gesamt' : `rack-${safeName(racks[0]?.name)}`;
exportGraphic(buildRackSvg(racks), button.dataset.format, name); exportGraphic(buildRackSvg(racks), button.dataset.format, name);
})); }));
@@ -415,6 +611,7 @@
})); }));
requestAnimationFrame(drawEdges); requestAnimationFrame(drawEdges);
window.addEventListener('resize', drawEdges); window.addEventListener('resize', drawEdges);
root.addEventListener('scroll', drawEdges, {passive: true});
})(); })();
</script> </script>
{% endblock %} {% endblock %}
+77 -5
View File
@@ -27,7 +27,7 @@ from django.http import HttpRequest, HttpResponseRedirect, QueryDict
from django.shortcuts import render, get_object_or_404 from django.shortcuts import render, get_object_or_404
from django.views.generic import View from django.views.generic import View
from extras.models import Tag, SavedFilter from extras.models import Tag, SavedFilter
from wireless.models import WirelessLink from wireless.models import WirelessLAN, WirelessLink
from netbox.views.generic import ( from netbox.views.generic import (
ObjectView, ObjectView,
ObjectListView, ObjectListView,
@@ -927,6 +927,13 @@ def _rack_endpoint(termination):
"rack_name": getattr(getattr(device, "rack", None), "name", None) or "Extern", "rack_name": getattr(getattr(device, "rack", None), "name", None) or "Extern",
"url": device.get_absolute_url() if device else None, "url": device.get_absolute_url() if device else None,
"color": getattr(getattr(device, "role", None), "color", None) or "1685fc", "color": getattr(getattr(device, "role", None), "color", None) or "1685fc",
"position": float(device.position or 1) if device else None,
"height": float(max(getattr(getattr(device, "device_type", None), "u_height", 1) or 1, 1)) if device else 1,
"face": str(device.face or "front").lower() if device else "front",
"rack_u_height": getattr(getattr(device, "rack", None), "u_height", None),
"rack_desc_units": getattr(getattr(device, "rack", None), "desc_units", False),
"rack_starting_unit": getattr(getattr(device, "rack", None), "starting_unit", 1),
"subtitle": str(device.device_type) if device else getattr(termination, "name", str(termination)),
} }
@@ -960,27 +967,42 @@ class RackElevationView(PermissionRequiredMixin, View):
device_ids = [] device_ids = []
for device in devices.filter(rack_id__in=rack_ids).order_by("rack_id", "position", "name"): for device in devices.filter(rack_id__in=rack_ids).order_by("rack_id", "position", "name"):
device_ids.append(device.pk) device_ids.append(device.pk)
height = max(getattr(device.device_type, "u_height", 1) or 1, 1) height = float(max(getattr(device.device_type, "u_height", 1) or 1, 1))
rack_height = max(device.rack.u_height 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}" rack_start = float(device.rack.starting_unit or 1)
position_offset = float(device.position or rack_start) - rack_start
if device.rack.desc_units:
bottom_units = rack_height - position_offset - height
else:
bottom_units = position_offset
device.elevation_bottom = f"{max(bottom_units, 0) / rack_height * 100:.4f}"
device.elevation_height = f"{min(height / rack_height * 100, 100):.4f}" device.elevation_height = f"{min(height / rack_height * 100, 100):.4f}"
device.elevation_face = str(device.face or "front").lower() device.elevation_face = str(device.face or "front").lower()
rack_devices[device.rack_id].append(device) rack_devices[device.rack_id].append(device)
for rack in rack_list: for rack in rack_list:
rack.elevation_devices = rack_devices[rack.pk] rack.elevation_devices = rack_devices[rack.pk]
rack.elevation_unit_labels = range(rack.u_height or 0, 0, -1) rack_start = rack.starting_unit or 1
if rack.desc_units:
rack.elevation_unit_labels = range(rack_start, rack_start + (rack.u_height or 0))
else:
rack.elevation_unit_labels = range(
rack_start + (rack.u_height or 0) - 1,
rack_start - 1,
-1,
)
rack_export = [] rack_export = []
for rack in rack_list: for rack in rack_list:
rack_export.append({ rack_export.append({
"id": rack.pk, "name": rack.name, "u_height": rack.u_height, "id": rack.pk, "name": rack.name, "u_height": rack.u_height,
"starting_unit": rack.starting_unit, "desc_units": rack.desc_units,
"site": str(rack.site), "location": str(rack.location or ""), "site": str(rack.site), "location": str(rack.location or ""),
"devices": [{ "devices": [{
"name": device.name or str(device.device_type), "name": device.name or str(device.device_type),
"type": str(device.device_type), "type": str(device.device_type),
"position": float(device.position or 1), "position": float(device.position or 1),
"height": max(device.device_type.u_height or 1, 1), "height": float(max(device.device_type.u_height or 1, 1)),
"face": device.elevation_face, "face": device.elevation_face,
"color": f"#{device.role.color}" if device.role.color else "#1685fc", "color": f"#{device.role.color}" if device.role.color else "#1685fc",
} for device in rack.elevation_devices], } for device in rack.elevation_devices],
@@ -1012,6 +1034,56 @@ 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",
})
wireless_lans = WirelessLAN.objects.restrict(request.user, "view").filter(
interfaces__device_id__in=device_ids
).distinct()
for wireless_lan in wireless_lans:
wlan_node_id = f"wlan-{wireless_lan.pk}"
topology_nodes[wlan_node_id] = {
"node_id": wlan_node_id, "device_id": None,
"device_name": f"WLAN · {wireless_lan.ssid}",
"name": wireless_lan.ssid, "subtitle": "Multi-Access WLAN",
"rack_id": None, "rack_name": "Funknetze",
"url": wireless_lan.get_absolute_url(), "color": "8b5cf6",
"position": None, "height": 1, "face": "front", "rack_u_height": None,
}
wlan_interfaces = Interface.objects.restrict(request.user, "view").filter(
wireless_lans=wireless_lan
).select_related("device__rack", "device__role", "device__device_type")
for interface in wlan_interfaces:
endpoint = _rack_endpoint(interface)
topology_nodes[endpoint["node_id"]] = endpoint
topology_edges.append({
"from": endpoint["node_id"], "to": wlan_node_id,
"from_device": endpoint["device_name"],
"to_device": f"WLAN · {wireless_lan.ssid}",
"from_port": endpoint["name"], "to_port": wireless_lan.ssid,
"label": wireless_lan.ssid, "color": "#8b5cf6",
"url": wireless_lan.get_absolute_url(), "kind": "wireless-lan",
}) })
return render(request, "netbox_topology_views/rack_elevation.html", { return render(request, "netbox_topology_views/rack_elevation.html", {