feat: optional topology per rack
- New topology option "Umfang": whole network (per site) and/or per rack, one landscape page each. Rack pages are grouped under "Topologie je Rack" with the rack titles one heading level lower to keep the table of contents short. - Directly connected devices outside the rack are shown dimmed and dashed with their rack as subtitle, so uplinks stay visible. - Cable paths are collected once per export and reused for all views. Fix: devices from other sites were able to pull in their own neighbors as external nodes; only direct neighbors of a group's devices are shown now. Bump version to 0.8.0. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -222,6 +222,8 @@ Der Abschnitt **Netzwerk-Topologie** zeichnet je Standort ein Diagramm mit Symbo
|
|||||||
Die Reihenfolge innerhalb einer Ebene wird so optimiert, dass sich möglichst wenige Leitungen kreuzen.
|
Die Reihenfolge innerhalb einer Ebene wird so optimiert, dass sich möglichst wenige Leitungen kreuzen.
|
||||||
* Leitungen werden **rechtwinklig** über eigene Sammelschienen geführt, farbig je übergeordnetem Gerät. Portnamen stehen
|
* Leitungen werden **rechtwinklig** über eigene Sammelschienen geführt, farbig je übergeordnetem Gerät. Portnamen stehen
|
||||||
direkt am Zielgerät, Mehrfach-Uplinks werden dicker gezeichnet und als „2ד markiert.
|
direkt am Zielgerät, Mehrfach-Uplinks werden dicker gezeichnet und als „2ד markiert.
|
||||||
|
* **Umfang** wählbar: *Gesamtes Netzwerk* (eine Seite je Standort) und/oder *Je Rack* (eine Seite je Rack). In der
|
||||||
|
Rack-Ansicht erscheinen direkt verbundene Geräte außerhalb des Racks blass und gestrichelt, mit Angabe ihres Racks.
|
||||||
* Jede Topologie steht auf einer **eigenen Seite im Querformat** (PDF und Word) und wird so groß wie möglich skaliert.
|
* Jede Topologie steht auf einer **eigenen Seite im Querformat** (PDF und Word) und wird so groß wie möglich skaliert.
|
||||||
Danach geht das Dokument im Hochformat weiter.
|
Danach geht das Dokument im Hochformat weiter.
|
||||||
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
|
* Provider-Leitungen erscheinen als Wolke, Geräte anderer Standorte gestrichelt (abschaltbar).
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ class CustomerExportConfig(PluginConfig):
|
|||||||
name = "netbox_customer_export"
|
name = "netbox_customer_export"
|
||||||
verbose_name = "Kunden-Export"
|
verbose_name = "Kunden-Export"
|
||||||
description = "Kundendokumentation als PDF oder Word exportieren"
|
description = "Kundendokumentation als PDF oder Word exportieren"
|
||||||
version = "0.7.0"
|
version = "0.8.0"
|
||||||
author = "Claude"
|
author = "Claude"
|
||||||
base_url = "customer-export"
|
base_url = "customer-export"
|
||||||
min_version = "4.6.8"
|
min_version = "4.6.8"
|
||||||
|
|||||||
@@ -72,6 +72,13 @@ class ExportForm(forms.Form):
|
|||||||
widget=forms.CheckboxSelectMultiple,
|
widget=forms.CheckboxSelectMultiple,
|
||||||
label="Beschriftung",
|
label="Beschriftung",
|
||||||
)
|
)
|
||||||
|
topo_scope = forms.MultipleChoiceField(
|
||||||
|
choices=(("site", "Gesamtes Netzwerk (je Standort)"), ("rack", "Je Rack")),
|
||||||
|
initial=["site"],
|
||||||
|
required=False,
|
||||||
|
widget=forms.CheckboxSelectMultiple,
|
||||||
|
label="Umfang",
|
||||||
|
)
|
||||||
topo_external = forms.BooleanField(
|
topo_external = forms.BooleanField(
|
||||||
required=False, initial=True, label="Provider-Leitungen & Geräte außerhalb anzeigen"
|
required=False, initial=True, label="Provider-Leitungen & Geräte außerhalb anzeigen"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -432,12 +432,19 @@ def topology(scope, opts):
|
|||||||
from .topology import build_graphs, render_png
|
from .topology import build_graphs, render_png
|
||||||
|
|
||||||
title = H(1, "Netzwerk-Topologie")
|
title = H(1, "Netzwerk-Topologie")
|
||||||
graphs = build_graphs(scope, opts)
|
scopes = opts.get("topo_scope") or ["site"]
|
||||||
if not graphs:
|
|
||||||
return _empty(opts, [title], "Keine verkabelten Geräte gefunden.")
|
|
||||||
ports = "ports" in (opts.get("topo_details") or [])
|
ports = "ports" in (opts.get("topo_details") or [])
|
||||||
# Je Standort eine Querformat-Seite; die Abschnittsüberschrift steht mit auf der ersten
|
pages = [] # (Überschriften, Graph) – je eine Querformat-Seite
|
||||||
|
if "site" in scopes:
|
||||||
|
pages += [([H(2, g.title)], g) for g in build_graphs(scope, opts, per="site")]
|
||||||
|
if "rack" in scopes:
|
||||||
|
racks = build_graphs(scope, opts, per="rack")
|
||||||
|
# Rack-Seiten unter einer gemeinsamen Zwischenüberschrift, Racks selbst eine Ebene tiefer (kürzeres Inhaltsverzeichnis)
|
||||||
|
pages += [(([H(2, "Topologie je Rack")] if i == 0 else []) + [H(3, g.title)], g) for i, g in enumerate(racks)]
|
||||||
|
if not pages:
|
||||||
|
return _empty(opts, [title], "Keine verkabelten Geräte gefunden.")
|
||||||
|
# Die Abschnittsüberschrift steht mit auf der ersten Seite
|
||||||
return [
|
return [
|
||||||
LANDSCAPE(([title] if i == 0 else []) + [H(2, g.title)], render_png(g, show_ports=ports))
|
LANDSCAPE(([title] if i == 0 else []) + headings, render_png(g, show_ports=ports))
|
||||||
for i, g in enumerate(graphs)
|
for i, (headings, g) in enumerate(pages)
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
.landscape { page: landscape; page-break-before: always; page-break-after: always; }
|
.landscape { page: landscape; page-break-before: always; page-break-after: always; }
|
||||||
.landscape h1 { page-break-before: avoid; margin: 0 0 2pt 0; font-size: 15pt; padding-bottom: 1pt; }
|
.landscape h1 { page-break-before: avoid; margin: 0 0 2pt 0; font-size: 15pt; padding-bottom: 1pt; }
|
||||||
.landscape h2 { margin: 2pt 0 3pt 0; font-size: 12pt; }
|
.landscape h2 { margin: 2pt 0 3pt 0; font-size: 12pt; }
|
||||||
|
.landscape h3 { margin: 2pt 0 3pt 0; font-size: 11pt; }
|
||||||
.landscape img { display: block; margin: 0 auto; max-width: 100%; max-height: 172mm; }
|
.landscape img { display: block; margin: 0 auto; max-width: 100%; max-height: 172mm; }
|
||||||
@media screen { .landscape img { max-height: none; width: 100%; } }
|
@media screen { .landscape img { max-height: none; width: 100%; } }
|
||||||
.pagebreak { page-break-after: always; }
|
.pagebreak { page-break-after: always; }
|
||||||
@@ -82,7 +83,7 @@
|
|||||||
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
|
{% elif b.kind == 'pagebreak' %}<div class="pagebreak"></div>
|
||||||
{% elif b.kind == 'landscape' %}
|
{% elif b.kind == 'landscape' %}
|
||||||
<section class="landscape">
|
<section class="landscape">
|
||||||
{% for h in b.rows %}{% if h.level == 1 %}<h1 id="{{ h.anchor }}">{{ h.text }}</h1>{% else %}<h2 id="{{ h.anchor }}">{{ h.text }}</h2>{% endif %}{% endfor %}
|
{% for h in b.rows %}{% if h.level == 1 %}<h1 id="{{ h.anchor }}">{{ h.text }}</h1>{% elif h.level == 2 %}<h2 id="{{ h.anchor }}">{{ h.text }}</h2>{% else %}<h3 id="{{ h.anchor }}">{{ h.text }}</h3>{% endif %}{% endfor %}
|
||||||
<img src="{{ b.images.0.data_uri }}" alt="">
|
<img src="{{ b.images.0.data_uri }}" alt="">
|
||||||
</section>
|
</section>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|||||||
@@ -131,7 +131,10 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="ce-label mt-0">Beschriftung</div>
|
<div class="ce-label mt-0">Umfang</div>
|
||||||
|
{% include 'netbox_customer_export/_pills.html' with field=form.topo_scope %}
|
||||||
|
<div class="form-text mb-2">„Je Rack“ zeigt zusätzlich jedes Rack einzeln; verbundene Geräte außerhalb erscheinen gestrichelt.</div>
|
||||||
|
<div class="ce-label">Beschriftung</div>
|
||||||
{% include 'netbox_customer_export/_pills.html' with field=form.topo_details %}
|
{% include 'netbox_customer_export/_pills.html' with field=form.topo_details %}
|
||||||
<div class="ce-label">Weiteres</div>
|
<div class="ce-label">Weiteres</div>
|
||||||
<label class="ce-switch">{{ form.topo_external }} {{ form.topo_external.label }}</label>
|
<label class="ce-switch">{{ form.topo_external }} {{ form.topo_external.label }}</label>
|
||||||
|
|||||||
@@ -76,14 +76,78 @@ def _device_sub(device, opts):
|
|||||||
return sub
|
return sub
|
||||||
|
|
||||||
|
|
||||||
def build_graphs(scope, opts):
|
def build_graphs(scope, opts, per="site"):
|
||||||
"""Ein Graph je Standort. Geräte anderer Standorte und Provider-Leitungen erscheinen als externe Knoten."""
|
"""Ein Graph je Standort (per="site") oder je Rack (per="rack").
|
||||||
|
|
||||||
|
Verbundene Geräte außerhalb der Gruppe (anderer Standort/anderes Rack) und Provider-Leitungen erscheinen
|
||||||
|
als externe, gestrichelte Knoten – so sieht man auch in der Rack-Ansicht, wohin die Uplinks gehen.
|
||||||
|
"""
|
||||||
|
cache = getattr(scope, "_topology_cache", None)
|
||||||
|
if cache is None:
|
||||||
|
cache = scope._topology_cache = _collect(scope, opts)
|
||||||
|
by_pk, drawn, links, external = cache
|
||||||
|
|
||||||
|
groups = OrderedDict()
|
||||||
|
for pk in drawn:
|
||||||
|
d = by_pk[pk]
|
||||||
|
key = d.site if per == "site" else d.rack
|
||||||
|
if key is not None:
|
||||||
|
groups.setdefault(key, []).append(pk)
|
||||||
|
if per == "rack":
|
||||||
|
groups = OrderedDict(
|
||||||
|
sorted(groups.items(), key=lambda kv: (str(kv[0].site), str(kv[0].location or ""), kv[0].name))
|
||||||
|
)
|
||||||
|
|
||||||
|
def context(d):
|
||||||
|
"""Untertitel eines externen Geräts: wo es steht."""
|
||||||
|
if per == "rack":
|
||||||
|
return [f"Rack {d.rack}" if d.rack else str(d.site)]
|
||||||
|
return [str(d.site)]
|
||||||
|
|
||||||
|
graphs = []
|
||||||
|
for group, pks in groups.items():
|
||||||
|
nodes = OrderedDict()
|
||||||
|
for pk in pks:
|
||||||
|
d = by_pk[pk]
|
||||||
|
kind, icon = drawn[pk]
|
||||||
|
nodes[f"d{pk}"] = Node(f"d{pk}", d.name or str(d), kind, icon, _device_sub(d, opts))
|
||||||
|
members = set(nodes) # nur Geräte der Gruppe – externe Nachbarn ziehen keine weiteren Nachbarn nach
|
||||||
|
edges = []
|
||||||
|
for (a, b), labels in links.items():
|
||||||
|
in_a, in_b = a in members, b in members
|
||||||
|
if not (in_a or in_b):
|
||||||
|
continue
|
||||||
|
for nid, inside in ((a, in_a), (b, in_b)):
|
||||||
|
if not inside:
|
||||||
|
other = external.get(nid)
|
||||||
|
if other is None: # Gerät im selben Scope, aber anderer Standort / anderes Rack
|
||||||
|
d = by_pk[int(nid[1:])]
|
||||||
|
kind, icon = drawn[d.pk]
|
||||||
|
other = Node(nid, d.name or str(d), kind, icon, context(d), external=True)
|
||||||
|
nodes[nid] = Node(**{**other.__dict__})
|
||||||
|
edges.append(Edge(a, b, labels))
|
||||||
|
if not opts.get("topo_isolated"):
|
||||||
|
linked = {n for e in edges for n in (e.a, e.b)}
|
||||||
|
nodes = OrderedDict((k, v) for k, v in nodes.items() if k in linked)
|
||||||
|
if nodes:
|
||||||
|
_layout(nodes, edges)
|
||||||
|
if per == "site":
|
||||||
|
title = f"Standort {group}"
|
||||||
|
else:
|
||||||
|
where = " / ".join(str(x) for x in (group.site, group.location) if x)
|
||||||
|
title = f"Rack {group.name} ({where})"
|
||||||
|
graphs.append(Graph(title, nodes, edges))
|
||||||
|
return graphs
|
||||||
|
|
||||||
|
|
||||||
|
def _collect(scope, opts):
|
||||||
|
"""Geräte, Symbole und Verbindungen (Kabelpfade) des Scopes einmalig einlesen."""
|
||||||
from dcim.models import Interface
|
from dcim.models import Interface
|
||||||
|
|
||||||
resolver = SymbolResolver()
|
resolver = SymbolResolver()
|
||||||
devices = list(
|
devices = list(
|
||||||
scope.devices().select_related(
|
scope.devices().select_related(
|
||||||
"role", "device_type__manufacturer", "site", "primary_ip4", "primary_ip6"
|
"role", "device_type__manufacturer", "site", "rack__site", "rack__location", "primary_ip4", "primary_ip6"
|
||||||
).order_by("site__name", "name")
|
).order_by("site__name", "name")
|
||||||
)
|
)
|
||||||
drawn = {} # device.pk -> (kind, icon)
|
drawn = {} # device.pk -> (kind, icon)
|
||||||
@@ -139,38 +203,7 @@ def build_graphs(scope, opts):
|
|||||||
if label not in links[key]:
|
if label not in links[key]:
|
||||||
links[key].append(label)
|
links[key].append(label)
|
||||||
|
|
||||||
# Knoten je Standort
|
return by_pk, drawn, links, external
|
||||||
graphs = []
|
|
||||||
sites = OrderedDict()
|
|
||||||
for pk in drawn:
|
|
||||||
sites.setdefault(by_pk[pk].site, []).append(pk)
|
|
||||||
for site, pks in sites.items():
|
|
||||||
nodes = OrderedDict()
|
|
||||||
for pk in pks:
|
|
||||||
d = by_pk[pk]
|
|
||||||
kind, icon = drawn[pk]
|
|
||||||
nodes[f"d{pk}"] = Node(f"d{pk}", d.name or str(d), kind, icon, _device_sub(d, opts))
|
|
||||||
edges = []
|
|
||||||
for (a, b), labels in links.items():
|
|
||||||
in_a, in_b = a in nodes, b in nodes
|
|
||||||
if not (in_a or in_b):
|
|
||||||
continue
|
|
||||||
for nid, inside in ((a, in_a), (b, in_b)):
|
|
||||||
if not inside:
|
|
||||||
other = external.get(nid)
|
|
||||||
if other is None: # Gerät eines anderen Standorts im selben Scope
|
|
||||||
d = by_pk[int(nid[1:])]
|
|
||||||
kind, icon = drawn[d.pk]
|
|
||||||
other = Node(nid, d.name or str(d), kind, icon, [str(d.site)], external=True)
|
|
||||||
nodes[nid] = Node(**{**other.__dict__})
|
|
||||||
edges.append(Edge(a, b, labels))
|
|
||||||
if not opts.get("topo_isolated"):
|
|
||||||
linked = {n for e in edges for n in (e.a, e.b)}
|
|
||||||
nodes = OrderedDict((k, v) for k, v in nodes.items() if k in linked)
|
|
||||||
if nodes:
|
|
||||||
_layout(nodes, edges)
|
|
||||||
graphs.append(Graph(f"Standort {site}", nodes, edges))
|
|
||||||
return graphs
|
|
||||||
|
|
||||||
|
|
||||||
# --- Layout -------------------------------------------------------------------
|
# --- Layout -------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netbox-customer-export"
|
name = "netbox-customer-export"
|
||||||
version = "0.7.0"
|
version = "0.8.0"
|
||||||
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
|
description = "NetBox-Plugin: Kundendokumentation (PDF/Word) für Standorte, Lokationen, Mandanten und Mandantengruppen"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.10"
|
requires-python = ">=3.10"
|
||||||
|
|||||||
Reference in New Issue
Block a user