"""aci_sim.graph — self-contained SVG/HTML topology diagram renderer. Renders the BUILT fabric (spines, leaves, border-leaves, controllers, the spine<->leaf cabling mesh, vPC pairs, and — for multi-site topologies — the ISN cloud) as a single, dependency-free SVG. No CDN, no server, no npm: the output is plain hand-generated SVG markup, optionally wrapped in a minimal `...` shell. This is a STANDALONE Python reimplementation of the visual language used by autoACI's Vue+cytoscape topology view (frontend/src/composables/ useTopologyStyles.js + backend/routers/topology.py) — same role->color palette and tiered spine/leaf/border-leaf/controller hierarchy with an ISN cloud between sites — but it imports nothing from autoACI and does not touch this repo's schema or builders. It only reads the already-validated `Topology` model (topology/schema.py) and re-derives the same spine<->leaf mesh build/cabling.py's `cabling_links()` produces. """ from __future__ import annotations from dataclasses import dataclass, field from xml.sax.saxutils import escape from aci_sim.build.cabling import cabling_links from aci_sim.topology.schema import Site, Topology # --------------------------------------------------------------------------- # Role -> color palette, matched to autoACI's useTopologyStyles.js # --------------------------------------------------------------------------- ROLE_COLORS: dict[str, tuple[str, str]] = { # role: (fill, border) — matches autoACI's cytoscape stylesheet exactly. "spine": ("#2563eb", "#1d4ed8"), "leaf": ("#16a34a", "#15803d"), "border-leaf": ("#16a34a", "#15803d"), # same green family as leaf in autoACI (role="leaf") "controller": ("#d97706", "#b45309"), "isn": ("#475569", "#334155"), } LEGEND_ORDER = ["spine", "leaf", "border-leaf", "controller", "isn"] LEGEND_LABELS = { "spine": "Spine", "leaf": "Leaf", "border-leaf": "Border Leaf", "controller": "Controller (APIC)", "isn": "ISN Cloud", } FONT = "Inter, -apple-system, 'Segoe UI', system-ui, sans-serif" NODE_W = 96 NODE_H = 40 H_GAP = 130 # horizontal spacing between nodes in the same tier V_GAP = 130 # vertical spacing between tiers SITE_GAP = 220 # extra horizontal gap between sites MARGIN = 60 LEGEND_H = 46 TITLE_H = 56 @dataclass class GNode: id: str label: str role: str site: str | None x: float = 0.0 y: float = 0.0 @dataclass class GLink: source: str target: str kind: str = "cabling" # cabling | isn | vpc @dataclass class Graph: nodes: list[GNode] = field(default_factory=list) links: list[GLink] = field(default_factory=list) fabric_name: str = "" site_count: int = 0 def node_count(self) -> int: return len(self.nodes) # --------------------------------------------------------------------------- # Graph construction (pure data — no rendering here) # --------------------------------------------------------------------------- def _site_nodes(site: Site) -> tuple[list[GNode], list[GNode], list[GNode], list[GNode]]: """Return (spines, leaves, border_leaves, controllers) as GNode lists for a site.""" spines = [ GNode(id=f"n{n.id}", label=f"{n.name}\n({n.id})", role="spine", site=site.name) for n in site.spine_nodes() ] leaves = [ GNode(id=f"n{n.id}", label=f"{n.name}\n({n.id})", role="leaf", site=site.name) for n in site.leaf_nodes() ] border_leaves = [ GNode(id=f"n{bl.id}", label=f"{bl.name}\n({bl.id})", role="border-leaf", site=site.name) for bl in site.border_leaves ] controllers = [ GNode( id=f"ctrl-{site.name}-{i + 1}", label=f"APIC{i + 1}\n{site.name}", role="controller", site=site.name, ) for i in range(site.controllers) ] return spines, leaves, border_leaves, controllers def build_graph(topo: Topology) -> Graph: """Derive the node/link graph from a validated Topology. Node IDs use the fabric's real node id (`n{id}`) for spines/leaves/ border-leaves, so cabling links (which are keyed by real node id) join up directly; controllers get a synthetic id since they have no fabricLink. """ graph = Graph(fabric_name=topo.fabric.name, site_count=len(topo.sites)) isn_enabled = topo.isn.enabled and len(topo.sites) > 1 isn_node = GNode(id="isn-cloud", label="ISN", role="isn", site=None) if isn_enabled else None for site in topo.sites: spines, leaves, border_leaves, controllers = _site_nodes(site) graph.nodes.extend(spines + leaves + border_leaves + controllers) # Spine<->leaf/border-leaf cabling mesh — reuse the exact same # derivation build/cabling.py's fabricLink builder uses, so the # diagram always matches the built fabric's real cabling graph. for n1, _s1, _p1, n2, _s2, _p2 in cabling_links(site): graph.links.append(GLink(source=f"n{n1}", target=f"n{n2}", kind="cabling")) # vPC pairs: border leaves sharing vpc_domain get a dashed peer-link # for visual grouping (mirrors autoACI's edge.vpc-link). by_domain: dict[str, list[int]] = {} for bl in site.border_leaves: by_domain.setdefault(bl.vpc_domain, []).append(bl.id) for ids in by_domain.values(): for i in range(len(ids)): for j in range(i + 1, len(ids)): graph.links.append(GLink(source=f"n{ids[i]}", target=f"n{ids[j]}", kind="vpc")) # Spine -> ISN cloud uplinks (autoACI: role="isn" node with spine # source links, backend/routers/topology.py isn_connections). if isn_node is not None: for spine in spines: graph.links.append(GLink(source=spine.id, target=isn_node.id, kind="isn")) if isn_node is not None: graph.nodes.append(isn_node) _layout(graph, topo) return graph def _layout(graph: Graph, topo: Topology) -> None: """Hand-computed tiered ACI hierarchy layout (mirrors autoACI's preset layout). Tiers (top to bottom): ISN cloud (multi-site only) -> spine -> leaf -> border-leaf -> controller. Sites are spread side by side; each tier's nodes are centered within their site's horizontal band. """ site_names = [s.name for s in topo.sites] multi_site = len(site_names) > 1 by_site: dict[str, dict[str, list[GNode]]] = { name: {"spine": [], "leaf": [], "border-leaf": [], "controller": []} for name in site_names } isn_node = None for n in graph.nodes: if n.role == "isn": isn_node = n continue by_site[n.site][n.role].append(n) def place_row(nodes: list[GNode], site_x: float, y: float) -> None: if not nodes: return width = (len(nodes) - 1) * H_GAP start_x = site_x - width / 2 for i, n in enumerate(nodes): n.x = start_x + i * H_GAP n.y = y y_isn = 0.0 y_spine = V_GAP * 1 if not multi_site else V_GAP * 1.6 y_leaf = y_spine + V_GAP y_border = y_leaf + V_GAP y_ctrl = y_border + V_GAP for idx, name in enumerate(site_names): site_x = idx * SITE_GAP + idx * H_GAP * 2 # extra spread accounts for wide tiers tiers = by_site[name] # Recompute site_x based on the widest tier so sites don't overlap. place_row(tiers["spine"], site_x, y_spine) place_row(tiers["leaf"], site_x, y_leaf) place_row(tiers["border-leaf"], site_x, y_border) place_row(tiers["controller"], site_x, y_ctrl) # Second pass: re-center each site block using the actual widest tier so # multi-site layouts don't visually collide when leaf counts differ. site_extents: dict[str, tuple[float, float]] = {} for name in site_names: tiers = by_site[name] xs = [n.x for row in tiers.values() for n in row] if xs: site_extents[name] = (min(xs) - NODE_W, max(xs) + NODE_W) else: site_extents[name] = (0.0, 0.0) cursor = 0.0 for name in site_names: lo, hi = site_extents[name] shift = cursor - lo for row in by_site[name].values(): for n in row: n.x += shift cursor = hi + shift + SITE_GAP if isn_node is not None: all_x = [n.x for name in site_names for row in by_site[name].values() for n in row] isn_node.x = (min(all_x) + max(all_x)) / 2 if all_x else 0.0 isn_node.y = y_isn # --------------------------------------------------------------------------- # Rendering # --------------------------------------------------------------------------- def _wrap_label(label: str) -> list[str]: return label.split("\n") def _node_svg(n: GNode) -> str: fill, border = ROLE_COLORS.get(n.role, ("#64748b", "#334155")) x = n.x - NODE_W / 2 y = n.y - NODE_H / 2 lines = _wrap_label(n.label) text_parts = [] line_h = 13 start_y = n.y - (len(lines) - 1) * line_h / 2 for i, line in enumerate(lines): text_parts.append( f'{escape(line)}' ) return ( f'' f'' + "".join(text_parts) + "" ) def _link_svg(link: GLink, pos: dict[str, GNode]) -> str: a = pos.get(link.source) b = pos.get(link.target) if a is None or b is None: return "" cls = { "cabling": "link-cabling", "isn": "link-isn", "vpc": "link-vpc", }.get(link.kind, "link-cabling") return f'' def _legend_svg(x: float, y: float, roles_present: list[str]) -> str: parts = [f''] swatch = 14 gap = 150 for i, role in enumerate(roles_present): fill, border = ROLE_COLORS[role] lx = i * gap parts.append( f'' f'{escape(LEGEND_LABELS[role])}' ) parts.append("") return "".join(parts) def _svg_style() -> str: return ( "" ) def _svg_body(graph: Graph, topo: Topology) -> tuple[str, int, int]: pos = {n.id: n for n in graph.nodes} if graph.nodes: min_x = min(n.x for n in graph.nodes) - NODE_W max_x = max(n.x for n in graph.nodes) + NODE_W min_y = min(n.y for n in graph.nodes) - NODE_H max_y = max(n.y for n in graph.nodes) + NODE_H else: min_x = min_y = 0.0 max_x = max_y = 0.0 width = int(max_x - min_x + 2 * MARGIN) height = int(max_y - min_y + 2 * MARGIN + TITLE_H + LEGEND_H) # Shift everything so the drawing starts at (MARGIN, MARGIN + TITLE_H). offset_x = MARGIN - min_x offset_y = MARGIN + TITLE_H - min_y for n in graph.nodes: n.x += offset_x n.y += offset_y roles_present = [r for r in LEGEND_ORDER if any(n.role == r for n in graph.nodes)] links_svg = "".join(_link_svg(link, pos) for link in graph.links) nodes_svg = "".join(_node_svg(n) for n in graph.nodes) node_count = graph.node_count() subtitle = f"{graph.site_count} site(s), {node_count} node(s)" title_svg = ( f'{escape(graph.fabric_name)} — Topology' f'{escape(subtitle)}' ) legend_y = height - LEGEND_H + 16 legend_svg = _legend_svg(MARGIN, legend_y, roles_present) body = ( f'' + _svg_style() + f'' + title_svg + links_svg + nodes_svg + legend_svg + "" ) return body, width, height def render_topology(topo: Topology, fmt: str = "html") -> str: """Render *topo* as a self-contained SVG or HTML string. fmt="svg" -> raw `...` markup. fmt="html" -> `...` with inline CSS. No network access, no external assets — the returned string is fully self-contained and safe to open directly in any browser. """ graph = build_graph(topo) svg, width, _height = _svg_body(graph, topo) if fmt == "svg": return svg if fmt != "html": raise ValueError(f"Unsupported format {fmt!r}; expected 'svg' or 'html'") title = escape(f"{topo.fabric.name} — Topology") return ( f'{title}' "" f"{svg}" )