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, "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<svg xmlns=\"http://www.w3.org/2000/svg\" width=\"%.0f\" height=\"%.0f\" viewBox=\"0 0 %.0f %.0f\" data-filename=\"p4_topology.svg\">\n", width, height, width, height)
fmt.Fprintf(&svg, " <title>%s</title>\n", html.EscapeString(title))
svg.WriteString(" <defs><marker id=\"arrow\" viewBox=\"0 -5 10 10\" refX=\"9\" refY=\"0\" markerWidth=\"7\" markerHeight=\"7\" orient=\"auto\"><path d=\"M0,-5L10,0L0,5\" fill=\"#52616b\"/></marker></defs>\n")
svg.WriteString(" <style>.edge{stroke:#52616b;stroke-width:1.6;marker-end:url(#arrow)}.edge.host-edge{stroke:#9aa7b1;stroke-dasharray:5 4;marker-end:none}.node{cursor:move}.node rect{stroke:#31424d;stroke-width:2;rx:6}.host rect{stroke:#66737c}.node text{font-family:sans-serif;fill:#17212b;font-size:13px;pointer-events:none}.node .heading{font-weight:bold}.node.dragging rect{stroke-dasharray:5 3}.node.selected rect{stroke-width:4}#save-button{cursor:pointer}#save-button rect{fill:#17212b}#save-button text{fill:#fff;font-family:sans-serif;font-size:13px}#hint{font-family:sans-serif;font-size:12px;fill:#52616b}</style>\n")
svg.WriteString(" <rect width=\"100%\" height=\"100%\" fill=\"#f4f7f9\"/>\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, " <line class=\"%s\" data-source=\"%s\" data-target=\"%s\" x1=\"%.1f\" y1=\"%.1f\" x2=\"%.1f\" y2=\"%.1f\"/>\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, " <g class=\"node %s\" data-id=\"%s\" data-width=\"%.0f\" data-height=\"%.0f\" transform=\"translate(%.1f %.1f)\"><rect x=\"%.0f\" y=\"%.0f\" width=\"%.0f\" height=\"%.0f\" fill=\"%s\"/>", 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, "<text%s x=\"%.0f\" y=\"%.0f\">%s</text>", class, -nodeWidth/2+14, -nodeHeight/2+26+float64(index)*19, html.EscapeString(line))
}
svg.WriteString("</g>\n")
}
svg.WriteString(" <g id=\"save-button\" transform=\"translate(20,20)\"><rect width=\"120\" height=\"30\" rx=\"6\"/><text x=\"20\" y=\"20\">Save layout</text></g>\n")
svg.WriteString(" <text id=\"hint\" x=\"152\" y=\"40\">Drag to move. Shift-click to select several, then drag together.</text>\n")
svg.WriteString(" <script type=\"application/ecmascript\"><![CDATA[")
svg.WriteString(svgScript)
svg.WriteString("]]></script>\n")
svg.WriteString("</svg>\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
}