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:
dcstephenson
2026-09-15 13:24:34 -05:00
parent b6151b7de5
commit 715e37d048
6 changed files with 142 additions and 10 deletions
+13 -2
View File
@@ -10,8 +10,8 @@ artifact, regenerated from NetBox and committed so history shows what the
| 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), 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 (`{a, a_port, b, b_port}`, one per cable). |
| `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, plus that port's 802.1Q mode/untagged/tagged VLANs where the port is a real interface. Also exports every `ipam.VLAN`. Read-only. |
| `topo_full.json` | Latest exported snapshot: devices (id, name, role, site, status), edges (`{a, a_port, a_vlan, b, b_port, b_vlan}`, one per cable), and `vlans` (every VLAN in NetBox). |
| `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. |
| `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. |
@@ -29,6 +29,17 @@ 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.
A **VLAN table** sits at the top of the diagram (VLAN / Name / Description /
Subnet / Site). As of this writing NetBox has no VLANs defined here, so it
renders as an explicit "not configured yet" placeholder rather than being
silently omitted or -- worse -- filled in with invented VLAN numbers. Add
VLANs and interface tagged/untagged assignments in NetBox and regenerate
(no code changes needed): the table populates for real, and every connector
whose port has VLAN config picks up a short suffix like `9·V10 ↔ 1·V10`.
Deliberately left out: literal switch CLI config dumps -- those belong in
the switches' NetBox config-context/notes, not pasted into a topology
diagram where they'd immediately go stale.
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
app and its links follow, port-tick decorations included since they're
File diff suppressed because one or more lines are too long
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 MiB

After

Width:  |  Height:  |  Size: 1.6 MiB

+36 -2
View File
@@ -22,10 +22,38 @@ Two passes:
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."
Also exports every ipam.VLAN (with its prefixes) and, for each edge whose
endpoint is a real Interface (not a passive patch-panel port), that
interface's 802.1Q mode/untagged/tagged VLANs. As of this writing NetBox
has zero VLANs defined here, so `vlans` comes back `[]` and every edge's
`a_vlan`/`b_vlan` comes back `null` -- generate_drawio.py renders that as
an explicit "not configured yet" placeholder rather than silently
omitting the VLAN legend. The moment VLANs and interface assignments get
added in NetBox, rerunning this (no code changes needed) starts filling
the diagram in for real.
"""
from dcim.models import Device, Interface, Cable
from ipam.models import VLAN
import json
def vlan_info(vlan):
if not vlan:
return None
prefixes = [str(p.prefix) for p in vlan.prefixes.all()]
return {"vid": vlan.vid, "name": vlan.name, "prefix": prefixes[0] if prefixes else None}
def port_vlans(termination):
"""VLAN mode/tagging for a real Interface termination; None for a
passive patch-panel front/rear port, which has no 802.1Q concept."""
if not isinstance(termination, Interface):
return None
return {
"mode": termination.mode,
"untagged": vlan_info(termination.untagged_vlan),
"tagged": [vlan_info(v) for v in termination.tagged_vlans.all()],
}
switch_ids = set(
Device.objects.filter(
role__slug__in=["core-switch", "dist-switch", "field-switch"]
@@ -66,8 +94,8 @@ for c in cables:
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,
"a": da.id, "a_port": ta.termination.name, "a_vlan": port_vlans(ta.termination),
"b": db.id, "b_port": tb.termination.name, "b_vlan": port_vlans(tb.termination),
}
devs = Device.objects.filter(id__in=touched_devices).select_related(
@@ -88,5 +116,11 @@ out = {
for d in devs
],
"edges": list(edges.values()),
"vlans": [
{"vid": v.vid, "name": v.name, "description": v.description,
"prefixes": [str(p.prefix) for p in v.prefixes.all()],
"site": v.site.name if v.site else ""}
for v in VLAN.objects.all().select_related("site").order_by("vid")
],
}
print(json.dumps(out))
+91 -4
View File
@@ -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()}">'
File diff suppressed because one or more lines are too long