package topology import ( "errors" "fmt" "html" "math" "os/exec" "sort" "strconv" "strings" ) const ( svgMargin = 48.0 svgNodeWidth = 220.0 svgNodeHeight = 72.0 ) type svgNode struct { id string kind string service string address string lines []string width float64 height float64 x float64 y float64 } type svgEdge struct { source string target string host bool } const svgScript = ` 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 borderPoint(fromEl, toEl) { var from = nodeCentre(fromEl); var to = nodeCentre(toEl); var halfWidth = parseFloat(fromEl.getAttribute('data-width')) / 2; var halfHeight = parseFloat(fromEl.getAttribute('data-height')) / 2; 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 ? halfWidth / Math.abs(dx) : Infinity, Math.abs(dy) > 0.0001 ? halfHeight / 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(); ` // RenderSVG renders P4 server and topology records as a standalone SVG. func RenderSVG(title string, servers, records []map[string]string) (string, error) { return RenderSVGWithLayout(title, servers, records, "layered") } // RenderSVGWithLayout renders raw P4 topology data using the requested layout. func RenderSVGWithLayout(title string, servers, records []map[string]string, layoutName string) (string, error) { nodesByID := make(map[string]*svgNode) addresses := make(map[string]string) addNode := func(id, service, address string) *svgNode { if id == "" { return nil } if node, ok := nodesByID[id]; ok { if node.service == "" { node.service = service } if node.address == "" { node.address = address } return node } node := &svgNode{id: id, service: service, address: address} nodesByID[id] = node return node } for _, record := range servers { id := record["ServerID"] address := firstValue(record, "Address", "ServerAddress") addNode(id, firstValue(record, "Services", "Service"), address) if address != "" { addresses[address] = id } } edgeSet := make(map[svgEdge]struct{}) for _, record := range records { source := record["ServerID"] if source == "" { continue } addNode(source, record["Type"], record["ServerAddress"]) target := record["TargetServerID"] if target == "" { target = addresses[record["TargetAddress"]] } if target == "" || target == source { continue } addNode(target, "", record["TargetAddress"]) edgeSet[svgEdge{source: source, target: target}] = struct{}{} } nodes := make([]*svgNode, 0, len(nodesByID)) for _, node := range nodesByID { nodes = append(nodes, node) } sort.Slice(nodes, func(i, j int) bool { return nodes[i].id < nodes[j].id }) edges := make([]svgEdge, 0, len(edgeSet)) for edge := range edgeSet { edges = append(edges, edge) } sort.Slice(edges, func(i, j int) bool { if edges[i].source == edges[j].source { return edges[i].target < edges[j].target } return edges[i].source < edges[j].source }) if layoutName == "sfdp" { if err := layoutWithSFDP(nodes, edges); err != nil { return "", err } } else { layoutRawNodes(nodes) } return renderSVG(title, nodes, edges), nil } // RenderNormalizedSVG renders the enriched servers section of a saved // topology JSON file with the same P4 and host nodes as the Python renderer. func RenderNormalizedSVG(title string, servers []Server) (string, error) { return RenderNormalizedSVGWithLayout(title, servers, "layered") } // RenderNormalizedSVGWithLayout renders normalized topology data using the // requested layout backend. func RenderNormalizedSVGWithLayout(title string, servers []Server, layoutName string) (string, error) { byID := make(map[string]Server, len(servers)) for _, server := range servers { if server.ServerID != "" { byID[server.ServerID] = server } } nodes := make([]*svgNode, 0, len(byID)*2) nodesByID := make(map[string]*svgNode, len(byID)*2) for _, server := range servers { if server.ServerID == "" { continue } lines := []string{"p4server: " + server.ServerID, "type: " + valueOr(server.ServerType, "unknown"), "port: " + valueOr(server.Port, "-")} if server.Target != "" { lines = append(lines, "target: "+server.Target) } if server.Version != "" { lines = append(lines, "version: "+server.Version) } for _, note := range server.Notes { lines = append(lines, "note: "+note) } node := &svgNode{id: server.ServerID, kind: "p4", service: server.ServerType, lines: lines, width: 330, height: 26 + float64(len(lines))*19} nodes = append(nodes, node) nodesByID[node.id] = node if server.HostTarget == "" { hostLines := []string{"host server", "address: " + valueOr(server.Host, "unknown"), "ip: " + valueOr(server.IP, "unknown")} host := &svgNode{id: "host-" + server.ServerID, kind: "host", lines: hostLines, width: 250, height: 83} nodes = append(nodes, host) nodesByID[host.id] = host } } edges := make([]svgEdge, 0, len(servers)*2) for _, server := range servers { if server.ServerID == "" { continue } hostID := "host-" + valueOr(server.HostTarget, server.ServerID) if _, ok := nodesByID[hostID]; ok { edges = append(edges, svgEdge{source: server.ServerID, target: hostID, host: true}) } if server.Target != "" { if _, ok := nodesByID[server.Target]; ok { edges = append(edges, svgEdge{source: server.ServerID, target: server.Target}) } } } if layoutName == "sfdp" { if err := layoutWithSFDP(nodes, edges); err != nil { return "", err } } else { layoutNormalizedNodes(nodes, servers, byID) } return renderSVG(title, nodes, edges), nil } func layoutNormalizedNodes(nodes []*svgNode, servers []Server, byID map[string]Server) { depths := make(map[string]int, len(byID)) visiting := make(map[string]bool, len(byID)) var depthFor func(string, map[string]bool) int depthFor = func(id string, seen map[string]bool) int { if depth, ok := depths[id]; ok { return depth } server, ok := byID[id] if !ok || server.Target == "" || seen[id] || visiting[id] { depths[id] = 0 return 0 } visiting[id] = true seen[id] = true depths[id] = depthFor(server.Target, seen) + 1 delete(seen, id) delete(visiting, id) return depths[id] } layers := make(map[int][]Server) maxDepth := 0 for _, server := range servers { if server.ServerID != "" { depth := depthFor(server.ServerID, make(map[string]bool)) layers[depth] = append(layers[depth], server) maxDepth = max(maxDepth, depth) } } components := layoutComponents(servers, byID) children := make(map[string][]string) for _, server := range servers { if _, ok := byID[server.Target]; ok { children[server.Target] = append(children[server.Target], server.ServerID) } } for depth := 0; depth <= maxDepth; depth++ { sortServers(layers[depth], nil, components) } positions := make(map[string]float64, len(byID)) setPositions := func() { for depth := 0; depth <= maxDepth; depth++ { for index, server := range layers[depth] { positions[server.ServerID] = float64(index) } } } for sweep := 0; sweep < 4; sweep++ { setPositions() for depth := 1; depth <= maxDepth; depth++ { sortServers(layers[depth], func(server Server) []float64 { if _, ok := positions[server.Target]; ok { return []float64{positions[server.Target]} } return nil }, components) } setPositions() for depth := maxDepth - 1; depth >= 0; depth-- { sortServers(layers[depth], func(server Server) []float64 { values := make([]float64, 0, len(children[server.ServerID])) for _, child := range children[server.ServerID] { values = append(values, positions[child]) } return values }, components) } } byNodeID := make(map[string]*svgNode, len(nodes)) for _, node := range nodes { byNodeID[node.id] = node } rowHeight := 250.0 for _, node := range nodes { if node.kind == "p4" { _, height := dimensions(node) hostHeight := 0.0 if host := byNodeID["host-"+node.id]; host != nil { _, hostHeight = dimensions(host) } rowHeight = math.Max(rowHeight, height+45+hostHeight+80) } } y := 0.0 for depth := maxDepth; depth >= 0; depth-- { layer := layers[depth] columns := min(8, len(layer)) for start := 0; start < len(layer); start += columns { end := min(start+columns, len(layer)) row := layer[start:end] rowWidth := -70.0 for _, server := range row { width, _ := dimensions(byNodeID[server.ServerID]) rowWidth += width + 70 } x := -rowWidth / 2 for _, server := range row { node := byNodeID[server.ServerID] width, height := dimensions(node) node.x = x + width/2 node.y = y x += width + 70 if host := byNodeID["host-"+server.ServerID]; host != nil { _, hostHeight := dimensions(host) host.x = node.x host.y = node.y + height/2 + 45 + hostHeight/2 } } y += rowHeight } y += 80 } } func layoutComponents(servers []Server, byID map[string]Server) map[string]string { components := make(map[string]string, len(servers)) var componentFor func(string, map[string]bool) string componentFor = func(id string, seen map[string]bool) string { if component, ok := components[id]; ok { return component } server, ok := byID[id] if !ok || server.Target == "" || seen[id] { components[id] = id return id } seen[id] = true components[id] = componentFor(server.Target, seen) delete(seen, id) return components[id] } for _, server := range servers { componentFor(server.ServerID, make(map[string]bool)) } return components } func sortServers(servers []Server, neighbors func(Server) []float64, components map[string]string) { sort.SliceStable(servers, func(i, j int) bool { left, right := servers[i], servers[j] if neighbors != nil { leftValues, rightValues := neighbors(left), neighbors(right) if len(leftValues) > 0 && len(rightValues) > 0 { leftMedian, rightMedian := median(leftValues), median(rightValues) if leftMedian != rightMedian { return leftMedian < rightMedian } } else if len(leftValues) > 0 || len(rightValues) > 0 { return len(leftValues) > 0 } } if components[left.ServerID] != components[right.ServerID] { return components[left.ServerID] < components[right.ServerID] } return left.ServerID < right.ServerID }) } func median(values []float64) float64 { sorted := append([]float64(nil), values...) sort.Float64s(sorted) return sorted[len(sorted)/2] } func firstValue(record map[string]string, keys ...string) string { for _, key := range keys { if value := strings.TrimSpace(record[key]); value != "" { return value } } return "" } func layoutRawNodes(nodes []*svgNode) { columns := max(1, int(math.Ceil(math.Sqrt(float64(len(nodes)))))) for index, node := range nodes { width, height := dimensions(node) column := index % columns row := index / columns node.x = svgMargin + width/2 + float64(column)*(svgNodeWidth+70) node.y = svgMargin + height/2 + float64(row)*(svgNodeHeight+70) } } func layoutWithSFDP(nodes []*svgNode, edges []svgEdge) error { if len(nodes) == 0 { return nil } var dot strings.Builder dot.WriteString("digraph topology {\n graph [overlap=prism, sep=\"+40,40\"];\n node [shape=box, fixedsize=true];\n") nodeNames := make(map[string]string, len(nodes)) byName := make(map[string]*svgNode, len(nodes)) for index, node := range nodes { name := fmt.Sprintf("n%d", index) width, height := dimensions(node) nodeNames[node.id] = name byName[name] = node fmt.Fprintf(&dot, " %s [width=%.3f, height=%.3f];\n", name, width/72, height/72) } for _, edge := range edges { if source, ok := nodeNames[edge.source]; ok { if target, ok := nodeNames[edge.target]; ok { fmt.Fprintf(&dot, " %s -> %s;\n", source, target) } } } dot.WriteString("}\n") command := exec.Command("sfdp", "-Tplain") command.Stdin = strings.NewReader(dot.String()) output, err := command.CombinedOutput() if err != nil { if errors.Is(err, exec.ErrNotFound) { return fmt.Errorf("Graphviz sfdp is not available on PATH; install Graphviz or use --topology-layout=layered") } return fmt.Errorf("run Graphviz sfdp: %w: %s", err, strings.TrimSpace(string(output))) } positions := make(map[string]struct{ x, y float64 }, len(nodes)) maxY := math.Inf(-1) for _, line := range strings.Split(string(output), "\n") { fields := strings.Fields(line) if len(fields) < 4 || fields[0] != "node" { continue } x, xErr := strconv.ParseFloat(fields[2], 64) y, yErr := strconv.ParseFloat(fields[3], 64) if xErr != nil || yErr != nil { return fmt.Errorf("parse Graphviz sfdp node position") } if _, ok := byName[fields[1]]; !ok { continue } positions[fields[1]] = struct{ x, y float64 }{x: x * 72, y: y * 72} maxY = math.Max(maxY, y*72) } if len(positions) != len(nodes) { return fmt.Errorf("Graphviz sfdp did not position every node") } for name, position := range positions { node := byName[name] node.x = position.x node.y = maxY - position.y } return nil } func renderSVG(title string, nodes []*svgNode, edges []svgEdge) string { width, height := fitCanvas(nodes) byID := make(map[string]*svgNode, len(nodes)) for _, node := range nodes { byID[node.id] = node } var svg strings.Builder fmt.Fprintf(&svg, "\n\n", width, height, width, height) fmt.Fprintf(&svg, " %s\n", html.EscapeString(title)) svg.WriteString(" \n") svg.WriteString(" \n") svg.WriteString(" \n") for _, edge := range edges { from, to := byID[edge.source], byID[edge.target] x1, y1 := borderPoint(from, to) x2, y2 := borderPoint(to, from) cssClass := "edge" if edge.host { cssClass = "edge host-edge" } fmt.Fprintf(&svg, " \n", cssClass, html.EscapeString(edge.source), html.EscapeString(edge.target), x1, y1, x2, y2) } for _, node := range nodes { lines := nodeText(node) nodeWidth, nodeHeight := dimensions(node) fmt.Fprintf(&svg, " ", node.kind, html.EscapeString(node.id), nodeWidth, nodeHeight, node.x, node.y, -nodeWidth/2, -nodeHeight/2, nodeWidth, nodeHeight, nodeFill(node)) for index, line := range lines { class := "" if index == 0 { class = " class=\"heading\"" } fmt.Fprintf(&svg, "%s", class, -nodeWidth/2+14, -nodeHeight/2+26+float64(index)*19, html.EscapeString(line)) } svg.WriteString("\n") } svg.WriteString(" Save layout\n") svg.WriteString(" Drag to move. Shift-click to select several, then drag together.\n") svg.WriteString(" \n") svg.WriteString("\n") return svg.String() } func nodeText(node *svgNode) []string { if len(node.lines) > 0 { return node.lines } lines := []string{node.id} if node.service != "" { return append(lines, node.service) } if node.address != "" { return append(lines, node.address) } return lines } func dimensions(node *svgNode) (float64, float64) { width, height := node.width, node.height if width == 0 { width = svgNodeWidth } if height == 0 { height = svgNodeHeight } return width, height } func nodeFill(node *svgNode) string { if node.kind == "host" { return "#ffffff" } service := strings.ToLower(node.service) switch { case strings.Contains(service, "commit"): return "#d7f0ff" case strings.Contains(service, "edge"): return "#dff7df" case strings.Contains(service, "standby"), strings.Contains(service, "replica"): return "#fff1cc" case strings.Contains(service, "broker"): return "#ffe0cc" case strings.Contains(service, "proxy"): return "#f4ddff" default: return "#d9eaf7" } } func fitCanvas(nodes []*svgNode) (float64, float64) { if len(nodes) == 0 { return 760, 520 } minX, maxX := math.Inf(1), math.Inf(-1) minY, maxY := math.Inf(1), math.Inf(-1) for _, node := range nodes { width, height := dimensions(node) minX, maxX = math.Min(minX, node.x-width/2), math.Max(maxX, node.x+width/2) minY, maxY = math.Min(minY, node.y-height/2), math.Max(maxY, node.y+height/2) } for _, node := range nodes { node.x += svgMargin - minX node.y += svgMargin - minY } return math.Max(760, maxX-minX+2*svgMargin), math.Max(520, maxY-minY+2*svgMargin) } func borderPoint(source, target *svgNode) (float64, float64) { dx, dy := target.x-source.x, target.y-source.y if dx == 0 && dy == 0 { return source.x, source.y } width, height := dimensions(source) scale := math.Min(width/2/math.Abs(dx), height/2/math.Abs(dy)) if math.IsInf(scale, 0) { if dx == 0 { scale = height / 2 / math.Abs(dy) } else { scale = width / 2 / math.Abs(dx) } } return source.x + dx*scale, source.y + dy*scale }