Compare commits

...
7 Commits
5 changed files with 117 additions and 38 deletions
+1
View File
@@ -13,6 +13,7 @@ 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 - Geräte in der Kabeltopologie innerhalb jedes Racks nach ihrer Rackposition sortieren
@@ -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: 20px; } .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;
@@ -100,18 +100,12 @@ html[data-netbox-color-mode="dark"] .rack-device {
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: #22c55e !important; stroke: var(--topology-highlight-color, #16a34a) !important;
stroke-width: 5; stroke-width: 5;
opacity: 1; opacity: 1;
filter: drop-shadow(0 0 5px #22c55e); 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; }
html[data-bs-theme="dark"] .rack-topology-edge.is-highlighted,
body[data-bs-theme="dark"] .rack-topology-edge.is-highlighted,
html[data-netbox-color-mode="dark"] .rack-topology-edge.is-highlighted {
stroke: #fff !important;
filter: drop-shadow(0 0 5px #fff);
}
.rack-topology-edge.is-wireless { .rack-topology-edge.is-wireless {
stroke-dasharray: 7 7; stroke-dasharray: 7 7;
stroke-linecap: round; stroke-linecap: round;
@@ -152,3 +146,10 @@ html[data-netbox-color-mode="dark"] .rack-topology-edge.is-highlighted {
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;
}
@@ -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: #22c55e !important; stroke-width: 5; opacity: 1; filter: drop-shadow(0 0 5px #22c55e); } .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,13 +70,14 @@ 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 { .rack-frame {
border-color: #718096; border-color: #718096;
background-color: #fff; background-color: #fff;
} }
.rack-topology-device-list { display: flex; flex-direction: column; gap: 20px; } .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-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 { 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 .card-body { min-height: 0; flex: 1; overflow: hidden; }
@@ -85,7 +86,6 @@ html[data-netbox-color-mode=dark] #visgraph {
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; }
.rack-device { background: #162e45; color: #fff; } .rack-device { background: #162e45; color: #fff; }
.rack-topology-edge.is-highlighted { stroke: #fff !important; filter: drop-shadow(0 0 5px #fff); }
} }
@media (max-width: 700px) { @media (max-width: 700px) {
@@ -116,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>
@@ -160,7 +160,32 @@
const zoomResetButton = document.getElementById('rack-topology-zoom-reset'); const zoomResetButton = document.getElementById('rack-topology-zoom-reset');
const zoomLabel = document.getElementById('rack-topology-zoom-label'); const zoomLabel = document.getElementById('rack-topology-zoom-label');
let topologyZoom = 1; let topologyZoom = 1;
const storageKey = `netbox-rack-topology-v2:${window.location.pathname}${window.location.search}`; 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();
@@ -170,6 +195,7 @@
if (!groups.has(groupKey)) groups.set(groupKey, { if (!groups.has(groupKey)) groups.set(groupKey, {
name: node.rack_name || 'Extern', name: node.rack_name || 'Extern',
rackUnits: node.rack_u_height || null, rackUnits: node.rack_u_height || null,
descUnits: Boolean(node.rack_desc_units),
nodes: [], nodes: [],
}); });
groups.get(groupKey).nodes.push(node); groups.get(groupKey).nodes.push(node);
@@ -183,8 +209,15 @@
column.appendChild(heading); column.appendChild(heading);
const nodeContainer = document.createElement('div'); const nodeContainer = document.createElement('div');
nodeContainer.className = 'rack-topology-device-list'; 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); column.appendChild(nodeContainer);
group.nodes.sort((a, b) => (b.position || 0) - (a.position || 0)).forEach(node => { 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}`;
@@ -217,6 +250,16 @@
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();
@@ -248,7 +291,7 @@
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>${isWirelessEdge(edge) ? 'WLAN · ' : ''}${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>` +
@@ -257,7 +300,7 @@
); );
}); });
path.addEventListener('mouseleave', () => { path.addEventListener('mouseleave', () => {
path.classList.remove('is-highlighted'); setEdgeHighlighted(path, false);
hideTooltip(); hideTooltip();
}); });
svg.appendChild(path); svg.appendChild(path);
@@ -270,8 +313,9 @@
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;
@@ -283,7 +327,10 @@
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();
}); });
@@ -354,9 +401,7 @@
})[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 exportPalette = () => {
const declaredTheme = document.body.dataset.bsTheme || const declaredTheme = activeNetBoxTheme();
document.documentElement.dataset.bsTheme ||
document.documentElement.dataset.netboxColorMode || '';
const dark = declaredTheme === 'dark' || (!declaredTheme && window.matchMedia('(prefers-color-scheme: dark)').matches); const dark = declaredTheme === 'dark' || (!declaredTheme && window.matchMedia('(prefers-color-scheme: dark)').matches);
return dark ? { return dark ? {
frame: '#52606d', grid: '#667382', heading: '#8fc8ff', muted: '#a9b7c6', frame: '#52606d', grid: '#667382', heading: '#8fc8ff', muted: '#a9b7c6',
@@ -378,11 +423,16 @@
const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => { const devices = Array.from(card.querySelectorAll('.rack-device')).map(device => {
const bottom = Number.parseFloat(device.style.bottom) || 0; const bottom = Number.parseFloat(device.style.bottom) || 0;
const height = Number.parseFloat(device.style.height) || (100 / rackUnits); 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 { return {
name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät', name: device.dataset.deviceName || device.querySelector('span')?.textContent || 'Gerät',
type: device.dataset.deviceType || '', type: device.dataset.deviceType || '',
position: bottom / 100 * rackUnits + 1, position: rack.desc_units
height: Math.max(height / 100 * rackUnits, 1), ? startingUnit + rackUnits - deviceHeight - bottomUnits
: startingUnit + bottomUnits,
height: deviceHeight,
face: device.dataset.face === 'rear' ? 'rear' : 'front', face: device.dataset.face === 'rear' ? 'rear' : 'front',
color: device.dataset.deviceColor || '#1685fc', color: device.dataset.deviceColor || '#1685fc',
}; };
@@ -403,18 +453,23 @@
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;
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.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); 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) => { ['front', 'rear'].forEach((face, faceIndex) => {
const x = baseX + labelWidth + faceIndex * (faceWidth + faceGap); 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(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); vertex('', `rounded=0;html=1;fillColor=none;strokeColor=${palette.frame};strokeWidth=4;`, x, header, faceWidth, rackHeight);
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;
if (faceIndex === 0) vertex(String(u), `text;html=1;strokeColor=none;fillColor=none;align=right;fontSize=8;fontColor=${palette.muted};`, x - labelWidth, y, labelWidth - 4, 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 => { 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);
const value = h >= 31 ? `${device.name}<br><font style="font-size:9px">U${device.position} · ${device.type}</font>` : device.name; 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); 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);
@@ -435,19 +490,24 @@
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;
const startingUnit = Number(rack.starting_unit) || 1;
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="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>`; 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="${palette.muted}" 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="${palette.frame}" 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;
const unitLabel = rack.desc_units ? startingUnit + row : startingUnit + rack.u_height - 1 - row;
content += `<line x1="${x}" y1="${y}" x2="${x + faceWidth}" y2="${y}" stroke="${palette.grid}" stroke-opacity=".35"/>`; 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">${u}</text>`; 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="${palette.deviceFill}" 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="${palette.deviceText}" 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>`;
+22 -5
View File
@@ -928,9 +928,11 @@ def _rack_endpoint(termination):
"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, "position": float(device.position or 1) if device else None,
"height": max(getattr(getattr(device, "device_type", None), "u_height", 1) or 1, 1) if device else 1, "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", "face": str(device.face or "front").lower() if device else "front",
"rack_u_height": getattr(getattr(device, "rack", None), "u_height", None), "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)), "subtitle": str(device.device_type) if device else getattr(termination, "name", str(termination)),
} }
@@ -965,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],