Add port labels, modernize patch panels, add a real PNG preview

- export_topology.py: edges now carry the exact port name on both ends
  (pulled directly from Cable/CableTermination, not inferred from the
  trace chain), so the diagram can label each connector.
- generate_drawio.py: patch panels are now a small rack-unit shape
  (header strip + port ticks, parented to the panel body so they move
  together) instead of a plain box; connectors show 'porta <-> portb'
  and no longer carry a directional arrowhead (these are physical
  cables, not directed links); switches keep the network-switch icon.
- render_png.js + render_host.html: headless Puppeteer driver for
  embed.diagrams.net's postMessage export protocol -- an actual
  render of the .drawio file, not an approximation. Field-Switch-
  Topology.png is the committed output, for anyone who wants a look
  without opening draw.io.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM3nivbufxEWiKKmatEtEe
This commit is contained in:
dcstephenson
2026-09-15 13:03:18 -05:00
parent d275b7e821
commit b6151b7de5
8 changed files with 185 additions and 48 deletions
+22 -10
View File
@@ -10,10 +10,12 @@ artifact, regenerated from NetBox and committed so history shows what the
| file | what it is | | file | what it is |
| --- | --- | | --- | --- |
| `export_topology.py` | Run inside the NetBox Django shell. Walks every field/distribution switch's cable path (through patch panels and splice modules) and dumps the resulting device graph as JSON. Read-only. | | `export_topology.py` | Run inside the NetBox Django shell. Walks every field/distribution switch's cable path (through patch panels and splice modules), then pulls every real `Cable` among the devices it found so each edge carries the exact port name on both ends. Read-only. |
| `topo_full.json` | Latest exported snapshot: devices (id, name, role, site, status) + edges (physical links, one per cable). | | `topo_full.json` | Latest exported snapshot: devices (id, name, role, site, status) + edges (`{a, a_port, b, b_port}`, one per cable). |
| `generate_drawio.py` | Pure-Python renderer: `topo_full.json` &rarr; `Field-Switch-Topology.drawio`. No NetBox access needed. | | `generate_drawio.py` | Pure-Python renderer: `topo_full.json` &rarr; `Field-Switch-Topology.drawio`. No NetBox access needed. |
| `Field-Switch-Topology.drawio` | The diagram. Open at [app.diagrams.net](https://app.diagrams.net) (or the desktop app, or the VS Code draw.io extension) -- free, no license needed. | | `Field-Switch-Topology.drawio` | The diagram. Open at [app.diagrams.net](https://app.diagrams.net) (or the desktop app, or the VS Code draw.io extension) -- free, no license needed. |
| `render_png.js` + `render_host.html` | Renders the `.drawio` through a real draw.io instance (embed.diagrams.net, driven headlessly with Puppeteer) to produce `Field-Switch-Topology.png` -- an actual render, not an approximation. Needs `npm install puppeteer` and network access. |
| `Field-Switch-Topology.png` | Static preview of the diagram, for anyone who wants a quick look without opening draw.io (viewing it in the Gitea file browser, a PR diff, etc.). |
Two distribution switches (`fche-sw17a`, `dmo-lob-sw35a`) anchor two Two distribution switches (`fche-sw17a`, `dmo-lob-sw35a`) anchor two
fabrics; everything downstream is field switches daisy-chained through fabrics; everything downstream is field switches daisy-chained through
@@ -22,12 +24,18 @@ both fabrics for a documented reason (spare/inventory, decommissioning, or
planned/not-yet-installed) -- those are called out in their own box on the planned/not-yet-installed) -- those are called out in their own box on the
diagram rather than silently dropped. diagram rather than silently dropped.
Every connector is labeled with the port at each end (e.g. `9 ↔ 1`), and
switches/patch panels look the part: switches get draw.io's built-in network
switch icon, patch panels get a small rack-unit look (header strip + port
ticks) rather than a plain box.
Connectors in the `.drawio` file are glued by shape id (draw.io's native Connectors in the `.drawio` file are glued by shape id (draw.io's native
`source`/`target` reference on each edge) -- drag a switch or panel in the `source`/`target` reference on each edge) -- drag a switch or panel in the
app and its links follow. That's different from this project's earlier app and its links follow, port-tick decorations included since they're
Visio (`.vsdx`) attempt, where glue had to be hand-written as cell formulas parented to their panel's body cell. That's different from this project's
because the format has no simpler mechanism; draw.io does this natively, earlier Visio (`.vsdx`) attempt, where glue had to be hand-written as cell
which is a big part of why this repo uses it instead. formulas because the format has no simpler mechanism; draw.io does this
natively, which is a big part of why this repo uses it instead.
## Regenerating ## Regenerating
@@ -38,12 +46,16 @@ docker exec netbox-netbox-1 python3 /opt/netbox/netbox/manage.py shell \
-c "$(cat topology/export_topology.py)" 2>/dev/null > topology/topo_full.json -c "$(cat topology/export_topology.py)" 2>/dev/null > topology/topo_full.json
python3 topology/generate_drawio.py topology/topo_full.json topology/Field-Switch-Topology.drawio python3 topology/generate_drawio.py topology/topo_full.json topology/Field-Switch-Topology.drawio
# optional -- refreshes the PNG preview to match; first run: npm install puppeteer
node topology/render_png.js topology/Field-Switch-Topology.drawio topology/Field-Switch-Topology.png
``` ```
Commit both files together. The diff on `topo_full.json` is the real Commit all three (`topo_full.json`, `.drawio`, `.png`) together. The diff on
changelog -- it shows exactly which device or cable changed between two `topo_full.json` is the real changelog -- it shows exactly which device or
snapshots; the `.drawio` diff is mostly noise (coordinates) but is kept in cable changed between two snapshots; the `.drawio`/`.png` diffs are mostly
sync so the rendered diagram never drifts from what NetBox actually says. noise (coordinates, pixels) but are kept in sync so the rendered diagram
never drifts from what NetBox actually says.
## Gotchas learned the hard way ## Gotchas learned the hard way
File diff suppressed because one or more lines are too long
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

