From d275b7e821946c2081bdbd991b8d17f188a729a9 Mon Sep 17 00:00:00 2001 From: dcstephenson Date: Tue, 15 Sep 2026 12:41:22 -0500 Subject: [PATCH] Add NetBox-derived field switch topology (draw.io) - export_topology.py: dumps switch + patch-panel cable graph from NetBox (Django shell, read-only) - generate_drawio.py: renders topo_full.json as a draw.io diagram with natively-glued connectors (source/target by shape id, no hand-rolled glue formulas) - topo_full.json + Field-Switch-Topology.drawio: current snapshot Replaces the earlier hand-built .vsdx approach: plain-text format diffs meaningfully in git, no Visio license needed to view/edit, and edges glue natively instead of needing point-to-point formula hacks. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01NM3nivbufxEWiKKmatEtEe --- README.md | 67 +++++++ topology/Field-Switch-Topology.drawio | 1 + topology/export_topology.py | 68 +++++++ topology/generate_drawio.py | 266 ++++++++++++++++++++++++++ topology/topo_full.json | 1 + 5 files changed, 403 insertions(+) create mode 100644 README.md create mode 100644 topology/Field-Switch-Topology.drawio create mode 100644 topology/export_topology.py create mode 100644 topology/generate_drawio.py create mode 100644 topology/topo_full.json diff --git a/README.md b/README.md new file mode 100644 index 0000000..df43597 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Netbox + +Generated documentation for the field-switch network, sourced from this +utility's live [NetBox](https://netbox.dev/) instance. NetBox is the source +of truth here, not this repo -- everything under `topology/` is a build +artifact, regenerated from NetBox and committed so history shows what the +*physical network* changed, not just what someone drew. + +## topology/ + +| 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. | +| `topo_full.json` | Latest exported snapshot: devices (id, name, role, site, status) + edges (physical links, one per cable). | +| `generate_drawio.py` | Pure-Python renderer: `topo_full.json` → `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. | + +Two distribution switches (`fche-sw17a`, `dmo-lob-sw35a`) anchor two +fabrics; everything downstream is field switches daisy-chained through +patch panels and fiber splice modules. A handful of switches sit outside +both fabrics for a documented reason (spare/inventory, decommissioning, or +planned/not-yet-installed) -- those are called out in their own box on the +diagram rather than silently dropped. + +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. That's different from this project's earlier +Visio (`.vsdx`) attempt, where glue had to be hand-written as cell 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 + +From the NetBox host (needs `docker exec` access to the NetBox container): + +```sh +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 + +python3 topology/generate_drawio.py topology/topo_full.json topology/Field-Switch-Topology.drawio +``` + +Commit both files together. The diff on `topo_full.json` is the real +changelog -- it shows exactly which device or cable changed between two +snapshots; the `.drawio` diff is mostly noise (coordinates) but is kept in +sync so the rendered diagram never drifts from what NetBox actually says. + +## Gotchas learned the hard way + +- **NetBox's cached cable path can go stale.** If a switch's + `connected_endpoints` comes back empty even though it's obviously + cabled, try `manage.py trace_paths --force` before assuming the cable + data itself is wrong -- it recomputes the path cache and doesn't touch + any cable/termination records. +- **A patch panel's front↔rear pass-through has to be modeled + explicitly** (a `PortMapping` row). A splice module can have a + perfectly good cable on both sides of a port and still fail to trace if + that mapping is missing -- check `FrontPort.objects.get(...).mappings.count()` + before assuming the far end really is disconnected. + +## Possible next step + +`export_topology.py` talks to NetBox via the Django ORM (`docker exec` + +`manage.py shell`), which only works run from the NetBox host itself. +Swapping it for a NetBox REST API client (device/cable/interface +endpoints + an API token) would let this regenerate from anywhere, +including CI, without shell access to the container. diff --git a/topology/Field-Switch-Topology.drawio b/topology/Field-Switch-Topology.drawio new file mode 100644 index 0000000..2f48ed5 --- /dev/null +++ b/topology/Field-Switch-Topology.drawio @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/topology/export_topology.py b/topology/export_topology.py new file mode 100644 index 0000000..335334b --- /dev/null +++ b/topology/export_topology.py @@ -0,0 +1,68 @@ +""" +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. +""" +from dcim.models import Device, Interface +import json + +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) +edges = set() # (device_id_a, device_id_b), deduplicated, any two adjacent devices on a physical chain + +for i in ifaces: + path = i._path + if not path or not path.is_active: + continue + chain = [] + for hop in i.trace(): + for item in hop: + for t in item: + dev = getattr(t, "device", None) + if dev: + if not chain or chain[-1] != dev.id: + chain.append(dev.id) + touched_devices.add(dev.id) + for a, b in zip(chain, chain[1:]): + if a != b: + edges.add(tuple(sorted((a, b)))) + +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": [[a, b] for a, b in sorted(edges)], +} +print(json.dumps(out)) diff --git a/topology/generate_drawio.py b/topology/generate_drawio.py new file mode 100644 index 0000000..2d2af82 --- /dev/null +++ b/topology/generate_drawio.py @@ -0,0 +1,266 @@ +#!/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. + +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"] + +# ---------------- components ---------------- +adj = {d: [] for d in devs} +for a, b in edges: + adj[a].append(b) + adj[b].append(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 a, b in edges: + union(a, 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 + children = [n for n in sorted(adj[node]) if n in node_set and n != par and n not in visited] + 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 = 130, 34 +COL_W = 190 +ROW_H = 82 +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 +cursor_y = MARGIN + TITLE_H + +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) +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="#EEF0F2", stroke="#8C97A6", font="#495566", 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): + 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 get a real network-switch icon (label rendered below it by +# draw.io's own verticalLabelPosition, not baked into the geometry), patch panels get +# a plain labeled box. +node_style = {} +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": + style = ( + 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: + 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 -- glued natively via source/target, orthogonal routing +for idx, (a, b) in enumerate(edges): + if a in pos and b in pos: + edge(f"e{idx}", f"n{a}", f"n{b}", + "edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=#9AA6B8;strokeWidth=1.2;" + "jettySize=auto;orthogonalLoop=1;") + +# legend +legend_items = [ + ("#2F5FAD", "#173D6E", "Distribution switch"), + ("#F3F5FA", "#7C8AA6", "Field switch"), + ("#EEF0F2", "#8C97A6", "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 + +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") diff --git a/topology/topo_full.json b/topology/topo_full.json new file mode 100644 index 0000000..885ac67 --- /dev/null +++ b/topology/topo_full.json @@ -0,0 +1 @@ +{"devices": [{"id": 57, "name": "afl-104", "role": "patch-panel", "role_name": "Patch Panel", "site": "Highway 104 Base Station", "status": "active", "device_type": "AFL 12-Port Splice Module"}, {"id": 92, "name": "afl-cr13", "role": "patch-panel", "role_name": "Patch Panel", "site": "County Road 13 Base Station", "status": "active", "device_type": "AFL 12-Port Splice Module"}, {"id": 85, "name": "afl-lem", "role": "patch-panel", "role_name": "Patch Panel", "site": "Lemler Substation", "status": "active", "device_type": "AFL 12-Port Splice Module"}, {"id": 58, "name": "afl-mag", "role": "patch-panel", "role_name": "Patch Panel", "site": "Magnolia Springs Base Station", "status": "active", "device_type": "AFL 12-Port Splice Module"}, {"id": 82, "name": "afl-swf", "role": "patch-panel", "role_name": "Patch Panel", "site": "Southwest Foley Substation", "status": "active", "device_type": "AFL 12-Port Splice Module"}, {"id": 42, "name": "bs-104-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Highway 104 Base Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 93, "name": "bs-cr13-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "County Road 13 Base Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 40, "name": "bs-lem-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Lemler Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 38, "name": "bs-lil-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Lillian Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 43, "name": "bs-mag-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Magnolia Springs Base Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 41, "name": "bs-nwt-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "North Water Tank", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 39, "name": "bs-swf-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Southwest Foley Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 7, "name": "dmo-lob-sw35a", "role": "dist-switch", "role_name": "Distribution Switch", "site": "DMO-LOB", "status": "active", "device_type": "S4112F-ON"}, {"id": 2, "name": "FCHE-PP-32B", "role": "patch-panel", "role_name": "Patch Panel", "site": "FCHE Cable Headend", "status": "active", "device_type": "LS Series 288-Port LC UPC"}, {"id": 4, "name": "FCHE-PP17D", "role": "patch-panel", "role_name": "Patch Panel", "site": "FCHE Cable Headend", "status": "active", "device_type": "FIS 48-Port LC UPC"}, {"id": 3, "name": "FCHE-PP34A", "role": "patch-panel", "role_name": "Patch Panel", "site": "FCHE Cable Headend", "status": "active", "device_type": "FIS 144-Port LC UPC 12-Slot"}, {"id": 1, "name": "fche-sw17a", "role": "dist-switch", "role_name": "Distribution Switch", "site": "FCHE Cable Headend", "status": "active", "device_type": "ProCurve 5412zl"}, {"id": 63, "name": "fis-nwt", "role": "patch-panel", "role_name": "Patch Panel", "site": "North Water Tank", "status": "active", "device_type": "F1-DW2X"}, {"id": 11, "name": "gs-nff-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "North Foley Field Gas Regulator", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 19, "name": "ls-avls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Aaronville Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 14, "name": "ls-bsls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Bay Street Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 20, "name": "ls-clls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Cornerstone Lodge Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 10, "name": "ls-guls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Gulftel Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 25, "name": "ls-mals-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Marlin Place Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 23, "name": "ls-mmls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Majestic Manor Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 26, "name": "ls-mpls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "McSwain Place Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 9, "name": "ls-pals-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Peachtree Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 21, "name": "ls-rols-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "River Oaks Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 24, "name": "ls-spls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "South Pointe Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 22, "name": "ls-vhls-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Villages at Hickory Lift Station", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 76, "name": "primex-ebert", "role": "patch-panel", "role_name": "Patch Panel", "site": "Ebert Substation", "status": "active", "device_type": "Primex 12-Port LC Panel"}, {"id": 79, "name": "primex-holk", "role": "patch-panel", "role_name": "Patch Panel", "site": "Holk Substation", "status": "active", "device_type": "Primex 12-Port LC Panel"}, {"id": 80, "name": "primex-lem", "role": "patch-panel", "role_name": "Patch Panel", "site": "Lemler Substation", "status": "active", "device_type": "Primex 12-Port LC Panel"}, {"id": 81, "name": "primex-lil", "role": "patch-panel", "role_name": "Patch Panel", "site": "Lillian Substation", "status": "active", "device_type": "Primex 12-Port LC Panel"}, {"id": 83, "name": "primex-wpb", "role": "patch-panel", "role_name": "Patch Panel", "site": "West Perdido Beach Substation", "status": "active", "device_type": "Primex 12-Port LC Panel"}, {"id": 33, "name": "rec-10000-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Bay St @ Azalea Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 34, "name": "rec-10008-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Cedar @ Michigan Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 17, "name": "rec-10016-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Underwood Rd Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 28, "name": "rec-10018-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Summerdale Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 18, "name": "rec-10136-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "WHEP Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 15, "name": "rec-10160-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Coca Cola Plant Hwy 104 Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 32, "name": "rec-10660-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Old Foley Rd Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 16, "name": "rec-13032-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Peachtree Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 30, "name": "rec-50094-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Randall Ave @ Hwy 98 Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 31, "name": "rec-52882-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Pollard Rd Recloser", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 87, "name": "sec-holk-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Holk Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 88, "name": "sec-lem-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Lemler Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 89, "name": "sec-swf-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Southwest Foley Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 45, "name": "spare-163", "role": "field-switch", "role_name": "Field Switch", "site": "FCHE Cable Headend", "status": "inventory", "device_type": "IES3220-8T4F"}, {"id": 46, "name": "spare-164", "role": "field-switch", "role_name": "Field Switch", "site": "FCHE Cable Headend", "status": "inventory", "device_type": "IES3220-8T4F"}, {"id": 47, "name": "spare-165", "role": "field-switch", "role_name": "Field Switch", "site": "FCHE Cable Headend", "status": "inventory", "device_type": "IES3220-8T4F"}, {"id": 48, "name": "spare-166", "role": "field-switch", "role_name": "Field Switch", "site": "FCHE Cable Headend", "status": "inventory", "device_type": "IES3220-8T4F"}, {"id": 36, "name": "sub-ebert-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Ebert Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 13, "name": "sub-fair-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Fairhope Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 29, "name": "sub-holk-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Holk Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 49, "name": "sub-lem-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Lemler Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 35, "name": "sub-lem-sw01-decommissioned", "role": "field-switch", "role_name": "Field Switch", "site": "Lemler Substation", "status": "decommissioning", "device_type": "2930F"}, {"id": 8, "name": "sub-lil-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Lillian Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 27, "name": "sub-swf-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Southwest Foley Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 37, "name": "sub-wpb-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "West Perdido Beach Substation", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 90, "name": "wtr-cwt-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Cypress Water Tank", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 12, "name": "wtr-ecc-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Elberta Cross Connect", "status": "active", "device_type": "IES3110-8TFP-R"}, {"id": 44, "name": "wtr-w12-sw01", "role": "field-switch", "role_name": "Field Switch", "site": "Well 12", "status": "planned", "device_type": "IES3110-8TFP-R"}, {"id": 51, "name": "xpak-10000", "role": "patch-panel", "role_name": "Patch Panel", "site": "Bay St @ Azalea Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 53, "name": "xpak-10008", "role": "patch-panel", "role_name": "Patch Panel", "site": "Cedar @ Michigan Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 72, "name": "xpak-10016", "role": "patch-panel", "role_name": "Patch Panel", "site": "Underwood Rd Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 71, "name": "xpak-10018", "role": "patch-panel", "role_name": "Patch Panel", "site": "Summerdale Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 74, "name": "xpak-10136", "role": "patch-panel", "role_name": "Patch Panel", "site": "WHEP Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 54, "name": "xpak-10160", "role": "patch-panel", "role_name": "Patch Panel", "site": "Coca Cola Plant Hwy 104 Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 64, "name": "xpak-10660", "role": "patch-panel", "role_name": "Patch Panel", "site": "Old Foley Rd Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 66, "name": "xpak-13032", "role": "patch-panel", "role_name": "Patch Panel", "site": "Peachtree Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 68, "name": "xpak-50094", "role": "patch-panel", "role_name": "Patch Panel", "site": "Randall Ave @ Hwy 98 Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 67, "name": "xpak-52882", "role": "patch-panel", "role_name": "Patch Panel", "site": "Pollard Rd Recloser", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 50, "name": "xpak-avls", "role": "patch-panel", "role_name": "Patch Panel", "site": "Aaronville Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 52, "name": "xpak-bsls", "role": "patch-panel", "role_name": "Patch Panel", "site": "Bay Street Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 55, "name": "xpak-clls", "role": "patch-panel", "role_name": "Patch Panel", "site": "Cornerstone Lodge Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 91, "name": "xpak-cwt", "role": "patch-panel", "role_name": "Patch Panel", "site": "Cypress Water Tank", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 56, "name": "xpak-guls", "role": "patch-panel", "role_name": "Patch Panel", "site": "Gulftel Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 60, "name": "xpak-mals", "role": "patch-panel", "role_name": "Patch Panel", "site": "Marlin Place Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 59, "name": "xpak-mmls", "role": "patch-panel", "role_name": "Patch Panel", "site": "Majestic Manor Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 61, "name": "xpak-mpls", "role": "patch-panel", "role_name": "Patch Panel", "site": "McSwain Place Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 62, "name": "xpak-nff", "role": "patch-panel", "role_name": "Patch Panel", "site": "North Foley Field Gas Regulator", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 65, "name": "xpak-pals", "role": "patch-panel", "role_name": "Patch Panel", "site": "Peachtree Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 69, "name": "xpak-rols", "role": "patch-panel", "role_name": "Patch Panel", "site": "River Oaks Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 70, "name": "xpak-spls", "role": "patch-panel", "role_name": "Patch Panel", "site": "South Pointe Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}, {"id": 73, "name": "xpak-vhls", "role": "patch-panel", "role_name": "Patch Panel", "site": "Villages at Hickory Lift Station", "status": "active", "device_type": "xPAK 6-Port SC/APC"}], "edges": [[1, 2], [1, 3], [1, 4], [2, 52], [2, 60], [2, 70], [2, 79], [2, 85], [2, 91], [3, 54], [3, 62], [3, 63], [3, 65], [3, 66], [4, 12], [4, 64], [4, 76], [7, 13], [7, 67], [7, 68], [8, 81], [9, 65], [10, 56], [11, 62], [14, 52], [15, 54], [16, 66], [17, 72], [18, 74], [19, 50], [20, 55], [21, 69], [22, 73], [23, 59], [24, 70], [25, 60], [26, 61], [27, 82], [28, 71], [29, 79], [30, 68], [31, 67], [32, 64], [33, 51], [34, 53], [36, 76], [37, 83], [38, 81], [39, 82], [40, 85], [41, 63], [42, 57], [43, 58], [49, 80], [50, 52], [50, 69], [51, 53], [51, 79], [53, 80], [55, 61], [56, 65], [57, 63], [57, 92], [58, 82], [59, 61], [59, 73], [66, 72], [69, 73], [71, 74], [72, 74], [76, 83], [79, 80], [79, 87], [80, 82], [80, 88], [81, 83], [82, 85], [82, 89], [90, 91], [92, 93]]}