#!/usr/bin/env python3 """Collect Perforce topology data and render a Mermaid diagram or other output formats. The script can either: 1. run the live commands needed to collect topology data (p4 servers/p4 topology), or 2. render from a previously collected JSON file. Outputs: - JSON intermediate file with raw command output and normalized records - Mermaid diagram source - Markdown file wrapping the Mermaid diagram in a fenced block - AsciiDoc block wrapping the Mermaid diagram for direct inclusion - SVG diagram with drag-and-save support - HTML page with interactive D3.js diagram If you have Graphviz installed, you can use the --layout option to generate a more compact SVG layout. If you have the fast-sugiyama python pip package installed, it will be used first for a better layout unless --layout is set to one of its various options. If neither fast-sugiyama nor Graphviz are available, a simple grid layout will be used. """ from __future__ import annotations import argparse import json import ipaddress import re import shlex import subprocess import sys from dataclasses import asdict, dataclass from datetime import datetime, timezone from pathlib import Path from typing import Iterable from xml.sax.saxutils import escape IPV4_RE = re.compile(r"\b(?:\d{1,3}\.){3}\d{1,3}\b") SERVER_LINE_RE = re.compile(r"^(?P\S+)\s+(?Pserver|proxy)\s+(?P.*)$") GRAPHVIZ_PROGRAMS = ("dot", "neato", "fdp", "sfdp", "circo", "twopi", "osage") GRAPHVIZ_FORCE_DIRECTED = {"neato", "fdp", "sfdp", "circo", "twopi"} LAYOUT_CHOICES = ("auto", "sugiyama", *GRAPHVIZ_PROGRAMS, "grid") @dataclass class CommandCapture: command: str output: object @dataclass class ServerRecord: serverid: str service: str server_type: str display_name: str | None address: str | None port: str | None host: str | None ip: str | None version: str | None target: str | None target_source: str | None host_target: str | None participates_in_topology: bool notes: list[str] def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter, ) parser.add_argument( "--input-json", type=Path, help="Use an existing JSON intermediate file instead of running live commands.", ) parser.add_argument( "--json-output", type=Path, default=Path("p4_topology_data.json"), help="Path to write the JSON intermediate file.", ) parser.add_argument( "--mermaid-output", type=Path, default=Path("p4_topology.mmd"), help="Path to write the Mermaid diagram source.", ) parser.add_argument( "--markdown-output", type=Path, default=Path("p4_topology.md"), help="Path to write the Markdown file containing the Mermaid diagram.", ) parser.add_argument( "--asciidoc-output", type=Path, default=Path("p4_topology.adoc"), help="Path to write an AsciiDoc Mermaid block.", ) parser.add_argument( "--html-output", type=Path, default=Path("p4_topology.html"), help="Path to write the interactive D3.js HTML diagram.", ) parser.add_argument( "--svg-output", type=Path, default=Path("p4_topology.svg"), help="Path to write a laid-out SVG diagram with drag-and-save support.", ) parser.add_argument( "--layout", choices=LAYOUT_CHOICES, default="auto", help="SVG layout engine (auto tries fast-sugiyama, then graphviz dot, then a simple grid).", ) parser.add_argument( "--title", default="P4 server topology", help="Title to embed in the generated Markdown output.", ) parser.add_argument( "--p4port", help="Perforce server port to use for live data collection.", ) parser.add_argument( "--p4user", help="Perforce user to use for live data collection.", ) return parser.parse_args() def run_command(command: list[str]) -> str: try: result = subprocess.run(command, check=True, capture_output=True, text=True) except FileNotFoundError as exc: raise RuntimeError(f"Command not found: {command[0]}") from exc except subprocess.CalledProcessError as exc: stderr = exc.stderr.strip() detail = f"\n{stderr}" if stderr else "" raise RuntimeError(f"Command failed: {' '.join(command)}{detail}") from exc return result.stdout.strip() def p4_value(record: dict, *names: str) -> str | None: for name in names: value = record.get(name) if value is not None and str(value).strip(): return str(value).strip() return None def parse_server_address(address: str | None) -> str | None: if not address: return None value = address if value.startswith("ssl:"): value = value[4:] if ":" not in value: return None host, _port = value.rsplit(":", 1) if not host or host.isdigit(): return None return host def parse_server_port(address: str | None) -> str | None: if not address or ":" not in address: return None return address.rsplit(":", 1)[1] def is_loopback_address(address: str | None) -> bool: host = parse_server_address(address) if not host: return False try: return ipaddress.ip_address(host).is_loopback except ValueError: return False def parse_p4_servers(output: str) -> list[ServerRecord]: records: list[ServerRecord] = [] for raw_line in output.splitlines(): line = raw_line.strip() if not line: continue match = SERVER_LINE_RE.match(line) if not match: continue serverid = match.group("serverid") service = match.group("service") before_quote = match.group("rest").split("'", 1)[0].strip() parts = before_quote.split() if len(parts) >= 2: if len(parts) == 2: address = None server_type = parts[1] else: address = parts[0] server_type = parts[1] else: address = None server_type = "unknown" host = parse_server_address(address) records.append( ServerRecord( serverid=serverid, service=service, server_type=server_type, display_name=None, address=address, port=parse_server_port(address), host=host, ip=None, version=None, target=None, target_source=None, host_target=None, participates_in_topology=False, notes=[], ) ) return records def parse_p4_server_records(output: list[dict]) -> list[ServerRecord]: records: list[ServerRecord] = [] for record in output: services = p4_value(record, "services", "Services", "service", "Service") or "unknown" if services == "git-connector": continue serverid = p4_value(record, "serverid", "serverID", "ServerID", "name", "Name") if not serverid: continue address = p4_value(record, "address", "Address", "externalAddress", "ExternalAddress") service = p4_value(record, "type", "Type") or "server" records.append( ServerRecord( serverid=serverid, service=service, server_type=services, display_name=None, address=address, port=parse_server_port(address), host=parse_server_address(address), ip=None, version=None, target=None, target_source=None, host_target=None, participates_in_topology=False, notes=[], ) ) return records def parse_nslookup_ip(output: str) -> str | None: name_seen = False candidate: str | None = None for raw_line in output.splitlines(): line = raw_line.strip() if line.startswith("Name:"): name_seen = True continue if line.startswith("Address:"): value = line.split(":", 1)[1].strip() if "#" in value: continue if IPV4_RE.fullmatch(value): if name_seen: return value candidate = value return candidate def parse_topology(output: str | list[dict], known_serverids: set[str]) -> dict[str, dict[str, str | bool | None]]: if isinstance(output, list): topology: dict[str, dict[str, str | bool]] = {} for record in output: subject = p4_value(record, "serverid", "serverID", "ServerID", "server") target = p4_value( record, "parent", "parentServer", "parentServerID", "TargetServerID", ) if subject in known_serverids: topology[subject] = { "target": target, "version": p4_value(record, "version", "Version"), "seen": True, } return topology topology: dict[str, dict[str, str | bool]] = {} for raw_line in output.splitlines(): line = raw_line.strip() if not line or line.startswith("Topology command was run on server"): continue tokens = line.split() known_tokens = [token for token in tokens if token in known_serverids] if not known_tokens: continue subject = known_tokens[0] target = known_tokens[-1] if len(known_tokens) > 1 else None topology[subject] = { "target": target, "version": None, "seen": True, } return topology def add_pseudo_topology_servers(records: list[ServerRecord], topology_output: list[dict] | str) -> None: if not isinstance(topology_output, list): return known_serverids = {record.serverid for record in records} selected_records: dict[tuple[str, str], dict] = {} for topology_record in topology_output: server_type = p4_value(topology_record, "type", "Type") if server_type not in {"broker", "proxy"}: continue address = p4_value(topology_record, "serverAddress", "ServerAddress") target_serverid = p4_value(topology_record, "targetServerID", "TargetServerID") if not address or not target_serverid or target_serverid not in known_serverids: continue key = (server_type, target_serverid) current = selected_records.get(key) if current is not None and ( is_loopback_address(address) or not is_loopback_address(p4_value(current, "serverAddress", "ServerAddress")) ): continue selected_records[key] = topology_record for (server_type, target_serverid), topology_record in selected_records.items(): address = p4_value(topology_record, "serverAddress", "ServerAddress") if not address: continue node_address = parse_server_address(address) or address safe_address = re.sub(r"[^A-Za-z0-9]+", "-", node_address).strip("-") pseudo_id = f"{server_type}-{safe_address}" if pseudo_id in known_serverids: continue records.append( ServerRecord( serverid=pseudo_id, service="pseudo", server_type=server_type, display_name=address, address=address, port=parse_server_port(address), host=parse_server_address(address), ip=parse_server_address(address) if not is_loopback_address(address) else None, version=p4_value(topology_record, "version", "Version"), target=target_serverid, target_source="topology", host_target=( target_serverid if parse_server_address(p4_value(topology_record, "targetAddress", "TargetAddress")) == "127.0.0.1" else None ), participates_in_topology=True, notes=[f"{server_type} for {target_serverid}"], ) ) def topology_own_addresses(topology_output: list[dict] | str) -> dict[str, str]: """Map serverid -> its own listening address, from topology records where ServerID is the record's own id.""" addresses: dict[str, str] = {} if not isinstance(topology_output, list): return addresses for record in topology_output: serverid = p4_value(record, "serverid", "serverID", "ServerID") address = p4_value(record, "serverAddress", "ServerAddress") if not serverid or not address or is_loopback_address(address): continue if serverid not in addresses: addresses[serverid] = address return addresses def enrich_server_records(records: list[ServerRecord], topology_output: list[dict] | str) -> None: topology_map = parse_topology(topology_output, {record.serverid for record in records}) own_addresses = topology_own_addresses(topology_output) master_host = next( (record.host for record in records if record.server_type == "commit-server"), None, ) for record in records: if not record.host: own_address = own_addresses.get(record.serverid) if own_address: record.address = record.address or own_address record.host = parse_server_address(own_address) record.port = record.port or parse_server_port(own_address) if record.host and not is_loopback_address(own_address): record.ip = record.ip or record.host master_host = master_host or ( record.host if record.server_type == "commit-server" else master_host ) topo_entry = topology_map.get(record.serverid) if topo_entry and isinstance(topo_entry.get("version"), str): record.version = topo_entry["version"] if record.server_type == "commit-server": record.target = None record.target_source = "root" continue if topo_entry: record.target = topo_entry["target"] if isinstance(topo_entry["target"], str) else None record.target_source = "topology" if record.target else None if isinstance(topo_entry.get("version"), str): record.version = topo_entry["version"] record.participates_in_topology = True elif record.server_type == "forwarding-standby" and record.host and record.host == master_host: record.target = "master" record.target_source = "inferred-from-address" record.notes.append("Configured, but not present in captured topology output") def collect_live_data(p4port: str | None = None, p4user: str | None = None) -> dict: try: from P4 import P4, P4Exception except ModuleNotFoundError as exc: raise RuntimeError("p4python is required for live data collection; install requirements.txt") from exc p4_options: list[str] = [] if p4port: p4_options.extend(["-p", p4port]) if p4user: p4_options.extend(["-u", p4user]) p4 = P4() if p4port: p4.port = p4port if p4user: p4.user = p4user p4_servers_command = ["p4", *p4_options, "servers"] topology_command = ["p4", *p4_options, "topology", "-t", "2"] try: p4.connect() p4_servers_output = p4.run("servers") records = parse_p4_server_records(p4_servers_output) topology_output = p4.run("topology", "-t", "2") except P4Exception as exc: raise RuntimeError(f"Perforce command failed: {exc}") from exc finally: if p4.connected(): p4.disconnect() add_pseudo_topology_servers(records, topology_output) nslookup_results: dict[str, CommandCapture] = {} hosts = sorted({record.host for record in records if record.host}) for host in hosts: try: output = run_command(["nslookup", host]) except RuntimeError as exc: output = str(exc) nslookup_results[host] = CommandCapture( command=f"nslookup {host}", output=output, ) ip_by_host = { host: parse_nslookup_ip(capture.output) for host, capture in nslookup_results.items() } for record in records: if record.host: record.ip = ip_by_host.get(record.host) enrich_server_records(records, topology_output) return { "collected_at": datetime.now(timezone.utc).isoformat(), "commands": { "p4_servers": asdict( CommandCapture(command=" ".join(p4_servers_command), output=json.dumps(p4_servers_output)) ), "p4_topology": asdict( CommandCapture(command=" ".join(topology_command), output=topology_output) ), "nslookup": {host: asdict(capture) for host, capture in nslookup_results.items()}, }, "servers": [asdict(record) for record in records], } def load_data(path: Path) -> dict: with path.open("r", encoding="utf-8") as handle: data = json.load(handle) commands = data.get("commands", {}) topology_capture = commands.get("p4_topology", {}) p4_topology = topology_capture.get("output") if isinstance(p4_topology, str): try: topology_capture["output"] = json.loads(p4_topology) except json.JSONDecodeError: pass if data.get("servers"): topology_output = topology_capture.get("output") if isinstance(topology_output, list): records = [ ServerRecord( serverid=server["serverid"], service=server.get("service", "server"), server_type=server.get("server_type", "unknown"), display_name=server.get("display_name"), address=server.get("address"), port=server.get("port") or parse_server_port(server.get("address")), host=server.get("host"), ip=server.get("ip"), version=server.get("version"), target=server.get("target", server.get("parent")), target_source=server.get("target_source", server.get("parent_source")), host_target=server.get("host_target"), participates_in_topology=server.get("participates_in_topology", False), notes=server.get("notes", []), ) for server in data["servers"] if server.get("service", "server") != "pseudo" ] add_pseudo_topology_servers(records, topology_output) enrich_server_records(records, topology_output) data["servers"] = [asdict(record) for record in records] return data p4_servers = commands.get("p4_servers", {}).get("output") p4_topology = topology_capture.get("output") if not p4_servers or not p4_topology: return data try: server_output = json.loads(p4_servers) if isinstance(p4_servers, str) else p4_servers topology_output = p4_topology records = parse_p4_server_records(server_output) except (TypeError, json.JSONDecodeError): return data enrich_server_records(records, topology_output) data["servers"] = [asdict(record) for record in records] return data def save_json(path: Path, payload: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as handle: json.dump(payload, handle, indent=2, sort_keys=True) handle.write("\n") def p4server_label_lines(server: dict) -> list[str]: fields = [ f"p4server: {server['serverid']}", f"type: {server['server_type']}", f"port: {server.get('port') or parse_server_port(server.get('address')) or '-'}", ] if server.get("target"): fields.append(f"target: {server['target']}") if server.get("version"): fields.append(f"version: {server['version']}") for note in server.get("notes", []): fields.append(f"note: {note}") return fields def host_label_lines(server: dict) -> list[str]: return [ "host server", f"address: {server.get('host') or 'unknown'}", f"ip: {server.get('ip') or 'unknown'}", ] def format_p4server_label(server: dict) -> str: return "
".join(p4server_label_lines(server)) def format_host_label(server: dict) -> str: return "
".join(host_label_lines(server)) def node_class(server_type: str) -> str: normalized_type = server_type.lower() if normalized_type in {"commit-server", "standard"}: return "commit" if normalized_type == "edge-server": return "edge" if normalized_type in {"forwarding-standby", "standby", "forwarding-replica", "replica"}: return "replica" if normalized_type == "proxy": return "proxy" if normalized_type == "broker": return "broker" return "commit" def iter_diagram_servers(servers: list[dict]) -> Iterable[dict]: by_id = {server["serverid"]: server for server in servers} yielded: set[str] = set() roots = [ server for server in servers if server["serverid"] == "master" or server["server_type"] == "commit-server" ] for server in sorted(roots, key=lambda item: item["serverid"]): yielded.add(server["serverid"]) yield server for server in sorted(servers, key=lambda item: item["serverid"]): if server["serverid"] in yielded: continue if server.get("target") or server["serverid"] == "master": yielded.add(server["serverid"]) yield server def render_mermaid(data: dict) -> str: servers = data["servers"] diagram_servers = list(iter_diagram_servers(servers)) lines = ["graph TD"] for server in diagram_servers: serverid = server["serverid"] lines.append(f' {serverid}["{format_p4server_label(server)}"]') if not server.get("host_target"): host_node = f"host-{serverid}" lines.append(f' {host_node}["{format_host_label(server)}"]') lines.append("") for server in diagram_servers: serverid = server["serverid"] host_node = f"host-{server.get('host_target') or serverid}" lines.append(f" {serverid} --> {host_node}") target = server.get("target") if target: lines.append(f" {serverid} --> {target}") lines.extend( [ "", " classDef commit fill:#d7f0ff,stroke:#1f5f8b,stroke-width:2px,color:#111;", " classDef edge fill:#dff7df,stroke:#2f6b2f,stroke-width:2px,color:#111;", " classDef replica fill:#fff1cc,stroke:#8a6d1d,stroke-width:2px,color:#111;", " classDef proxy fill:#f4ddff,stroke:#7b3f98,stroke-width:2px,color:#111;", " classDef broker fill:#ffe0cc,stroke:#a64b00,stroke-width:2px,color:#111;", " classDef host fill:#fff,stroke:#666,stroke-width:1px,color:#111;", "", ] ) classes: dict[str, list[str]] = {"commit": [], "edge": [], "replica": [], "proxy": [], "broker": []} for server in diagram_servers: classes[node_class(server["server_type"])].append(server["serverid"]) if not server.get("host_target"): classes.setdefault("host", []).append(f"host-{server['serverid']}") for class_name, members in classes.items(): if members: lines.append(f" class {','.join(members)} {class_name};") return "\n".join(lines) + "\n" def render_markdown(title: str, mermaid: str, data: dict) -> str: unresolved = [ server["serverid"] for server in sorted(data["servers"], key=lambda item: item["serverid"]) if server["serverid"] != "master" and server["server_type"] != "commit-server" and not server.get("target") and not server.get("participates_in_topology") ] lines = [f"# {title}", "", "```mermaid", mermaid.rstrip(), "```"] if unresolved: lines.extend( [ "", "Unplaced configured entries:", "", *[f"- {serverid}" for serverid in unresolved], ] ) return "\n".join(lines) + "\n" def render_asciidoc(title: str, mermaid: str, data: dict) -> str: unresolved = [ server["serverid"] for server in sorted(data["servers"], key=lambda item: item["serverid"]) if server["serverid"] != "master" and server["server_type"] != "commit-server" and not server.get("target") and not server.get("participates_in_topology") ] target = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") or "p4-server-topology" lines = [ f". {title}", f"[mermaid, target=\"{target}\"]", "----", mermaid.rstrip(), "----", ] if unresolved: lines.extend( [ "", "Unplaced configured entries:", "", *[f"* {serverid}" for serverid in unresolved], ] ) return "\n".join(lines) + "\n" def write_text(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as handle: handle.write(content) def render_html(title: str, data: dict) -> str: diagram_servers = list(iter_diagram_servers(data["servers"])) unresolved = [ server["serverid"] for server in sorted(data["servers"], key=lambda item: item["serverid"]) if server["serverid"] != "master" and server["server_type"] != "commit-server" and not server.get("target") and not server.get("participates_in_topology") ] diagram_data = {"servers": diagram_servers, "unresolved": unresolved} payload = json.dumps(diagram_data, separators=(",", ":")).replace(" """ + title_json + """

