From d2744db4eefffe6ad0a5335755e7b056d0db688e Mon Sep 17 00:00:00 2001 From: sjat Date: Wed, 24 Jun 2026 14:38:23 +0200 Subject: [PATCH] feat(rack): render mermaid power graph into the rack page --- scripts/gen_rack.py | 47 +++++++++++++++++++++++++++++ tests/test_gen_rack.py | 67 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 114 insertions(+) diff --git a/scripts/gen_rack.py b/scripts/gen_rack.py index 499d162..37d0b68 100644 --- a/scripts/gen_rack.py +++ b/scripts/gen_rack.py @@ -292,6 +292,47 @@ def render_svg(rack: str, items: list[dict]) -> str: return "\n".join(p) + "\n" +def _node_id(name: str) -> str: + """A mermaid-safe node id derived from a hostname.""" + return re.sub(r"[^0-9A-Za-z]", "_", str(name)) + + +def render_power(rack: str, items: list[dict]) -> str: + """Return a mermaid power-distribution flowchart, or '' if no feeds.""" + powered = [fm for fm in items if fm.get("power")] + if not powered: + return "" + pdus = _pdu_index(items) + + edges: list[tuple[str, int, str]] = [] + for fm in powered: + device = fm.get("hostname", "?") + for feed in fm["power"]: + edges.append((feed["pdu"], feed["outlet"], device)) + edges.sort() + + lines: list[str] = ["```mermaid", "flowchart LR"] + for pdu in sorted(pdus): + outlets = pdus[pdu].get("outlets") + lines.append(f' {_node_id(pdu)}["{pdu}
{outlets} outlets"]') + devices = sorted( + powered, + key=lambda i: ( + i.get("rack_u", 0) if isinstance(i.get("rack_u"), int) else 0, + i.get("hostname", ""), + ), + ) + for fm in devices: + device = fm.get("hostname", "?") + lines.append(f' {_node_id(device)}["{device}"]') + for pdu, outlet, device in edges: + lines.append( + f" {_node_id(pdu)} -->|outlet {outlet}| {_node_id(device)}" + ) + lines.append("```") + return "\n".join(lines) + "\n" + + def render_page(rack: str, items: list[dict]) -> str: items = _sorted_items(items) lines: list[str] = [] @@ -307,6 +348,12 @@ def render_page(rack: str, items: list[dict]) -> str: lines.append("") lines.append(f"![Rack {rack} elevation]({rack}-elevation.svg)") lines.append("") + power = render_power(rack, items) + if power: + lines.append("## Power") + lines.append("") + lines.append(power.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 875c3a4..6ecd93c 100644 --- a/tests/test_gen_rack.py +++ b/tests/test_gen_rack.py @@ -275,3 +275,70 @@ def test_generate_returns_1_on_bad_power_ref(tmp_path): rc = gen_rack.generate(hw, out) assert rc == 1 assert not (out / "rack01.md").exists() + + +def test_render_power_has_nodes_and_edge_labels(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 3}]), + ] + out = gen_rack.render_power("rack01", items) + assert "```mermaid" in out + assert "flowchart LR" in out + assert "pdu01" in out + assert "8 outlets" in out + assert "outlet 3" in out + assert "mf00" in out + + +def test_render_power_redundant_device_has_two_edges(): + items = [ + item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8), + item(hostname="pdu02", kind="pdu", rack_face="right", outlets=8), + item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 1}, + {"pdu": "pdu02", "outlet": 1}]), + ] + out = gen_rack.render_power("rack01", items) + assert out.count("-->|outlet") == 2 + + +def test_render_power_empty_when_no_feeds(): + items = [item(hostname="mf00", rack_u=1, u_height=1, rack_face="front")] + assert gen_rack.render_power("rack01", items) == "" + + +def test_render_power_is_deterministic(): + a = item(hostname="pdu01", kind="pdu", rack_face="left", outlets=8) + b = item(hostname="mf01", rack_u=2, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 2}]) + c = item(hostname="mf00", rack_u=1, u_height=1, rack_face="front", + power=[{"pdu": "pdu01", "outlet": 1}]) + assert gen_rack.render_power("rack01", [a, b, c]) == \ + gen_rack.render_power("rack01", [c, b, a]) + + +def test_generate_includes_power_section(tmp_path): + hw = tmp_path / "hardware" + out = tmp_path / "out" + hw.mkdir() + _write_item( + hw, + "pdu01", + "---\nhostname: pdu01\nkind: pdu\nstatus: in-use\n" + "rack: rack01\nrack_face: left\noutlets: 8\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" + "power:\n - { pdu: pdu01, outlet: 1 }\n---\n", + ) + rc = gen_rack.generate(hw, out) + assert rc == 0 + page = (out / "rack01.md").read_text() + assert "## Power" in page + assert "```mermaid" in page + assert "outlet 1" in page