+34 -10
View File
@@ -12,8 +12,18 @@ The script only reads data -- it never modifies NetBox. If a switch's
connected_endpoints comes back empty when it clearly shouldn't, that is a connected_endpoints comes back empty when it clearly shouldn't, that is a
NetBox cable-path bug, not a bug in this script -- see the "gotchas" section NetBox cable-path bug, not a bug in this script -- see the "gotchas" section
of the repo README before assuming the physical link doesn't exist. of the repo README before assuming the physical link doesn't exist.
Two passes:
1. Walk every switch's cable path (Interface.trace()) to find every
device -- patch panel, splice module, other switch -- it physically
touches. This is just to build the device set; NetBox's cached path
is what can go stale (see README), which is why pass 2 doesn't rely
on it for the edges themselves.
2. Pull every real Cable among those devices directly, so each edge
carries the exact port name on both ends (what generate_drawio.py
labels connectors with) instead of just "these two are connected."
""" """
from dcim.models import Device, Interface from dcim.models import Device, Interface, Cable
import json import json
switch_ids = set( switch_ids = set(
@@ -27,24 +37,38 @@ ifaces = Interface.objects.filter(
).select_related("device") ).select_related("device")
touched_devices = set(switch_ids) touched_devices = set(switch_ids)
edges = set() # (device_id_a, device_id_b), deduplicated, any two adjacent devices on a physical chain
for i in ifaces: for i in ifaces:
path = i._path path = i._path
if not path or not path.is_active: if not path or not path.is_active:
continue continue
chain = []
for hop in i.trace(): for hop in i.trace():
for item in hop: for item in hop:
for t in item: for t in item:
dev = getattr(t, "device", None) dev = getattr(t, "device", None)
if dev: if dev:
if not chain or chain[-1] != dev.id:
chain.append(dev.id)
touched_devices.add(dev.id) touched_devices.add(dev.id)
for a, b in zip(chain, chain[1:]):
if a != b: cables = Cable.objects.filter(
edges.add(tuple(sorted((a, b)))) terminations__termination_type__model__in=["interface", "frontport", "rearport"],
).distinct()
edges = {}
for c in cables:
terms = list(c.terminations.all())
a_side = [t for t in terms if t.cable_end == "A"]
b_side = [t for t in terms if t.cable_end == "B"]
for ta in a_side:
for tb in b_side:
da = getattr(ta.termination, "device", None)
db = getattr(tb.termination, "device", None)
if not da or not db:
continue
if da.id in touched_devices and db.id in touched_devices and da.id != db.id:
key = tuple(sorted((da.id, db.id)))
edges[key] = {
"a": da.id, "a_port": ta.termination.name,
"b": db.id, "b_port": tb.termination.name,
}
devs = Device.objects.filter(id__in=touched_devices).select_related( devs = Device.objects.filter(id__in=touched_devices).select_related(
"site", "role", "device_type" "site", "role", "device_type"
@@ -63,6 +87,6 @@ out = {
} }
for d in devs for d in devs
], ],
"edges": [[a, b] for a, b in sorted(edges)], "edges": list(edges.values()),
} }
print(json.dumps(out)) print(json.dumps(out))
+47 -26
View File
@@ -5,7 +5,8 @@ Render topo_full.json (produced by export_topology.py) as a draw.io
Edges reference shapes by id (source=/target=), which is how draw.io's Edges reference shapes by id (source=/target=), which is how draw.io's
native glue works -- no hand-rolled formulas needed: dragging a shape in native glue works -- no hand-rolled formulas needed: dragging a shape in
the app keeps every attached line attached. the app keeps every attached line attached. Patch panel port ticks/accent
strips are parented to the panel's body cell, so they move with it too.
Usage: Usage:
python3 generate_drawio.py [topo_full.json] [Field-Switch-Topology.drawio] python3 generate_drawio.py [topo_full.json] [Field-Switch-Topology.drawio]
@@ -19,13 +20,13 @@ OUT_PATH = sys.argv[2] if len(sys.argv) > 2 else "Field-Switch-Topology.drawio"
data = json.load(open(IN_PATH)) data = json.load(open(IN_PATH))
devs = {d["id"]: d for d in data["devices"]} devs = {d["id"]: d for d in data["devices"]}
edges = data["edges"] edges = data["edges"] # [{a, a_port, b, b_port}, ...]
# ---------------- components ---------------- # ---------------- components ----------------
adj = {d: [] for d in devs} adj = {d: [] for d in devs}
for a, b in edges: for e in edges:
adj[a].append(b) adj[e["a"]].append(e["b"])
adj[b].append(a) adj[e["b"]].append(e["a"])
parent = {d: d for d in devs} parent = {d: d for d in devs}
def find(x): def find(x):
@@ -37,8 +38,8 @@ def union(a, b):
ra, rb = find(a), find(b) ra, rb = find(a), find(b)
if ra != rb: if ra != rb:
parent[ra] = rb parent[ra] = rb
for a, b in edges: for e in edges:
union(a, b) union(e["a"], e["b"])
from collections import defaultdict from collections import defaultdict
comps = defaultdict(list) comps = defaultdict(list)
for d in devs: for d in devs:
@@ -74,9 +75,9 @@ depth_dmo, yslot_dmo = layout_tree(7, dmo_comp)
# ---------------- geometry constants (px) ---------------- # ---------------- geometry constants (px) ----------------
SWITCH_ICON = 46 # icon box; label renders below it SWITCH_ICON = 46 # icon box; label renders below it
SWITCH_LABEL_H = 30 SWITCH_LABEL_H = 30
PANEL_W, PANEL_H = 130, 34 PANEL_W, PANEL_H = 132, 42
COL_W = 190 COL_W = 200
ROW_H = 82 ROW_H = 86
PAD = 40 PAD = 40
LABEL_STRIP = 34 LABEL_STRIP = 34
SECTION_GAP = 70 SECTION_GAP = 70
@@ -137,7 +138,7 @@ STYLE = {
"spare": dict(fill="#F1F1F2", stroke="#9AA0AA", font="#5B6478", dashed=1, bold=0), "spare": dict(fill="#F1F1F2", stroke="#9AA0AA", font="#5B6478", dashed=1, bold=0),
"decom": dict(fill="#FBE6E2", stroke="#C04B34", font="#8A3423", dashed=1, bold=0), "decom": dict(fill="#FBE6E2", stroke="#C04B34", font="#8A3423", dashed=1, bold=0),
"planned": dict(fill="#FDF1CF", stroke="#B8862C", font="#7A5C00", dashed=1, bold=0), "planned": dict(fill="#FDF1CF", stroke="#B8862C", font="#7A5C00", dashed=1, bold=0),
"panel": dict(fill="#EEF0F2", stroke="#8C97A6", font="#495566", dashed=0, bold=0), "panel": dict(fill="#3A4457", stroke="#242C38", font="#FFFFFF", dashed=0, bold=0),
} }
def style_for(dev): def style_for(dev):
@@ -166,9 +167,9 @@ def vertex(cell_id, value, x, y, w, h, style, parent="1"):
f'</mxCell>' f'</mxCell>'
) )
def edge(cell_id, source, target, style): def edge(cell_id, source, target, style, value=""):
add_cell( add_cell(
f'<mxCell id="{cell_id}" style="{style}" edge="1" parent="1" ' f'<mxCell id="{cell_id}" value="{escape(value)}" style="{style}" edge="1" parent="1" '
f'source="{source}" target="{target}"><mxGeometry relative="1" as="geometry"/></mxCell>' f'source="{source}" target="{target}"><mxGeometry relative="1" as="geometry"/></mxCell>'
) )
@@ -198,10 +199,29 @@ add_cell(
f'<mxGeometry x="{MARGIN}" y="10" width="600" height="30" as="geometry"/></mxCell>' f'<mxGeometry x="{MARGIN}" y="10" width="600" height="30" as="geometry"/></mxCell>'
) )
# node shapes -- switches get a real network-switch icon (label rendered below it by # ---------------- node shapes ----------------
# draw.io's own verticalLabelPosition, not baked into the geometry), patch panels get # Switches: a real network-switch icon, hostname caption below it.
# a plain labeled box. # Patch panels: a small rack-style unit -- dark body, a lighter header strip,
node_style = {} # and a row of port ticks along the bottom -- built from cells parented to
# the panel's own body cell, so the whole unit moves together when dragged.
def panel_shape(did, x, y, w, h, name, st):
body_id = f"n{did}"
vertex(body_id, name, x, y, w, h,
f'rounded=1;arcSize=10;whiteSpace=wrap;html=1;fillColor={st["fill"]};strokeColor={st["stroke"]};'
f'fontColor={st["font"]};fontSize=9;verticalAlign=bottom;spacingBottom=5;')
# header strip (lighter steel), relative to the body
vertex(f"{body_id}-strip", "", 5, 4, w - 10, 5,
'rounded=1;arcSize=40;fillColor=#8C97A6;strokeColor=none;', parent=body_id)
# port ticks along the top-middle, evenly spaced
n_ticks = 6
tick_w, tick_h = 10, 7
usable = w - 16
gap = usable / n_ticks
for k in range(n_ticks):
tx = 8 + gap * k + (gap - tick_w) / 2
vertex(f"{body_id}-t{k}", "", tx, 15, tick_w, tick_h,
'rounded=0;fillColor=#2A313D;strokeColor=#5B6577;strokeWidth=0.75;', parent=body_id)
for did, dev in devs.items(): for did, dev in devs.items():
if did not in pos: if did not in pos:
continue continue
@@ -209,11 +229,7 @@ for did, dev in devs.items():
st = style_for(dev) st = style_for(dev)
dashed = "dashed=1;dashPattern=4 3;" if st["dashed"] else "" dashed = "dashed=1;dashPattern=4 3;" if st["dashed"] else ""
if dev["role"] == "patch-panel": if dev["role"] == "patch-panel":
style = ( panel_shape(did, x, y, w, h, dev["name"], st)
f'rounded=0;whiteSpace=wrap;html=1;fillColor={st["fill"]};strokeColor={st["stroke"]};'
f'fontColor={st["font"]};fontSize=9;{dashed}'
)
vertex(f"n{did}", dev["name"], x, y, w, h, style)
else: else:
style = ( style = (
'shape=mxgraph.networks.switch;html=1;verticalLabelPosition=bottom;verticalAlign=top;' 'shape=mxgraph.networks.switch;html=1;verticalLabelPosition=bottom;verticalAlign=top;'
@@ -222,18 +238,23 @@ for did, dev in devs.items():
) )
vertex(f"n{did}", dev["name"], x, y, SWITCH_ICON, SWITCH_ICON, style) vertex(f"n{did}", dev["name"], x, y, SWITCH_ICON, SWITCH_ICON, style)
# connectors -- glued natively via source/target, orthogonal routing # ---------------- connectors, labeled with the port at each end ----------------
for idx, (a, b) in enumerate(edges): for idx, e in enumerate(edges):
a, b = e["a"], e["b"]
if a in pos and b in pos: if a in pos and b in pos:
label = f'{e["a_port"]} ↔ {e["b_port"]}'
edge(f"e{idx}", f"n{a}", f"n{b}", edge(f"e{idx}", f"n{a}", f"n{b}",
"edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=#9AA6B8;strokeWidth=1.2;" "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=#9AA6B8;strokeWidth=1.2;"
"jettySize=auto;orthogonalLoop=1;") "startArrow=none;endArrow=none;"
"jettySize=auto;orthogonalLoop=1;fontSize=7.5;fontColor=#7C8AA6;"
"labelBackgroundColor=#F7F8FBcc;align=center;verticalAlign=middle;",
value=label)
# legend # legend
legend_items = [ legend_items = [
("#2F5FAD", "#173D6E", "Distribution switch"), ("#2F5FAD", "#173D6E", "Distribution switch"),
("#F3F5FA", "#7C8AA6", "Field switch"), ("#F3F5FA", "#7C8AA6", "Field switch"),
("#EEF0F2", "#8C97A6", "Patch panel / splice module"), ("#3A4457", "#242C38", "Patch panel / splice module"),
("#F1F1F2", "#9AA0AA", "Spare / decommissioned / planned"), ("#F1F1F2", "#9AA0AA", "Spare / decommissioned / planned"),
] ]
lx = MARGIN lx = MARGIN
+24
View File
@@ -0,0 +1,24 @@
<!doctype html>
<html><body style="margin:0">
<iframe id="f" src="https://embed.diagrams.net/?proto=json&spin=1&embed=1&libraries=1&noSaveBtn=1"
style="width:1600px;height:1200px;border:0"></iframe>
<script>
window.__RESULT__ = null;
window.__ERR__ = null;
const iframe = document.getElementById('f');
window.addEventListener('message', (evt) => {
let msg;
try { msg = JSON.parse(evt.data); } catch (e) { return; }
console.log('HOST EVENT:', msg.event);
if (msg.event === 'init') {
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: window.__XML__, autosave: 0 }), '*');
} else if (msg.event === 'load') {
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xmlpng', xml: window.__XML__, border: 24, scale: 2 }), '*');
} else if (msg.event === 'export') {
window.__RESULT__ = msg.data;
} else if (msg.event === 'error') {
window.__ERR__ = JSON.stringify(msg);
}
});
</script>
</body></html>
+56
View File
@@ -0,0 +1,56 @@
#!/usr/bin/env node
/**
* Render a .drawio file to PNG via a real draw.io instance (embed.diagrams.net),
* driven headlessly with Puppeteer -- this is an actual render, not an
* approximation, so switch icons, patch panel styling, and port labels all
* come out exactly as they'd look opening the file in the app.
*
* Needs `npm install puppeteer` first (downloads its own Chromium; not the
* system browser). Needs network access to embed.diagrams.net.
*
* Usage:
* node render_png.js Field-Switch-Topology.drawio Field-Switch-Topology.png
*/
const puppeteer = require('puppeteer');
const fs = require('fs');
const path = require('path');
const xmlPath = process.argv[2];
const outPath = process.argv[3];
if (!xmlPath || !outPath) {
console.error('usage: node render_png.js <in.drawio> <out.png>');
process.exit(1);
}
const xml = fs.readFileSync(xmlPath, 'utf8');
(async () => {
const browser = await puppeteer.launch({
headless: 'new',
args: ['--no-sandbox', '--disable-setuid-sandbox'],
});
const page = await browser.newPage();
await page.setViewport({ width: 1700, height: 1300 });
page.on('pageerror', (err) => console.error('PAGE ERROR:', err));
await page.evaluateOnNewDocument((x) => { window.__XML__ = x; }, xml);
await page.goto('file://' + path.join(__dirname, 'render_host.html'), {
waitUntil: 'load',
timeout: 30000,
});
let dataUrl = null;
const deadline = Date.now() + 60000;
while (Date.now() < deadline) {
dataUrl = await page.evaluate(() => window.__RESULT__);
const err = await page.evaluate(() => window.__ERR__);
if (err) throw new Error('draw.io export error: ' + err);
if (dataUrl) break;
await new Promise((r) => setTimeout(r, 500));
}
if (!dataUrl) throw new Error('timed out waiting for draw.io to export (network to embed.diagrams.net?)');
const base64 = dataUrl.split(',')[1];
fs.writeFileSync(outPath, Buffer.from(base64, 'base64'));
console.log('wrote', outPath, fs.statSync(outPath).size, 'bytes');
await browser.close();
})().catch((e) => { console.error(e); process.exit(1); });
File diff suppressed because one or more lines are too long