""" + title_json + """

Drag nodes to reposition them. Scroll to zoom, drag the background to pan.

""" NODE_COLORS = { "commit": ("#d7f0ff", "#1f5f8b"), "edge": ("#dff7df", "#2f6b2f"), "replica": ("#fff1cc", "#8a6d1d"), "proxy": ("#f4ddff", "#7b3f98"), "broker": ("#ffe0cc", "#a64b00"), } SVG_P4_WIDTH = 330 SVG_HOST_WIDTH = 250 SVG_LINE_HEIGHT = 19 SVG_BOX_PADDING = 26 SVG_COLUMN_SPACING = 400 SVG_LAYER_SPACING = 360 SVG_HOST_OFFSET = 165 SVG_NODE_GAP = 70 SVG_MARGIN = 60 SVG_SCRIPT = r""" var svgRoot = document.documentElement; var nodes = {}; var edges = []; var dragState = null; var selection = []; Array.prototype.forEach.call(svgRoot.querySelectorAll('g.node'), function (el) { nodes[el.getAttribute('data-id')] = el; }); Array.prototype.forEach.call(svgRoot.querySelectorAll('line.edge'), function (el) { edges.push(el); }); function addClass(el, name) { var current = el.getAttribute('class') || ''; if (current.split(/\s+/).indexOf(name) === -1) { el.setAttribute('class', (current + ' ' + name).trim()); } } function removeClass(el, name) { var current = el.getAttribute('class') || ''; el.setAttribute('class', current.split(/\s+/).filter(function (part) { return part && part !== name; }).join(' ')); } function clearSelection() { selection.forEach(function (el) { removeClass(el, 'selected'); }); selection = []; } function toggleSelection(el) { var index = selection.indexOf(el); if (index === -1) { selection.push(el); addClass(el, 'selected'); } else { selection.splice(index, 1); removeClass(el, 'selected'); } } function nodeCentre(el) { var match = /translate\(\s*(-?[\d.]+)[ ,]+(-?[\d.]+)\s*\)/.exec(el.getAttribute('transform') || ''); return match ? { x: parseFloat(match[1]), y: parseFloat(match[2]) } : { x: 0, y: 0 }; } function nodeHalfSize(el) { return { w: parseFloat(el.getAttribute('data-width')) / 2, h: parseFloat(el.getAttribute('data-height')) / 2 }; } function borderPoint(fromEl, toEl) { var from = nodeCentre(fromEl); var to = nodeCentre(toEl); var half = nodeHalfSize(fromEl); var dx = to.x - from.x; var dy = to.y - from.y; if (dx === 0 && dy === 0) { return from; } var scale = Math.min( Math.abs(dx) > 0.0001 ? half.w / Math.abs(dx) : Infinity, Math.abs(dy) > 0.0001 ? half.h / Math.abs(dy) : Infinity ); return { x: from.x + dx * scale, y: from.y + dy * scale }; } function refreshEdges() { edges.forEach(function (edge) { var source = nodes[edge.getAttribute('data-source')]; var target = nodes[edge.getAttribute('data-target')]; if (!source || !target) { return; } var start = borderPoint(source, target); var end = borderPoint(target, source); edge.setAttribute('x1', start.x.toFixed(1)); edge.setAttribute('y1', start.y.toFixed(1)); edge.setAttribute('x2', end.x.toFixed(1)); edge.setAttribute('y2', end.y.toFixed(1)); }); } function toUserSpace(event) { var point = svgRoot.createSVGPoint(); point.x = event.clientX; point.y = event.clientY; return point.matrixTransform(svgRoot.getScreenCTM().inverse()); } svgRoot.addEventListener('mousedown', function (event) { var group = event.target.closest ? event.target.closest('g.node') : null; if (!group) { clearSelection(); return; } if (event.shiftKey) { toggleSelection(group); event.preventDefault(); return; } if (selection.indexOf(group) === -1) { clearSelection(); } var moving = selection.length ? selection.slice() : [group]; dragState = { pointer: toUserSpace(event), items: moving.map(function (el) { var centre = nodeCentre(el); addClass(el, 'dragging'); return { el: el, x: centre.x, y: centre.y }; }) }; event.preventDefault(); }); svgRoot.addEventListener('mousemove', function (event) { if (!dragState) { return; } var pointer = toUserSpace(event); var dx = pointer.x - dragState.pointer.x; var dy = pointer.y - dragState.pointer.y; dragState.items.forEach(function (item) { item.el.setAttribute( 'transform', 'translate(' + (item.x + dx).toFixed(1) + ',' + (item.y + dy).toFixed(1) + ')' ); }); refreshEdges(); }); svgRoot.addEventListener('mouseup', function () { if (!dragState) { return; } dragState.items.forEach(function (item) { removeClass(item.el, 'dragging'); }); dragState = null; }); function saveDiagram() { clearSelection(); var fallback = svgRoot.getAttribute('data-filename') || 'p4_topology.svg'; var name = window.prompt('Save diagram as', fallback); if (name === null) { return; } name = name.trim() || fallback; if (!/\.svg$/i.test(name)) { name += '.svg'; } var markup = new XMLSerializer().serializeToString(svgRoot); var blob = new Blob([markup], { type: 'image/svg+xml;charset=utf-8' }); var url = URL.createObjectURL(blob); var link = document.createElementNS('http://www.w3.org/1999/xhtml', 'a'); link.setAttribute('href', url); link.setAttribute('download', name); svgRoot.appendChild(link); link.click(); svgRoot.removeChild(link); URL.revokeObjectURL(url); } svgRoot.querySelector('#save-button').addEventListener('click', saveDiagram); refreshEdges(); """ def svg_border_point(source: dict, target: dict) -> tuple[float, float]: dx = target["x"] - source["x"] dy = target["y"] - source["y"] if dx == 0 and dy == 0: return source["x"], source["y"] half_width = source["width"] / 2 half_height = source["height"] / 2 scale = min( half_width / abs(dx) if abs(dx) > 1e-6 else float("inf"), half_height / abs(dy) if abs(dy) > 1e-6 else float("inf"), ) return source["x"] + dx * scale, source["y"] + dy * scale def svg_layer_depths(servers: list[dict]) -> dict[str, int]: by_id = {server["serverid"]: server for server in servers} depths: dict[str, int] = {} def depth_of(serverid: str, seen: frozenset[str]) -> int: if serverid in depths: return depths[serverid] server = by_id.get(serverid) target = server.get("target") if server else None if not server or not target or target not in by_id or target in seen: depths[serverid] = 0 else: depths[serverid] = depth_of(target, seen | {serverid}) + 1 return depths[serverid] for server in servers: depth_of(server["serverid"], frozenset()) return depths def build_svg_graph(servers: list[dict]) -> tuple[list[dict], list[dict]]: nodes: list[dict] = [] nodes_by_id: dict[str, dict] = {} for server in sorted(servers, key=lambda item: item["serverid"]): lines = p4server_label_lines(server) fill, stroke = NODE_COLORS[node_class(server["server_type"])] node = { "id": server["serverid"], "kind": "p4", "lines": lines, "width": SVG_P4_WIDTH, "height": SVG_BOX_PADDING + len(lines) * SVG_LINE_HEIGHT, "fill": fill, "stroke": stroke, "x": 0.0, "y": 0.0, } nodes.append(node) nodes_by_id[node["id"]] = node if server.get("host_target"): continue host_lines = host_label_lines(server) host_node = { "id": f"host-{server['serverid']}", "kind": "host", "lines": host_lines, "width": SVG_HOST_WIDTH, "height": SVG_BOX_PADDING + len(host_lines) * SVG_LINE_HEIGHT, "fill": "#ffffff", "stroke": "#66737c", "x": 0.0, "y": 0.0, } nodes.append(host_node) nodes_by_id[host_node["id"]] = host_node edges: list[dict] = [] for server in servers: host_id = f"host-{server.get('host_target') or server['serverid']}" if host_id in nodes_by_id: edges.append({"source": server["serverid"], "target": host_id, "host": True}) if server.get("target") and server["target"] in nodes_by_id: edges.append({"source": server["serverid"], "target": server["target"], "host": False}) return nodes, edges def sugiyama_positions(nodes: list[dict], edges: list[dict]) -> dict[str, tuple[float, float]] | None: try: from fast_sugiyama import from_edges # pyright: ignore[reportMissingImports] except ModuleNotFoundError: return None if not edges: return None try: raw = from_edges([(edge["source"], edge["target"]) for edge in edges]).dot_layout().to_dict() except Exception: return None # Dummy nodes for long edges come back with non-string keys. known = {node["id"] for node in nodes} placed = {key: value for key, value in raw.items() if key in known} if len(placed) != len(known): return None widths = {node["id"]: node["width"] for node in nodes} layers: dict[float, list[tuple[float, str]]] = {} for key, (x, y) in placed.items(): layers.setdefault(y, []).append((x, key)) xs = [x for x, _ in placed.values()] span = max(xs) - min(xs) widest = max(len(row) for row in layers.values()) scale = (widest * SVG_COLUMN_SPACING) / span if span > 0 else 1.0 positions: dict[str, tuple[float, float]] = {} # Roots come back with the highest y, so invert to read top-down. for index, layer in enumerate(sorted(layers, reverse=True)): y = index * SVG_LAYER_SPACING previous_x: float | None = None previous_width = 0.0 for x, key in sorted(layers[layer]): scaled = x * scale if previous_x is not None: scaled = max(scaled, previous_x + (previous_width + widths[key]) / 2 + SVG_NODE_GAP) positions[key] = (scaled, y) previous_x, previous_width = scaled, widths[key] return positions def graphviz_positions( nodes: list[dict], edges: list[dict], program: str = "dot" ) -> dict[str, tuple[float, float]] | None: if program in GRAPHVIZ_FORCE_DIRECTED: graph_attrs = [' overlap=prism;', ' sep="+40,40";'] elif program == "osage": graph_attrs = [' nodesep=0.7;'] else: graph_attrs = [" rankdir=TB;", " nodesep=0.7;", " ranksep=0.9;"] source_lines = [ "digraph topology {", *graph_attrs, " node [shape=box, fixedsize=true];", ] for node in nodes: source_lines.append( f' "{node["id"]}" [width={node["width"] / 72:.3f}, height={node["height"] / 72:.3f}];' ) for edge in edges: source_lines.append(f' "{edge["source"]}" -> "{edge["target"]}";') source_lines.append("}") try: result = subprocess.run( [program, "-Tplain"], input="\n".join(source_lines), capture_output=True, text=True, check=True, ) except (FileNotFoundError, subprocess.CalledProcessError): return None positions: dict[str, tuple[float, float]] = {} for line in result.stdout.splitlines(): if not line.startswith("node "): continue try: parts = shlex.split(line) except ValueError: return None positions[parts[1]] = (float(parts[2]) * 72, float(parts[3]) * 72) known = {node["id"] for node in nodes} if not known.issubset(positions): return None max_y = max(positions[key][1] for key in known) return {key: (positions[key][0], max_y - positions[key][1]) for key in known} def grid_positions(nodes: list[dict], servers: list[dict]) -> dict[str, tuple[float, float]]: nodes_by_id = {node["id"]: node for node in nodes} depths = svg_layer_depths(servers) layers: dict[int, list[dict]] = {} for server in servers: layers.setdefault(depths[server["serverid"]], []).append(server) positions: dict[str, tuple[float, float]] = {} for depth in sorted(layers): row = sorted(layers[depth], key=lambda item: item["serverid"]) for index, server in enumerate(row): node = nodes_by_id[server["serverid"]] x = SVG_P4_WIDTH / 2 + index * SVG_COLUMN_SPACING y = node["height"] / 2 + depth * SVG_LAYER_SPACING positions[node["id"]] = (x, y) host_id = f"host-{server['serverid']}" if host_id in nodes_by_id: positions[host_id] = (x, y + SVG_HOST_OFFSET) return positions def layout_svg_nodes(nodes: list[dict], edges: list[dict], servers: list[dict], engine: str) -> str: if engine == "auto": candidates: tuple[str, ...] = ("sugiyama", "dot", "grid") elif engine == "grid": candidates = ("grid",) else: candidates = (engine, "grid") positions = None used = "grid" for candidate in candidates: if candidate == "sugiyama": positions = sugiyama_positions(nodes, edges) elif candidate in GRAPHVIZ_PROGRAMS: positions = graphviz_positions(nodes, edges, candidate) else: positions = grid_positions(nodes, servers) if positions is not None: used = candidate break assert positions is not None offset_x = SVG_MARGIN - min(positions[node["id"]][0] - node["width"] / 2 for node in nodes) offset_y = SVG_MARGIN - min(positions[node["id"]][1] - node["height"] / 2 for node in nodes) for node in nodes: x, y = positions[node["id"]] node["x"] = x + offset_x node["y"] = y + offset_y return used def build_svg_nodes(servers: list[dict], engine: str = "auto") -> tuple[list[dict], list[dict]]: nodes, edges = build_svg_graph(servers) layout_svg_nodes(nodes, edges, servers, engine) return nodes, edges def render_svg( title: str, data: dict, engine: str = "auto", filename: str = "p4_topology.svg" ) -> tuple[str, str]: servers = list(iter_diagram_servers(data["servers"])) nodes, edges = build_svg_graph(servers) used_engine = layout_svg_nodes(nodes, edges, servers, engine) nodes_by_id = {node["id"]: node for node in nodes} width = max((node["x"] + node["width"] / 2 for node in nodes), default=600) + SVG_MARGIN height = max((node["y"] + node["height"] / 2 for node in nodes), default=400) + SVG_MARGIN parts = [ '', f'', f" {escape(title)}", " ", ' ', " ", " ", f' ', " ", ] for edge in edges: source = nodes_by_id[edge["source"]] target = nodes_by_id[edge["target"]] x1, y1 = svg_border_point(source, target) x2, y2 = svg_border_point(target, source) css_class = "edge host-edge" if edge["host"] else "edge" parts.append( f' ' ) parts.append(" ") parts.append(' ') for node in nodes: parts.append( f' ' ) parts.append( f' ' ) text_x = -node["width"] / 2 + 14 first_y = -node["height"] / 2 + SVG_BOX_PADDING for index, line in enumerate(node["lines"]): css_class = ' class="heading"' if index == 0 else "" y = first_y + index * SVG_LINE_HEIGHT parts.append(f' {escape(line)}') parts.append(" ") parts.append(" ") parts.extend( [ ' ', ' ', ' Save layout', " ", ' Drag to move. Shift-click to select several, then drag together.', " ", "", ] ) return "\n".join(parts) + "\n", used_engine def main() -> int: args = parse_args() if args.input_json: data = load_data(args.input_json) else: data = collect_live_data(args.p4port, args.p4user) save_json(args.json_output, data) mermaid = render_mermaid(data) write_text(args.mermaid_output, mermaid) write_text(args.markdown_output, render_markdown(args.title, mermaid, data)) write_text(args.asciidoc_output, render_asciidoc(args.title, mermaid, data)) write_text(args.html_output, render_html(args.title, data)) svg_markup, svg_engine = render_svg(args.title, data, args.layout, args.svg_output.name) write_text(args.svg_output, svg_markup) if args.input_json and args.input_json != args.json_output: save_json(args.json_output, data) print(f"Wrote JSON data to {args.json_output}") print(f"Wrote Mermaid diagram to {args.mermaid_output}") print(f"Wrote Markdown wrapper to {args.markdown_output}") print(f"Wrote AsciiDoc wrapper to {args.asciidoc_output}") print(f"Wrote interactive HTML diagram to {args.html_output}") print(f"Wrote editable SVG diagram to {args.svg_output} (layout: {svg_engine})") return 0 if __name__ == "__main__": try: raise SystemExit(main()) except RuntimeError as exc: print(str(exc), file=sys.stderr) raise SystemExit(1)