Add SVG/PNG/draw.io export for rack concepts, center the add-device hint

- Extract the elevation's <svg> body into inc/elevation_svg_content.html,
  shared by the on-page elevation and a new standalone export template
  so they never drift apart.
- Add "Export" dropdown above the elevation: Front/Rear as standalone
  SVG (self-contained inline styles, no dependency on the page's CSS,
  absolute device-type image URLs so the file still works once saved
  elsewhere), Front/Rear as PNG (rasterized entirely client-side via
  <canvas> from that same SVG - no new server-side image dependency),
  and the whole concept as a draw.io (.drawio / mxGraph XML) diagram
  with both faces side by side, reusing the identical block geometry.
- Center the "+ Add device" hint on empty units instead of
  right-aligning it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-16 12:34:26 +02:00
co-authored by Claude Sonnet 5
parent 27d915c45e
commit 2c991e37ae
9 changed files with 387 additions and 82 deletions
+112
View File
@@ -0,0 +1,112 @@
"""
Export a rack concept as a draw.io (diagrams.net) diagram.
Reuses the same Elevation/ElevationBlock geometry the on-page SVG and the SVG export
are built from, so all three always agree on where everything sits. The output is a
plain mxGraph XML file - drawio can open it directly (File > Open) or import it
(Extras > Edit Diagram accepts the same XML pasted in).
"""
import html as html_lib
from .elevation import get_elevations
__all__ = ('build_drawio',)
LABEL_HEIGHT = 30
FACE_GAP = 60
def _escape(text):
"""HTML-escape for use inside an html=1 cell value (which is itself XML-escaped
again by the caller) - two layers, same as any HTML embedded in an XML attribute."""
return html_lib.escape(text or '', quote=False)
def _quote_attr(value):
"""XML-attribute-escape a string, matching xml.sax.saxutils.quoteattr but always
using double quotes so it's safe to splice straight into our hand-built XML."""
return '"' + (
value.replace('&', '&amp;').replace('<', '&lt;').replace('>', '&gt;').replace('"', '&quot;')
) + '"'
class _CellBuilder:
"""Accumulates mxCell XML fragments and hands out fresh ids."""
def __init__(self):
self._next_id = 1
self.cells = []
def _id(self):
self._next_id += 1
return f'c{self._next_id}'
def add(self, value, style, x, y, width, height):
cell_id = self._id()
self.cells.append(
f' <mxCell id="{cell_id}" value={_quote_attr(value)} style={_quote_attr(style)} '
f'vertex="1" parent="1">\n'
f' <mxGeometry x="{x:.2f}" y="{y:.2f}" width="{width:.2f}" height="{height:.2f}" '
f'as="geometry"/>\n'
f' </mxCell>'
)
return cell_id
def _device_style(block):
style = (
f'rounded=1;whiteSpace=wrap;html=1;arcSize=8;'
f'fillColor=#{block.color};fontColor=#{block.text_color};fontSize=11;'
)
if block.opposite:
style += 'dashed=1;opacity=45;'
return style
def build_drawio(concept):
front, rear = get_elevations(concept)
builder = _CellBuilder()
offset_x = 0.0
for elevation in (front, rear):
builder.add(
_escape(elevation.face_label.title()),
'text;html=1;align=center;verticalAlign=middle;fontStyle=1;fontSize=14;',
offset_x, 0, elevation.width, LABEL_HEIGHT - 4
)
builder.add(
'', 'rounded=0;whiteSpace=wrap;html=1;fillColor=none;strokeColor=#35404A;strokeWidth=2;',
offset_x + elevation.rack_x, LABEL_HEIGHT + 6, elevation.rack_width, elevation.height - 12
)
for unit in elevation.units:
builder.add(
str(unit.number),
'text;html=1;align=right;verticalAlign=middle;fontSize=10;fontColor=#808F9C;spacingRight=4;',
offset_x, LABEL_HEIGHT + unit.y, elevation.rack_x - 4, unit.height
)
for block in elevation.blocks:
label = _escape(block.label)
if block.sublabel:
label += '<br>' + _escape(block.sublabel)
builder.add(
label, _device_style(block),
offset_x + block.x, LABEL_HEIGHT + block.y, block.width, block.height
)
offset_x += elevation.width + FACE_GAP
body = '\n'.join(builder.cells)
return (
'<mxfile host="netbox-rack-concept">\n'
f' <diagram id="concept-{concept.pk}" name={_quote_attr(concept.name)}>\n'
' <mxGraphModel dx="800" dy="600" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" '
'arrows="1" fold="1" page="1" pageScale="1" pageWidth="850" pageHeight="1100" math="0" shadow="0">\n'
' <root>\n'
' <mxCell id="0"/>\n'
' <mxCell id="1" parent="0"/>\n'
f'{body}\n'
' </root>\n'
' </mxGraphModel>\n'
' </diagram>\n'
'</mxfile>\n'
)
+1
View File
@@ -116,6 +116,7 @@ class Elevation:
self.rack_width = RACK_WIDTH
self.rack_x = RACK_X
self.rack_right = RACK_X + RACK_WIDTH
self.rack_center_x = RACK_X + RACK_WIDTH / 2
self.width = RACK_X + RACK_WIDTH + MARGIN
self.height = concept.u_height * UNIT_HEIGHT + MARGIN * 2
@@ -0,0 +1,83 @@
/*
* "Export as PNG" - fetches the same standalone SVG the SVG download link points to
* and rasterizes it client-side via <canvas>. Deliberately no server-side image
* library involved: a NetBox plugin shouldn't need one just for this, and every
* browser can already turn an SVG into a PNG on its own.
*/
(function () {
'use strict';
const SCALE = 2; // render at 2x so the PNG stays crisp
document.querySelectorAll('.concept-export-png').forEach(function (link) {
link.addEventListener('click', function (event) {
event.preventDefault();
exportPng(link.dataset.svgUrl, link.dataset.filename || 'export.png');
});
});
function exportPng(svgUrl, filename) {
fetch(svgUrl, { credentials: 'same-origin' })
.then(function (response) {
if (!response.ok) {
throw new Error('Could not load the SVG (' + response.status + ').');
}
return response.text();
})
.then(function (svgText) {
rasterize(svgText, filename);
})
.catch(function () {
window.alert('The PNG could not be generated.');
});
}
function rasterize(svgText, filename) {
const sizeMatch = svgText.match(/<svg[^>]*\swidth="([\d.]+)"[^>]*\sheight="([\d.]+)"/);
const width = sizeMatch ? parseFloat(sizeMatch[1]) : 800;
const height = sizeMatch ? parseFloat(sizeMatch[2]) : 600;
const svgBlob = new Blob([svgText], { type: 'image/svg+xml;charset=utf-8' });
const svgUrl = URL.createObjectURL(svgBlob);
const img = new Image();
img.onload = function () {
URL.revokeObjectURL(svgUrl);
const canvas = document.createElement('canvas');
canvas.width = width * SCALE;
canvas.height = height * SCALE;
const ctx = canvas.getContext('2d');
// The SVG itself has a white background, but that's inside a <style> block,
// which some rasterizers (older WebKit) ignore when drawing to canvas.
ctx.fillStyle = '#ffffff';
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
canvas.toBlob(function (blob) {
if (!blob) {
window.alert('The PNG could not be generated.');
return;
}
const downloadUrl = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = downloadUrl;
link.download = filename;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(downloadUrl);
}, 'image/png');
};
img.onerror = function () {
URL.revokeObjectURL(svgUrl);
// Most likely a device type image from a different origin without
// permissive CORS headers, which taints the canvas and blocks toBlob().
window.alert('The PNG could not be generated. This can happen when a device '
+ 'type image is hosted on a different domain without CORS enabled.');
};
img.src = svgUrl;
}
})();
@@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"
width="{{ elevation.width }}" height="{{ elevation.height }}"
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}">
<title>{{ elevation.concept.name }} - {{ elevation.face_label|title }}</title>
<style>
/* Concrete colours, not the on-page CSS custom properties: this file is meant to
be opened outside NetBox, where those variables don't exist. */
svg { background-color: #ffffff; font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif; }
.unit-slot { fill: #f6f8fa; stroke: #dadfe5; stroke-width: 1; }
.unit-label { font-size: 10px; fill: #808f9c; }
.rack-frame { stroke: #35404a; stroke-width: 2; }
.device-rect { stroke: rgba(0, 0, 0, 0.35); stroke-width: 1; }
.device-label { font-size: 11px; }
.device-image-label { font-size: 11px; }
.device-image-label-stroke { fill: none; stroke: #000000; stroke-width: 0.2em; stroke-linejoin: round; }
.device-image-label-fill { fill: #ffffff; }
.opposite-hint { font-size: 9px; opacity: 0.8; }
.device-block.opposite .device-rect { stroke-dasharray: 3 2; }
.add-device-hint { display: none; }
</style>
{% include 'netbox_rack_concept/inc/elevation_svg_content.html' %}
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,90 +1,10 @@
{% load i18n %}
{% load l10n %}
{% comment %}
One face of a concept rack. Localization is switched off below because SVG
coordinates must use a dot as the decimal separator whatever the UI language is.
{% endcomment %}
{% localize off %}
<div class="concept-elevation" data-face="{{ elevation.face }}">
<h5 class="text-center text-muted mb-2">{{ elevation.face_label|title }}</h5>
<svg width="{{ elevation.width }}" height="{{ elevation.height }}"
viewBox="0 0 {{ elevation.width }} {{ elevation.height }}"
class="concept-elevation-svg" role="img"
aria-label="{% blocktrans with face=elevation.face_label name=elevation.concept.name %}{{ face }} elevation of {{ name }}{% endblocktrans %}">
{# Unit rows and legend. An empty unit becomes a click target for adding a device
here, exactly like a real rack's elevation. #}
{% for unit in elevation.units %}
<g class="unit">
{% if not unit.occupied and can_add_device %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ elevation.concept.pk }}&amp;position={{ unit.number }}&amp;face={{ elevation.face }}&amp;return_url={{ elevation.concept.get_absolute_url }}">
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot free"
data-unit="{{ unit.number }}"></rect>
<text x="{{ elevation.rack_right|add:'-6' }}" y="{{ unit.y }}" dy="15"
class="add-device-hint" text-anchor="end">+ {% trans "Add device" %}</text>
</a>
{% else %}
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
{% endif %}
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g>
{% endfor %}
{# Devices #}
{% for block in elevation.blocks %}
<g class="device-block {% if block.opposite %}opposite{% endif %}"
data-device-id="{{ block.device.pk }}"
data-position="{{ block.device.position }}"
data-u-height="{{ block.device.effective_u_height }}"
data-width-fraction="{{ block.device.width_fraction }}">
<a href="{{ block.device.get_absolute_url }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
rx="2" fill="#{{ block.color }}"
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-rect"></rect>
{% comment %}
The plain label is always in the markup, image or not - toggling "images
only" just layers the (opaque) image on top of it, exactly like NetBox's
own rack elevation does. That way there is one code path, not two.
{% endcomment %}
{% if block.show_text %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
fill="#{{ block.text_color }}" class="device-label">
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
</text>
{% endif %}
{% if block.image %}
<clipPath id="concept-block-{{ elevation.face }}-{{ block.device.pk }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}" rx="2"></rect>
</clipPath>
<image href="{{ block.image.url }}" x="{{ block.x }}" y="{{ block.y }}"
width="{{ block.width }}" height="{{ block.height }}"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-image"></image>
{% if block.show_text %}
{# Outlined white-on-black text, drawn as a stroke pass then a fill pass, so it reads over any image without a background box. #}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-stroke">{{ block.label }}</text>
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-fill">{{ block.label }}</text>
{% endif %}
{% endif %}
<title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; U{{ block.device.position }}</title>
</a>
</g>
{% endfor %}
<rect x="{{ elevation.rack_x }}" y="6"
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
class="rack-frame" fill="none"></rect>
{% include 'netbox_rack_concept/inc/elevation_svg_content.html' %}
</svg>
</div>
{% endlocalize %}
@@ -0,0 +1,86 @@
{% load i18n %}
{% load l10n %}
{% comment %}
Everything inside one face's <svg>...</svg> - shared by the on-page elevation
(inc/elevation.html) and the standalone SVG export (elevation_export.html), so the
two never drift apart. Localization is switched off because SVG coordinates must use
a dot as the decimal separator whatever the UI language is.
{% endcomment %}
{% localize off %}
{# Unit rows and legend. An empty unit becomes a click target for adding a device
here, exactly like a real rack's elevation. #}
{% for unit in elevation.units %}
<g class="unit">
{% if not unit.occupied and can_add_device %}
<a href="{% url 'plugins:netbox_rack_concept:conceptdevice_add' %}?concept={{ elevation.concept.pk }}&amp;position={{ unit.number }}&amp;face={{ elevation.face }}&amp;return_url={{ elevation.concept.get_absolute_url }}">
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot free"
data-unit="{{ unit.number }}"></rect>
<text x="{{ elevation.rack_center_x }}" y="{{ unit.y }}" dy="15"
class="add-device-hint" text-anchor="middle">+ {% trans "Add device" %}</text>
</a>
{% else %}
<rect x="{{ elevation.rack_x }}" y="{{ unit.y }}"
width="{{ elevation.rack_width }}" height="{{ unit.height }}"
class="unit-slot"
data-unit="{{ unit.number }}"></rect>
{% endif %}
<text x="{{ elevation.rack_x }}" y="{{ unit.y }}" dx="-4" dy="15"
class="unit-label" text-anchor="end">{{ unit.number }}</text>
</g>
{% endfor %}
{# Devices #}
{% for block in elevation.blocks %}
<g class="device-block {% if block.opposite %}opposite{% endif %}"
data-device-id="{{ block.device.pk }}"
data-position="{{ block.device.position }}"
data-u-height="{{ block.device.effective_u_height }}"
data-width-fraction="{{ block.device.width_fraction }}">
<a href="{{ block.device.get_absolute_url }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}"
rx="2" fill="#{{ block.color }}"
fill-opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-rect"></rect>
{% comment %}
The plain label is always in the markup, image or not - toggling "images
only" just layers the (opaque) image on top of it, exactly like NetBox's
own rack elevation does. That way there is one code path, not two.
{% endcomment %}
{% if block.show_text %}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
fill="#{{ block.text_color }}" class="device-label">
{{ block.label }}{% if block.opposite %} <tspan class="opposite-hint">({% trans "rear" %})</tspan>{% endif %}
</text>
{% endif %}
{% if block.image %}
<clipPath id="concept-block-{{ elevation.face }}-{{ block.device.pk }}">
<rect x="{{ block.x }}" y="{{ block.y }}" width="{{ block.width }}" height="{{ block.height }}" rx="2"></rect>
</clipPath>
{# Absolute when we have a request (the export - a file that outlives the page
it came from needs a URL that still resolves once it's saved elsewhere). #}
<image href="{% if request %}{{ request.scheme }}://{{ request.get_host }}{% endif %}{{ block.image.url }}"
x="{{ block.x }}" y="{{ block.y }}"
width="{{ block.width }}" height="{{ block.height }}"
preserveAspectRatio="xMidYMid slice"
clip-path="url(#concept-block-{{ elevation.face }}-{{ block.device.pk }})"
opacity="{% if block.opposite %}0.35{% else %}1{% endif %}"
class="device-image"></image>
{% if block.show_text %}
{# Outlined white-on-black text, drawn as a stroke pass then a fill pass, so it reads over any image without a background box. #}
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-stroke">{{ block.label }}</text>
<text x="{{ block.x }}" y="{{ block.y }}" dy="15" dx="6"
class="device-image-label device-image-label-fill">{{ block.label }}</text>
{% endif %}
{% endif %}
<title>{{ block.label }}{% if block.sublabel %} &middot; {{ block.sublabel }}{% endif %} &middot; U{{ block.device.position }}</title>
</a>
</g>
{% endfor %}
<rect x="{{ elevation.rack_x }}" y="6"
width="{{ elevation.rack_width }}" height="{{ elevation.height|add:'-12' }}"
class="rack-frame" fill="none"></rect>
{% endlocalize %}
@@ -140,6 +140,37 @@
{% if perms.netbox_rack_concept.change_conceptdevice %}
<span class="badge text-bg-secondary">{% trans "Drag devices to move them" %}</span>
{% endif %}
<div class="dropdown">
<button type="button" class="btn btn-outline-secondary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
<i class="mdi mdi-download"></i> {% trans "Export" %}
</button>
<ul class="dropdown-menu dropdown-menu-end">
<li><h6 class="dropdown-header">{% trans "Front" %}</h6></li>
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front">SVG</a>
</li>
<li>
<a class="dropdown-item concept-export-png" href="#"
data-svg-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=front"
data-filename="{{ object.name|slugify }}-front.png">PNG</a>
</li>
<li><h6 class="dropdown-header">{% trans "Rear" %}</h6></li>
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=rear">SVG</a>
</li>
<li>
<a class="dropdown-item concept-export-png" href="#"
data-svg-url="{% url 'plugins:netbox_rack_concept:rackconcept_export_svg' pk=object.pk %}?face=rear"
data-filename="{{ object.name|slugify }}-rear.png">PNG</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item" href="{% url 'plugins:netbox_rack_concept:rackconcept_export_drawio' pk=object.pk %}">
{% trans "Draw.io (.drawio)" %}
</a>
</li>
</ul>
</div>
</div>
</div>
<div class="card-body">
@@ -172,4 +203,5 @@
{% block javascript %}
{{ block.super }}
<script src="{% static 'netbox_rack_concept/elevation.js' %}"></script>
<script src="{% static 'netbox_rack_concept/export.js' %}"></script>
{% endblock %}
+44 -1
View File
@@ -6,18 +6,22 @@ from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Count
from django.http import HttpResponseForbidden, JsonResponse
from django.http import HttpResponse, HttpResponseForbidden, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.template.loader import render_to_string
from django.urls import reverse
from django.utils.text import slugify
from django.utils.translation import gettext as _
from django.views.generic import View
from dcim.choices import DeviceFaceChoices
from dcim.models import Rack
from netbox.views import generic
from utilities.views import ObjectPermissionRequiredMixin, ViewTab, register_model_view
from . import components, deploy, filtersets, forms, tables
from .choices import ConceptComponentTypeChoices
from .drawio import build_drawio
from .elevation import get_elevations
from .models import ConceptCable, ConceptComponent, ConceptDevice, RackConcept
@@ -62,6 +66,45 @@ class RackConceptView(generic.ObjectView):
}
@register_model_view(RackConcept, 'export_svg', path='export/svg')
class RackConceptExportSVGView(ObjectPermissionRequiredMixin, View):
"""Download one face's elevation as a standalone SVG file."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.view_rackconcept'
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
face = request.GET.get('face')
if face not in (DeviceFaceChoices.FACE_FRONT, DeviceFaceChoices.FACE_REAR):
face = DeviceFaceChoices.FACE_FRONT
front, rear = get_elevations(concept)
elevation = front if face == DeviceFaceChoices.FACE_FRONT else rear
svg = render_to_string('netbox_rack_concept/elevation_export.html', {'elevation': elevation}, request=request)
response = HttpResponse(svg, content_type='image/svg+xml')
response['Content-Disposition'] = f'attachment; filename="{slugify(concept.name)}-{face}.svg"'
return response
@register_model_view(RackConcept, 'export_drawio', path='export/drawio')
class RackConceptExportDrawioView(ObjectPermissionRequiredMixin, View):
"""Download both faces of a concept as one draw.io (diagrams.net) diagram."""
queryset = RackConcept.objects.all()
def get_required_permission(self):
return 'netbox_rack_concept.view_rackconcept'
def get(self, request, pk):
concept = get_object_or_404(self.queryset.restrict(request.user, 'view'), pk=pk)
xml = build_drawio(concept)
response = HttpResponse(xml, content_type='application/xml')
response['Content-Disposition'] = f'attachment; filename="{slugify(concept.name)}.drawio"'
return response
@register_model_view(RackConcept, 'devices')
class RackConceptDevicesView(generic.ObjectChildrenView):
queryset = RackConcept.objects.all()