Files
Netbox/topology/export_topology.py
T
dcstephenson dd5ac52105 Fix dropped-cable bug, tag VLAN 214 on FS.com switch uplinks
Found while wiring up VLAN 214 on switch uplink ports: export_topology.py
deduped edges by DEVICE PAIR, so when a switch had two real cables to the
same neighboring device (very common here -- most FS.com field switches
run both SFP ports 9 and 10 into the same local patch panel before fanning
out from its rear side) only one of the two cables survived into the
graph. 43 real physical links across 30 device pairs were silently
missing from the diagram.

- export_topology.py: edges keyed per Cable (cable_id), never deduped by
  device pair. Edge count 80 -> 123.
- generate_drawio.py: the tree-layout DFS had a matching bug -- a node's
  neighbor list wasn't deduped before recursing, so a parallel edge to an
  already-visited node got walked a second time, found no unvisited
  children, and got counted as a phantom extra leaf. One phantom leaf per
  redundant cable was inflating page height for no reason (bogus page
  height >16000px, clipped by draw.io's render cap, before this fix).

NetBox changes (per user): all 42 FS.com IES3110-8TFP-R field switches'
SFP ports (9 and 10, whichever are actually cabled -- chain-end switches
only use port 9) are now interface.mode='tagged' with untagged_vlan=214
(Switch Management), the native VLAN on what the user confirmed are
redundant trunk uplinks. 63 interfaces updated; the diagram now shows
'9·V214' style tags on those connectors. Department-VLAN tagging (the
other half of each trunk) is intentionally not done yet -- needs a real
per-switch mapping, not a guess.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NM3nivbufxEWiKKmatEtEe
2026-09-15 13:45:15 -05:00

133 lines
5.1 KiB
Python

"""
Run INSIDE the NetBox Django shell to export the field-switch topology
(switches + every patch panel / splice module they physically pass through)
as JSON, for generate_drawio.py to render.
Usage (from the NetBox host, e.g. /opt/netbox):
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
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
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.
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."
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"]
).values_list("id", flat=True)
)
ifaces = Interface.objects.filter(
device_id__in=switch_ids, cable__isnull=False
).select_related("device")
touched_devices = set(switch_ids)
for i in ifaces:
path = i._path
if not path or not path.is_active:
continue
for hop in i.trace():
for item in hop:
for t in item:
dev = getattr(t, "device", None)
if dev:
touched_devices.add(dev.id)
cables = Cable.objects.filter(
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:
# One entry PER CABLE (not deduped by device pair) -- a device
# pair can be joined by more than one real cable (redundant
# fiber pairs, or a switch with two local jumpers into the
# same on-site patch panel that fan out to different places
# from there), and collapsing those to one edge silently
# drops real physical links.
edges.append({
"cable_id": c.id,
"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(
"site", "role", "device_type"
)
out = {
"devices": [
{
"id": d.id,
"name": d.name,
"role": d.role.slug,
"role_name": d.role.name,
"site": d.site.name if d.site else "",
"status": str(d.status),
"device_type": d.device_type.model,
}
for d in devs
],
"edges": edges,
"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))