#!/usr/bin/env python3
"""
Render topo_full.json (produced by export_topology.py) as a draw.io
(mxGraph) diagram: Field-Switch-Topology.drawio.
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
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:
python3 generate_drawio.py [topo_full.json] [Field-Switch-Topology.drawio]
"""
import json, sys, uuid
from xml.sax.saxutils import escape
import xml.dom.minidom as minidom
IN_PATH = sys.argv[1] if len(sys.argv) > 1 else "topo_full.json"
OUT_PATH = sys.argv[2] if len(sys.argv) > 2 else "Field-Switch-Topology.drawio"
data = json.load(open(IN_PATH))
devs = {d["id"]: d for d in data["devices"]}
edges = data["edges"] # [{a, a_port, a_vlan, b, b_port, b_vlan}, ...]
vlans = data.get("vlans", []) # [{vid, name, description, prefixes, site}, ...]
VLAN_PALETTE = ["#D6603F", "#3C9D6B", "#E8A33D", "#5B7FD9", "#9A6BC9", "#3FAAB0", "#C2547E", "#7A8C3F"]
def vlan_color(vid):
return VLAN_PALETTE[hash(vid) % len(VLAN_PALETTE)]
def vlan_tag(pv):
"""Short connector-label suffix for a port's VLAN config, e.g. 'V10' or
'V10,20T'. Empty string when the port has no 802.1Q config -- which, as
of this export, is every port (see export_topology.py docstring)."""
if not pv:
return ""
parts = []
if pv.get("untagged"):
parts.append(str(pv["untagged"]["vid"]))
tagged = pv.get("tagged") or []
if tagged:
parts.append(",".join(str(v["vid"]) for v in tagged) + "T")
return f' · V{"+".join(parts)}' if parts else ""
# ---------------- components ----------------
adj = {d: [] for d in devs}
for e in edges:
adj[e["a"]].append(e["b"])
adj[e["b"]].append(e["a"])
parent = {d: d for d in devs}
def find(x):
while parent[x] != x:
parent[x] = parent[parent[x]]
x = parent[x]
return x
def union(a, b):
ra, rb = find(a), find(b)
if ra != rb:
parent[ra] = rb
for e in edges:
union(e["a"], e["b"])
from collections import defaultdict
comps = defaultdict(list)
for d in devs:
comps[find(d)].append(d)
comps = sorted(comps.values(), key=len, reverse=True)
main_comp = comps[0] # fche fabric, rooted at id 1
dmo_comp = comps[1] # dmo-lob fabric, rooted at id 7
iso = [c[0] for c in comps[2:]]
# ---------------- tidy tree layout ----------------
def layout_tree(root, node_ids):
node_set = set(node_ids)
depth, yslot, visited = {}, {}, set()
leaf_counter = [0]
def dfs(node, par, d):
visited.add(node)
depth[node] = d
# dedupe: two devices can be joined by more than one real cable
# (redundant links), which must not create more than one tree child
seen_here = set()
children = []
for n in sorted(adj[node]):
if n in node_set and n != par and n not in visited and n not in seen_here:
seen_here.add(n)
children.append(n)
if not children:
y = leaf_counter[0]; leaf_counter[0] += 1
yslot[node] = y
return y
ys = [dfs(c, node, d + 1) for c in children]
y = sum(ys) / len(ys)
yslot[node] = y
return y
dfs(root, None, 0)
return depth, yslot
depth_main, yslot_main = layout_tree(1, main_comp)
depth_dmo, yslot_dmo = layout_tree(7, dmo_comp)
# ---------------- geometry constants (px) ----------------
SWITCH_ICON = 46 # icon box; label renders below it
SWITCH_LABEL_H = 30
PANEL_W, PANEL_H = 132, 42
COL_W = 200
ROW_H = 86
PAD = 40
LABEL_STRIP = 34
SECTION_GAP = 70
MARGIN = 40
def footprint(dev):
if dev["role"] == "patch-panel":
return PANEL_W, PANEL_H
return SWITCH_ICON, SWITCH_ICON + SWITCH_LABEL_H
pos = {} # device_id -> (x, y) TOP-LEFT of the node's own bounding box (not center)
def place_section(depth_map, yslot_map, y_offset):
ys = list(yslot_map.values())
min_y = min(ys)
max_x = max(depth_map.values())
for node, d in depth_map.items():
y = yslot_map[node]
w, h = footprint(devs[node])
cx = MARGIN + PAD + d * COL_W + SWITCH_ICON / 2
cy = y_offset + PAD + LABEL_STRIP + (y - min_y) * ROW_H + SWITCH_ICON / 2
pos[node] = (cx - w / 2, cy - h / 2, w, h)
width = MARGIN * 2 + PAD * 2 + max_x * COL_W + SWITCH_ICON
height = PAD * 2 + LABEL_STRIP + (max(ys) - min_y) * ROW_H + SWITCH_ICON + SWITCH_LABEL_H
return width, height
TITLE_H = 60
LEGEND_ROW_H = 34
VLAN_ROW_H = 26
VLAN_HEADER_H = 30
VLAN_TABLE_W = 900
vlan_rows = max(1, len(vlans)) # the placeholder counts as one row
VLAN_TABLE_H = VLAN_HEADER_H + vlan_rows * VLAN_ROW_H + 16
cursor_y = MARGIN + TITLE_H + LEGEND_ROW_H + VLAN_TABLE_H + 20
w_main, h_main = place_section(depth_main, yslot_main, cursor_y)
main_top = cursor_y
cursor_y += h_main + SECTION_GAP
w_dmo, h_dmo = place_section(depth_dmo, yslot_dmo, cursor_y)
dmo_top = cursor_y
cursor_y += h_dmo + SECTION_GAP
ISO_COLS = 3
iso_top = cursor_y
iso_sorted = sorted(iso, key=lambda d: devs[d]["name"])
for i, node in enumerate(iso_sorted):
r, c = divmod(i, ISO_COLS)
w, h = footprint(devs[node])
cx = MARGIN + PAD + c * COL_W + SWITCH_ICON / 2
cy = iso_top + PAD + LABEL_STRIP + r * ROW_H + SWITCH_ICON / 2
pos[node] = (cx - w / 2, cy - h / 2, w, h)
iso_rows = (len(iso_sorted) + ISO_COLS - 1) // ISO_COLS
w_iso = MARGIN * 2 + PAD * 2 + (ISO_COLS - 1) * COL_W + SWITCH_ICON
h_iso = PAD * 2 + LABEL_STRIP + (iso_rows - 1) * ROW_H + SWITCH_ICON + SWITCH_LABEL_H
PAGE_W = max(w_main, w_dmo, w_iso, VLAN_TABLE_W + MARGIN * 2)
PAGE_H = cursor_y + h_iso + MARGIN
# ---------------- style palette ----------------
STYLE = {
"dist": dict(fill="#2F5FAD", stroke="#173D6E", font="#FFFFFF", dashed=0, bold=1),
"field": dict(fill="#F3F5FA", stroke="#7C8AA6", font="#1A2233", dashed=0, 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),
"planned": dict(fill="#FDF1CF", stroke="#B8862C", font="#7A5C00", dashed=1, bold=0),
"panel": dict(fill="#3A4457", stroke="#242C38", font="#FFFFFF", dashed=0, bold=0),
}
def style_for(dev):
if dev["role"] == "patch-panel":
return STYLE["panel"]
if dev["role"] == "dist-switch":
return STYLE["dist"]
if dev["status"] == "inventory":
return STYLE["spare"]
if dev["status"] == "decommissioning":
return STYLE["decom"]
if dev["status"] == "planned":
return STYLE["planned"]
return STYLE["field"]
# ---------------- mxGraph XML ----------------
cells = []
def add_cell(xml):
cells.append(xml)
def vertex(cell_id, value, x, y, w, h, style, parent="1"):
add_cell(
f''
f''
f''
)
def edge(cell_id, source, target, style, value=""):
add_cell(
f''
)
def container(cell_id, x, y, w, h, label):
add_cell(
f''
f''
)
add_cell(
f''
)
container("bg-main", MARGIN, main_top, w_main - MARGIN * 2, h_main,
f"FCHE Cable Headend — distribution fabric ({len(main_comp)} devices)")
container("bg-dmo", MARGIN, dmo_top, w_dmo - MARGIN * 2, h_dmo,
f"DMO-LOB — distribution fabric ({len(dmo_comp)} devices)")
container("bg-iso", MARGIN, iso_top, w_iso - MARGIN * 2, h_iso,
"Not in a resolved cable path")
# title
add_cell(
f''
f''
)
# ---------------- node shapes ----------------
# Switches: a real network-switch icon, hostname caption below it.
# Patch panels: a small rack-style unit -- dark body, a lighter header strip,
# 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():
if did not in pos:
continue
x, y, w, h = pos[did]
st = style_for(dev)
dashed = "dashed=1;dashPattern=4 3;" if st["dashed"] else ""
if dev["role"] == "patch-panel":
panel_shape(did, x, y, w, h, dev["name"], st)
else:
style = (
'shape=mxgraph.networks.switch;html=1;verticalLabelPosition=bottom;verticalAlign=top;'
f'labelBackgroundColor=none;fontSize=10;fontColor={st["font"] if st["font"] != "#FFFFFF" else "#1A2233"};'
f'fillColor={st["fill"]};strokeColor={st["stroke"]};fontStyle={st["bold"]};{dashed}'
)
vertex(f"n{did}", dev["name"], x, y, SWITCH_ICON, SWITCH_ICON, style)
# ---------------- connectors, labeled with the port at each end ----------------
for idx, e in enumerate(edges):
a, b = e["a"], e["b"]
if a in pos and b in pos:
label = f'{e["a_port"]}{vlan_tag(e.get("a_vlan"))} ↔ {e["b_port"]}{vlan_tag(e.get("b_vlan"))}'
edge(f"e{idx}", f"n{a}", f"n{b}",
"edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=#9AA6B8;strokeWidth=1.2;"
"startArrow=none;endArrow=none;"
"jettySize=auto;orthogonalLoop=1;fontSize=13;fontStyle=1;fontColor=#3A4457;"
"labelBackgroundColor=#F7F8FBee;align=center;verticalAlign=middle;",
value=label)
# legend
legend_items = [
("#2F5FAD", "#173D6E", "Distribution switch"),
("#F3F5FA", "#7C8AA6", "Field switch"),
("#3A4457", "#242C38", "Patch panel / splice module"),
("#F1F1F2", "#9AA0AA", "Spare / decommissioned / planned"),
]
lx = MARGIN
ly = 44
for i, (fill, stroke, label) in enumerate(legend_items):
add_cell(
f''
)
add_cell(
f''
f''
)
lx += 260
# ---------------- VLAN legend / mapping table ----------------
# Structure is always rendered; content depends on whether NetBox actually
# has VLANs defined yet (see export_topology.py). Nothing here is invented --
# an empty table renders as an explicit "not configured" placeholder, not as
# blank space or made-up VLAN numbers.
vt_x, vt_y = MARGIN, MARGIN + TITLE_H + LEGEND_ROW_H
vt_cols = [("VLAN", 60), ("Name", 130), ("Description", 300), ("Subnet", 160), ("Site", 170)]
add_cell(
f''
)
add_cell(
f''
f''
)
cx = vt_x + 90
for label, w in vt_cols:
add_cell(
f''
f''
)
cx += w
row_y = vt_y + VLAN_HEADER_H
if not vlans:
add_cell(
f''
f''
)
add_cell(
f''
)
else:
for v in vlans:
add_cell(
f''
f''
)
row_vals = [
str(v["vid"]), v["name"], v.get("description") or "—",
", ".join(v.get("prefixes") or []) or "—", v.get("site") or "—",
]
cx = vt_x + 90
for (label, w), val in zip(vt_cols, row_vals):
add_cell(
f''
f''
)
cx += w
row_y += VLAN_ROW_H
xml = (
''
f''
f''
''
+ "".join(cells) +
''
)
minidom.parseString(xml) # fail fast on malformed XML
open(OUT_PATH, "w").write(xml)
print(f"wrote {OUT_PATH}: {len(cells)} cells, page {PAGE_W:.0f}x{PAGE_H:.0f}px")