Add port labels, modernize patch panels, add a real PNG preview
- export_topology.py: edges now carry the exact port name on both ends (pulled directly from Cable/CableTermination, not inferred from the trace chain), so the diagram can label each connector. - generate_drawio.py: patch panels are now a small rack-unit shape (header strip + port ticks, parented to the panel body so they move together) instead of a plain box; connectors show 'porta <-> portb' and no longer carry a directional arrowhead (these are physical cables, not directed links); switches keep the network-switch icon. - render_png.js + render_host.html: headless Puppeteer driver for embed.diagrams.net's postMessage export protocol -- an actual render of the .drawio file, not an approximation. Field-Switch- Topology.png is the committed output, for anyone who wants a look without opening draw.io. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NM3nivbufxEWiKKmatEtEe
This commit is contained in:
@@ -10,10 +10,12 @@ artifact, regenerated from NetBox and committed so history shows what the
|
|||||||
|
|
||||||
| file | what it is |
|
| 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. |
|
| `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 (physical links, one per cable). |
|
| `topo_full.json` | Latest exported snapshot: devices (id, name, role, site, status) + edges (`{a, a_port, b, b_port}`, one per cable). |
|
||||||
| `generate_drawio.py` | Pure-Python renderer: `topo_full.json` → `Field-Switch-Topology.drawio`. No NetBox access needed. |
|
| `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. |
|
| `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. |
|
||||||
|
| `Field-Switch-Topology.png` | Static preview of the diagram, for anyone who wants a quick look without opening draw.io (viewing it in the Gitea file browser, a PR diff, etc.). |
|
||||||
|
|
||||||
Two distribution switches (`fche-sw17a`, `dmo-lob-sw35a`) anchor two
|
Two distribution switches (`fche-sw17a`, `dmo-lob-sw35a`) anchor two
|
||||||
fabrics; everything downstream is field switches daisy-chained through
|
fabrics; everything downstream is field switches daisy-chained through
|
||||||
@@ -22,12 +24,18 @@ both fabrics for a documented reason (spare/inventory, decommissioning, or
|
|||||||
planned/not-yet-installed) -- those are called out in their own box on the
|
planned/not-yet-installed) -- those are called out in their own box on the
|
||||||
diagram rather than silently dropped.
|
diagram rather than silently dropped.
|
||||||
|
|
||||||
|
Every connector is labeled with the port at each end (e.g. `9 ↔ 1`), and
|
||||||
|
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.
|
||||||
|
|
||||||
Connectors in the `.drawio` file are glued by shape id (draw.io's native
|
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
|
`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
|
app and its links follow, port-tick decorations included since they're
|
||||||
Visio (`.vsdx`) attempt, where glue had to be hand-written as cell formulas
|
parented to their panel's body cell. That's different from this project's
|
||||||
because the format has no simpler mechanism; draw.io does this natively,
|
earlier Visio (`.vsdx`) attempt, where glue had to be hand-written as cell
|
||||||
which is a big part of why this repo uses it instead.
|
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
|
## Regenerating
|
||||||
|
|
||||||
@@ -38,12 +46,16 @@ 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
|
-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
|
python3 topology/generate_drawio.py topology/topo_full.json topology/Field-Switch-Topology.drawio
|
||||||
|
|
||||||
|
# optional -- refreshes the PNG preview to match; first run: npm install puppeteer
|
||||||
|
node topology/render_png.js topology/Field-Switch-Topology.drawio topology/Field-Switch-Topology.png
|
||||||
```
|
```
|
||||||
|
|
||||||
Commit both files together. The diff on `topo_full.json` is the real
|
Commit all three (`topo_full.json`, `.drawio`, `.png`) together. The diff on
|
||||||
changelog -- it shows exactly which device or cable changed between two
|
`topo_full.json` is the real changelog -- it shows exactly which device or
|
||||||
snapshots; the `.drawio` diff is mostly noise (coordinates) but is kept in
|
cable changed between two snapshots; the `.drawio`/`.png` diffs are mostly
|
||||||
sync so the rendered diagram never drifts from what NetBox actually says.
|
noise (coordinates, pixels) but are kept in sync so the rendered diagram
|
||||||
|
never drifts from what NetBox actually says.
|
||||||
|
|
||||||
## Gotchas learned the hard way
|
## Gotchas learned the hard way
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Binary file not shown.
|
After Width: | Height: | Size: 1.5 MiB |
+34
-10
@@ -12,8 +12,18 @@ 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
|
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
|
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.
|
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."
|
||||||
"""
|
"""
|
||||||
from dcim.models import Device, Interface
|
from dcim.models import Device, Interface, Cable
|
||||||
import json
|
import json
|
||||||
|
|
||||||
switch_ids = set(
|
switch_ids = set(
|
||||||
@@ -27,24 +37,38 @@ ifaces = Interface.objects.filter(
|
|||||||
).select_related("device")
|
).select_related("device")
|
||||||
|
|
||||||
touched_devices = set(switch_ids)
|
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:
|
for i in ifaces:
|
||||||
path = i._path
|
path = i._path
|
||||||
if not path or not path.is_active:
|
if not path or not path.is_active:
|
||||||
continue
|
continue
|
||||||
chain = []
|
|
||||||
for hop in i.trace():
|
for hop in i.trace():
|
||||||
for item in hop:
|
for item in hop:
|
||||||
for t in item:
|
for t in item:
|
||||||
dev = getattr(t, "device", None)
|
dev = getattr(t, "device", None)
|
||||||
if dev:
|
if dev:
|
||||||
if not chain or chain[-1] != dev.id:
|
|
||||||
chain.append(dev.id)
|
|
||||||
touched_devices.add(dev.id)
|
touched_devices.add(dev.id)
|
||||||
for a, b in zip(chain, chain[1:]):
|
|
||||||
if a != b:
|
cables = Cable.objects.filter(
|
||||||
edges.add(tuple(sorted((a, b))))
|
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:
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
|
||||||
devs = Device.objects.filter(id__in=touched_devices).select_related(
|
devs = Device.objects.filter(id__in=touched_devices).select_related(
|
||||||
"site", "role", "device_type"
|
"site", "role", "device_type"
|
||||||
@@ -63,6 +87,6 @@ out = {
|
|||||||
}
|
}
|
||||||
for d in devs
|
for d in devs
|
||||||
],
|
],
|
||||||
"edges": [[a, b] for a, b in sorted(edges)],
|
"edges": list(edges.values()),
|
||||||
}
|
}
|
||||||
print(json.dumps(out))
|
print(json.dumps(out))
|
||||||
|
|||||||
+47
-26
@@ -5,7 +5,8 @@ Render topo_full.json (produced by export_topology.py) as a draw.io
|
|||||||
|
|
||||||
Edges reference shapes by id (source=/target=), which is how draw.io's
|
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
|
native glue works -- no hand-rolled formulas needed: dragging a shape in
|
||||||
the app keeps every attached line attached.
|
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:
|
Usage:
|
||||||
python3 generate_drawio.py [topo_full.json] [Field-Switch-Topology.drawio]
|
python3 generate_drawio.py [topo_full.json] [Field-Switch-Topology.drawio]
|
||||||
@@ -19,13 +20,13 @@ OUT_PATH = sys.argv[2] if len(sys.argv) > 2 else "Field-Switch-Topology.drawio"
|
|||||||
|
|
||||||
data = json.load(open(IN_PATH))
|
data = json.load(open(IN_PATH))
|
||||||
devs = {d["id"]: d for d in data["devices"]}
|
devs = {d["id"]: d for d in data["devices"]}
|
||||||
edges = data["edges"]
|
edges = data["edges"] # [{a, a_port, b, b_port}, ...]
|
||||||
|
|
||||||
# ---------------- components ----------------
|
# ---------------- components ----------------
|
||||||
adj = {d: [] for d in devs}
|
adj = {d: [] for d in devs}
|
||||||
for a, b in edges:
|
for e in edges:
|
||||||
adj[a].append(b)
|
adj[e["a"]].append(e["b"])
|
||||||
adj[b].append(a)
|
adj[e["b"]].append(e["a"])
|
||||||
|
|
||||||
parent = {d: d for d in devs}
|
parent = {d: d for d in devs}
|
||||||
def find(x):
|
def find(x):
|
||||||
@@ -37,8 +38,8 @@ def union(a, b):
|
|||||||
ra, rb = find(a), find(b)
|
ra, rb = find(a), find(b)
|
||||||
if ra != rb:
|
if ra != rb:
|
||||||
parent[ra] = rb
|
parent[ra] = rb
|
||||||
for a, b in edges:
|
for e in edges:
|
||||||
union(a, b)
|
union(e["a"], e["b"])
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
comps = defaultdict(list)
|
comps = defaultdict(list)
|
||||||
for d in devs:
|
for d in devs:
|
||||||
@@ -74,9 +75,9 @@ depth_dmo, yslot_dmo = layout_tree(7, dmo_comp)
|
|||||||
# ---------------- geometry constants (px) ----------------
|
# ---------------- geometry constants (px) ----------------
|
||||||
SWITCH_ICON = 46 # icon box; label renders below it
|
SWITCH_ICON = 46 # icon box; label renders below it
|
||||||
SWITCH_LABEL_H = 30
|
SWITCH_LABEL_H = 30
|
||||||
PANEL_W, PANEL_H = 130, 34
|
PANEL_W, PANEL_H = 132, 42
|
||||||
COL_W = 190
|
COL_W = 200
|
||||||
ROW_H = 82
|
ROW_H = 86
|
||||||
PAD = 40
|
PAD = 40
|
||||||
LABEL_STRIP = 34
|
LABEL_STRIP = 34
|
||||||
SECTION_GAP = 70
|
SECTION_GAP = 70
|
||||||
@@ -137,7 +138,7 @@ STYLE = {
|
|||||||
"spare": dict(fill="#F1F1F2", stroke="#9AA0AA", font="#5B6478", dashed=1, 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),
|
"decom": dict(fill="#FBE6E2", stroke="#C04B34", font="#8A3423", dashed=1, bold=0),
|
||||||
"planned": dict(fill="#FDF1CF", stroke="#B8862C", font="#7A5C00", 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),
|
"panel": dict(fill="#3A4457", stroke="#242C38", font="#FFFFFF", dashed=0, bold=0),
|
||||||
}
|
}
|
||||||
|
|
||||||
def style_for(dev):
|
def style_for(dev):
|
||||||
@@ -166,9 +167,9 @@ def vertex(cell_id, value, x, y, w, h, style, parent="1"):
|
|||||||
f'</mxCell>'
|
f'</mxCell>'
|
||||||
)
|
)
|
||||||
|
|
||||||
def edge(cell_id, source, target, style):
|
def edge(cell_id, source, target, style, value=""):
|
||||||
add_cell(
|
add_cell(
|
||||||
f'<mxCell id="{cell_id}" style="{style}" edge="1" parent="1" '
|
f'<mxCell id="{cell_id}" value="{escape(value)}" style="{style}" edge="1" parent="1" '
|
||||||
f'source="{source}" target="{target}"><mxGeometry relative="1" as="geometry"/></mxCell>'
|
f'source="{source}" target="{target}"><mxGeometry relative="1" as="geometry"/></mxCell>'
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -198,10 +199,29 @@ add_cell(
|
|||||||
f'<mxGeometry x="{MARGIN}" y="10" width="600" height="30" as="geometry"/></mxCell>'
|
f'<mxGeometry x="{MARGIN}" y="10" width="600" height="30" as="geometry"/></mxCell>'
|
||||||
)
|
)
|
||||||
|
|
||||||
# node shapes -- switches get a real network-switch icon (label rendered below it by
|
# ---------------- node shapes ----------------
|
||||||
# draw.io's own verticalLabelPosition, not baked into the geometry), patch panels get
|
# Switches: a real network-switch icon, hostname caption below it.
|
||||||
# a plain labeled box.
|
# Patch panels: a small rack-style unit -- dark body, a lighter header strip,
|
||||||
node_style = {}
|
# 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():
|
for did, dev in devs.items():
|
||||||
if did not in pos:
|
if did not in pos:
|
||||||
continue
|
continue
|
||||||
@@ -209,11 +229,7 @@ for did, dev in devs.items():
|
|||||||
st = style_for(dev)
|
st = style_for(dev)
|
||||||
dashed = "dashed=1;dashPattern=4 3;" if st["dashed"] else ""
|
dashed = "dashed=1;dashPattern=4 3;" if st["dashed"] else ""
|
||||||
if dev["role"] == "patch-panel":
|
if dev["role"] == "patch-panel":
|
||||||
style = (
|
panel_shape(did, x, y, w, h, dev["name"], st)
|
||||||
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:
|
else:
|
||||||
style = (
|
style = (
|
||||||
'shape=mxgraph.networks.switch;html=1;verticalLabelPosition=bottom;verticalAlign=top;'
|
'shape=mxgraph.networks.switch;html=1;verticalLabelPosition=bottom;verticalAlign=top;'
|
||||||
@@ -222,18 +238,23 @@ for did, dev in devs.items():
|
|||||||
)
|
)
|
||||||
vertex(f"n{did}", dev["name"], x, y, SWITCH_ICON, SWITCH_ICON, style)
|
vertex(f"n{did}", dev["name"], x, y, SWITCH_ICON, SWITCH_ICON, style)
|
||||||
|
|
||||||
# connectors -- glued natively via source/target, orthogonal routing
|
# ---------------- connectors, labeled with the port at each end ----------------
|
||||||
for idx, (a, b) in enumerate(edges):
|
for idx, e in enumerate(edges):
|
||||||
|
a, b = e["a"], e["b"]
|
||||||
if a in pos and b in pos:
|
if a in pos and b in pos:
|
||||||
|
label = f'{e["a_port"]} ↔ {e["b_port"]}'
|
||||||
edge(f"e{idx}", f"n{a}", f"n{b}",
|
edge(f"e{idx}", f"n{a}", f"n{b}",
|
||||||
"edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=#9AA6B8;strokeWidth=1.2;"
|
"edgeStyle=orthogonalEdgeStyle;rounded=1;html=1;strokeColor=#9AA6B8;strokeWidth=1.2;"
|
||||||
"jettySize=auto;orthogonalLoop=1;")
|
"startArrow=none;endArrow=none;"
|
||||||
|
"jettySize=auto;orthogonalLoop=1;fontSize=7.5;fontColor=#7C8AA6;"
|
||||||
|
"labelBackgroundColor=#F7F8FBcc;align=center;verticalAlign=middle;",
|
||||||
|
value=label)
|
||||||
|
|
||||||
# legend
|
# legend
|
||||||
legend_items = [
|
legend_items = [
|
||||||
("#2F5FAD", "#173D6E", "Distribution switch"),
|
("#2F5FAD", "#173D6E", "Distribution switch"),
|
||||||
("#F3F5FA", "#7C8AA6", "Field switch"),
|
("#F3F5FA", "#7C8AA6", "Field switch"),
|
||||||
("#EEF0F2", "#8C97A6", "Patch panel / splice module"),
|
("#3A4457", "#242C38", "Patch panel / splice module"),
|
||||||
("#F1F1F2", "#9AA0AA", "Spare / decommissioned / planned"),
|
("#F1F1F2", "#9AA0AA", "Spare / decommissioned / planned"),
|
||||||
]
|
]
|
||||||
lx = MARGIN
|
lx = MARGIN
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html><body style="margin:0">
|
||||||
|
<iframe id="f" src="https://embed.diagrams.net/?proto=json&spin=1&embed=1&libraries=1&noSaveBtn=1"
|
||||||
|
style="width:1600px;height:1200px;border:0"></iframe>
|
||||||
|
<script>
|
||||||
|
window.__RESULT__ = null;
|
||||||
|
window.__ERR__ = null;
|
||||||
|
const iframe = document.getElementById('f');
|
||||||
|
window.addEventListener('message', (evt) => {
|
||||||
|
let msg;
|
||||||
|
try { msg = JSON.parse(evt.data); } catch (e) { return; }
|
||||||
|
console.log('HOST EVENT:', msg.event);
|
||||||
|
if (msg.event === 'init') {
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'load', xml: window.__XML__, autosave: 0 }), '*');
|
||||||
|
} else if (msg.event === 'load') {
|
||||||
|
iframe.contentWindow.postMessage(JSON.stringify({ action: 'export', format: 'xmlpng', xml: window.__XML__, border: 24, scale: 2 }), '*');
|
||||||
|
} else if (msg.event === 'export') {
|
||||||
|
window.__RESULT__ = msg.data;
|
||||||
|
} else if (msg.event === 'error') {
|
||||||
|
window.__ERR__ = JSON.stringify(msg);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body></html>
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Render a .drawio file to PNG via a real draw.io instance (embed.diagrams.net),
|
||||||
|
* driven headlessly with Puppeteer -- this is an actual render, not an
|
||||||
|
* approximation, so switch icons, patch panel styling, and port labels all
|
||||||
|
* come out exactly as they'd look opening the file in the app.
|
||||||
|
*
|
||||||
|
* Needs `npm install puppeteer` first (downloads its own Chromium; not the
|
||||||
|
* system browser). Needs network access to embed.diagrams.net.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* node render_png.js Field-Switch-Topology.drawio Field-Switch-Topology.png
|
||||||
|
*/
|
||||||
|
const puppeteer = require('puppeteer');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const xmlPath = process.argv[2];
|
||||||
|
const outPath = process.argv[3];
|
||||||
|
if (!xmlPath || !outPath) {
|
||||||
|
console.error('usage: node render_png.js <in.drawio> <out.png>');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
const xml = fs.readFileSync(xmlPath, 'utf8');
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const browser = await puppeteer.launch({
|
||||||
|
headless: 'new',
|
||||||
|
args: ['--no-sandbox', '--disable-setuid-sandbox'],
|
||||||
|
});
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setViewport({ width: 1700, height: 1300 });
|
||||||
|
page.on('pageerror', (err) => console.error('PAGE ERROR:', err));
|
||||||
|
|
||||||
|
await page.evaluateOnNewDocument((x) => { window.__XML__ = x; }, xml);
|
||||||
|
await page.goto('file://' + path.join(__dirname, 'render_host.html'), {
|
||||||
|
waitUntil: 'load',
|
||||||
|
timeout: 30000,
|
||||||
|
});
|
||||||
|
|
||||||
|
let dataUrl = null;
|
||||||
|
const deadline = Date.now() + 60000;
|
||||||
|
while (Date.now() < deadline) {
|
||||||
|
dataUrl = await page.evaluate(() => window.__RESULT__);
|
||||||
|
const err = await page.evaluate(() => window.__ERR__);
|
||||||
|
if (err) throw new Error('draw.io export error: ' + err);
|
||||||
|
if (dataUrl) break;
|
||||||
|
await new Promise((r) => setTimeout(r, 500));
|
||||||
|
}
|
||||||
|
if (!dataUrl) throw new Error('timed out waiting for draw.io to export (network to embed.diagrams.net?)');
|
||||||
|
|
||||||
|
const base64 = dataUrl.split(',')[1];
|
||||||
|
fs.writeFileSync(outPath, Buffer.from(base64, 'base64'));
|
||||||
|
console.log('wrote', outPath, fs.statSync(outPath).size, 'bytes');
|
||||||
|
await browser.close();
|
||||||
|
})().catch((e) => { console.error(e); process.exit(1); });
|
||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user