Add VLAN table + per-connector VLAN tags (structure only -- no VLANs in NetBox yet)
Inspired by a reference diagram the user liked (VLAN legend/mapping table, per-port VLAN info) but explicitly without the literal switch CLI config dumps that diagram also had. - export_topology.py: pulls ipam.VLAN (with prefixes) plus, for every edge whose endpoint is a real Interface, that port's mode/untagged/ tagged VLANs. NetBox has zero VLANs defined right now, so this comes back empty/null throughout -- the plumbing is real, the data just isn't there yet. - generate_drawio.py: renders a VLAN table (VLAN/Name/Description/ Subnet/Site) at the top of the diagram. With no VLANs it shows an explicit dashed 'not configured yet' placeholder row instead of either fabricating VLAN numbers or silently omitting the table. Connector labels get a 'V<id>' / 'V<id>,<id>T' suffix per side whenever that port actually has VLAN config -- currently a no-op, activates automatically the moment VLANs exist in NetBox. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NM3nivbufxEWiKKmatEtEe
This commit is contained in:
@@ -20,7 +20,26 @@ 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, b, b_port}, ...]
|
||||
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}
|
||||
@@ -105,7 +124,13 @@ def place_section(depth_map, yslot_map, y_offset):
|
||||
return width, height
|
||||
|
||||
TITLE_H = 60
|
||||
cursor_y = MARGIN + TITLE_H
|
||||
LEGEND_ROW_H = 34
|
||||
VLAN_ROW_H = 20
|
||||
VLAN_HEADER_H = 24
|
||||
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
|
||||
@@ -128,7 +153,7 @@ 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)
|
||||
PAGE_W = max(w_main, w_dmo, w_iso, VLAN_TABLE_W + MARGIN * 2)
|
||||
PAGE_H = cursor_y + h_iso + MARGIN
|
||||
|
||||
# ---------------- style palette ----------------
|
||||
@@ -242,7 +267,7 @@ for did, dev in devs.items():
|
||||
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"]} ↔ {e["b_port"]}'
|
||||
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;"
|
||||
@@ -271,6 +296,68 @@ for i, (fill, stroke, label) in enumerate(legend_items):
|
||||
)
|
||||
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'<mxCell id="vt-bg" value="" style="rounded=0;fillColor=#FFFFFF;strokeColor=#DDE1EA;" '
|
||||
f'vertex="1" parent="1"><mxGeometry x="{vt_x}" y="{vt_y}" width="{VLAN_TABLE_W}" '
|
||||
f'height="{VLAN_TABLE_H}" as="geometry"/></mxCell>'
|
||||
)
|
||||
add_cell(
|
||||
f'<mxCell id="vt-title" value="VLANs" style="text;html=1;align=left;verticalAlign=middle;'
|
||||
f'fontStyle=1;fontSize=11;fontColor=#33415C;" vertex="1" parent="1">'
|
||||
f'<mxGeometry x="{vt_x+10}" y="{vt_y}" width="200" height="{VLAN_HEADER_H}" as="geometry"/></mxCell>'
|
||||
)
|
||||
cx = vt_x + 90
|
||||
for label, w in vt_cols:
|
||||
add_cell(
|
||||
f'<mxCell id="vt-h-{label}" value="{escape(label)}" style="text;html=1;align=left;'
|
||||
f'verticalAlign=middle;fontStyle=1;fontSize=9;fontColor=#7C8AA6;" vertex="1" parent="1">'
|
||||
f'<mxGeometry x="{cx}" y="{vt_y}" width="{w}" height="{VLAN_HEADER_H}" as="geometry"/></mxCell>'
|
||||
)
|
||||
cx += w
|
||||
|
||||
row_y = vt_y + VLAN_HEADER_H
|
||||
if not vlans:
|
||||
add_cell(
|
||||
f'<mxCell id="vt-empty-sw" value="" style="rounded=0;fillColor=#F1F1F2;strokeColor=#9AA0AA;'
|
||||
f'dashed=1;dashPattern=4 3;" vertex="1" parent="1">'
|
||||
f'<mxGeometry x="{vt_x+10}" y="{row_y+2}" width="14" height="14" as="geometry"/></mxCell>'
|
||||
)
|
||||
add_cell(
|
||||
f'<mxCell id="vt-empty-txt" value="No VLANs defined in NetBox yet (ipam.VLAN) — this table '
|
||||
f'and each connector’s VLAN tag fill in automatically once VLANs and interface assignments '
|
||||
f'are added, next time this diagram is regenerated." style="text;html=1;align=left;'
|
||||
f'verticalAlign=middle;fontSize=9;fontColor=#7C8AA6;fontStyle=2;whiteSpace=wrap;" '
|
||||
f'vertex="1" parent="1"><mxGeometry x="{vt_x+90}" y="{row_y}" width="{VLAN_TABLE_W-100}" '
|
||||
f'height="{VLAN_ROW_H}" as="geometry"/></mxCell>'
|
||||
)
|
||||
else:
|
||||
for v in vlans:
|
||||
add_cell(
|
||||
f'<mxCell id="vt-sw-{v["vid"]}" value="" style="rounded=0;fillColor={vlan_color(v["vid"])};'
|
||||
f'strokeColor=none;" vertex="1" parent="1">'
|
||||
f'<mxGeometry x="{vt_x+10}" y="{row_y+3}" width="14" height="14" as="geometry"/></mxCell>'
|
||||
)
|
||||
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'<mxCell id="vt-{v["vid"]}-{label}" value="{escape(val)}" style="text;html=1;align=left;'
|
||||
f'verticalAlign=middle;fontSize=9;fontColor=#3A4457;" vertex="1" parent="1">'
|
||||
f'<mxGeometry x="{cx}" y="{row_y}" width="{w}" height="{VLAN_ROW_H}" as="geometry"/></mxCell>'
|
||||
)
|
||||
cx += w
|
||||
row_y += VLAN_ROW_H
|
||||
|
||||
xml = (
|
||||
'<mxfile host="app.diagrams.net" agent="netbox-topology-generator" version="24.0.0">'
|
||||
f'<diagram name="Topology" id="{uuid.uuid4()}">'
|
||||
|
||||
Reference in New Issue
Block a user