From 39644541f1bc47bf9b794cb655f4dfbae5bb99d9 Mon Sep 17 00:00:00 2001 From: sjat Date: Wed, 24 Jun 2026 15:05:42 +0200 Subject: [PATCH] feat(rack): render mermaid network graph into the rack page --- scripts/gen_rack.py | 52 ++++++++++++++++++++++++++ tests/test_gen_rack.py | 83 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 135 insertions(+) diff --git a/scripts/gen_rack.py b/scripts/gen_rack.py index ac716a8..f950472 100644 --- a/scripts/gen_rack.py +++ b/scripts/gen_rack.py @@ -393,6 +393,52 @@ def render_power(rack: str, items: list[dict]) -> str: return "\n".join(lines) + "\n" +def render_network(rack: str, items: list[dict]) -> str: + """Return a mermaid network-cabling flowchart, or '' if no links. + + Assumes `validate_links` has already passed: every link has a non-empty + `local`/`peer` and an integer `peer_port`, and `peer` resolves to a real + hardware file. `generate` validates before any render call. + """ + linked = [fm for fm in items if fm.get("links")] + if not linked: + return "" + + by_host = {fm.get("hostname"): fm for fm in items} + + edges: list[tuple[str, str, str, int, object]] = [] + nodes: set[str] = set() + for fm in linked: + source = fm.get("hostname", "?") + nodes.add(source) + for link in fm["links"]: + peer = link["peer"] + nodes.add(peer) + edges.append( + (source, link["local"], peer, link["peer_port"], + link.get("speed_gbps")) + ) + edges.sort(key=lambda e: (e[0], e[1], e[2], e[3])) + + def node_label(name: str) -> str: + fm = by_host.get(name) + kind = fm.get("kind") if fm else None + if kind in ("switch", "patch-panel"): + return f"{name}
{kind}" + return name + + lines: list[str] = ["```mermaid", "flowchart LR"] + for name in sorted(nodes): + lines.append(f' {_node_id(name)}["{node_label(name)}"]') + for source, local, peer, peer_port, speed in edges: + label = f"{local} → p{peer_port}" + if speed is not None: + label += f" · {speed}G" + lines.append(f" {_node_id(source)} -->|{label}| {_node_id(peer)}") + lines.append("```") + return "\n".join(lines) + "\n" + + def render_page(rack: str, items: list[dict]) -> str: items = _sorted_items(items) lines: list[str] = [] @@ -414,6 +460,12 @@ def render_page(rack: str, items: list[dict]) -> str: lines.append("") lines.append(power.rstrip()) lines.append("") + network = render_network(rack, items) + if network: + lines.append("## Network") + lines.append("") + lines.append(network.rstrip()) + lines.append("") lines.append("## Occupancy") lines.append("") lines.append("| U | Device | Kind | Face | Status |") diff --git a/tests/test_gen_rack.py b/tests/test_gen_rack.py index f1a5e7e..b061f7f 100644 --- a/tests/test_gen_rack.py +++ b/tests/test_gen_rack.py @@ -419,3 +419,86 @@ def test_generate_returns_1_on_bad_link_peer(tmp_path): rc = gen_rack.generate(hw, out) assert rc == 1 assert not (out / "rack01.md").exists() + + +def test_render_network_has_nodes_and_edge_labels(): + items = [ + item(hostname="sw01", kind="switch", rack_u=10, u_height=1, + rack_face="front", ports=24), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + links=[{"local": "eth0", "peer": "sw01", + "peer_port": 1, "speed_gbps": 1}]), + ] + out = gen_rack.render_network("rack01", items) + assert "```mermaid" in out + assert "flowchart LR" in out + assert "sw01
switch" in out + assert "mf00" in out + assert "eth0" in out + assert "p1" in out + assert "1G" in out + + +def test_render_network_patch_panel_subtitle(): + items = [ + item(hostname="pp01", kind="patch-panel", rack_u=24, u_height=1, + rack_face="front", ports=24), + item(hostname="mf01", rack_u=2, u_height=1, rack_face="front", + links=[{"local": "eth0", "peer": "pp01", + "peer_port": 1, "speed_gbps": 1}]), + ] + out = gen_rack.render_network("rack01", items) + assert "pp01
patch-panel" in out + + +def test_render_network_empty_when_no_links(): + items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")] + assert gen_rack.render_network("rack01", items) == "" + + +def test_render_network_omits_speed_when_absent(): + items = [ + item(hostname="sw01", kind="switch", rack_u=10, u_height=1, + rack_face="front", ports=24), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + links=[{"local": "eth0", "peer": "sw01", "peer_port": 1}]), + ] + out = gen_rack.render_network("rack01", items) + assert "eth0" in out and "p1" in out + assert "·" not in out # no speed suffix rendered + + +def test_render_network_is_deterministic(): + a = item(hostname="sw01", kind="switch", rack_u=10, u_height=1, + rack_face="front", ports=24) + b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front", + links=[{"local": "eth0", "peer": "sw01", + "peer_port": 2, "speed_gbps": 1}]) + c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + links=[{"local": "eth0", "peer": "sw01", + "peer_port": 1, "speed_gbps": 1}]) + assert gen_rack.render_network("rack01", [a, b, c]) == \ + gen_rack.render_network("rack01", [c, b, a]) + + +def test_generate_includes_network_section(tmp_path): + hw = tmp_path / "hardware" + out = tmp_path / "out" + hw.mkdir() + _write_item( + hw, "sw01", + "---\nhostname: sw01\nkind: switch\nstatus: in-use\n" + "rack: rack01\nrack_u: 10\nu_height: 1\nrack_face: front\nports: 24\n---\n", + ) + _write_item( + hw, "mf00", + "---\nhostname: mf00\nkind: server\nstatus: in-use\n" + "rack: rack01\nrack_u: 1\nu_height: 1\nrack_face: front\n" + "links:\n - { local: eth0, peer: sw01, peer_port: 1, speed_gbps: 1 }\n---\n", + ) + rc = gen_rack.generate(hw, out) + assert rc == 0 + page = (out / "rack01.md").read_text() + assert "## Network" in page + assert "```mermaid" in page + assert "eth0" in page