MermaidSerialiser.cs 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. using System.Text;
  2. namespace RackPeek.Domain.Graph.Serialisers;
  3. /// <summary>
  4. /// Renders a <see cref="Graph"/> as a Mermaid flowchart string.
  5. /// Output is deterministic (nodes/edges in insertion order) so the
  6. /// same inventory always produces the same diagram — important for
  7. /// golden-file tests and for committing rendered diagrams to docs.
  8. /// </summary>
  9. public sealed class MermaidSerialiser {
  10. // Single neutral palette for a sleek monochrome look. Resource kind is
  11. // signalled by node shape, not colour, so diagrams stay calm even with
  12. // every kind of resource mixed in.
  13. private const string _nodeFill = "#1f2937"; // gray-800
  14. private const string _nodeStroke = "#52525b"; // zinc-600
  15. private const string _nodeText = "#e5e7eb"; // gray-200
  16. private const string _edgeStroke = "#52525b"; // zinc-600
  17. private const string _groupStroke = "#3f3f46"; // zinc-700
  18. private const string _groupText = "#a1a1aa"; // zinc-400
  19. private const string _nodeClass = "rpknode";
  20. private const string _groupClass = "rpkgroup";
  21. // Mermaid node shape per resource kind. Shape choice borrows from the
  22. // network-diagram conventions used by NetBox/draw.io/UniFi: hexagons for
  23. // security boundaries, stadiums for gateways, cylinders for compute,
  24. // circles for radios, etc. Looking at the silhouette alone should hint
  25. // at the role without colour or icons.
  26. private static readonly IReadOnlyDictionary<string, Shape> _shapes =
  27. new Dictionary<string, Shape>(StringComparer.OrdinalIgnoreCase) {
  28. // Physical / topology view shapes
  29. ["Firewall"] = new("{{\"", "\"}}"), // hexagon — boundary
  30. ["Router"] = new("([\"", "\"])"), // stadium — gateway
  31. ["Switch"] = new("[[\"", "\"]]"), // subroutine — distribution
  32. ["Server"] = new("[(\"", "\")]"), // cylinder — compute / storage
  33. ["AccessPoint"] = new("((\"", "\"))"), // circle — radio
  34. ["Ups"] = new("{\"", "\"}"), // rhombus — utility
  35. ["Desktop"] = new("(\"", "\")"), // rounded rect — endpoint
  36. ["Laptop"] = new("(\"", "\")"), // rounded rect — endpoint
  37. // Logical / service view shapes (don't appear with the physical
  38. // kinds in the same diagram, so shape reuse across views is OK)
  39. ["Service"] = new("[[\"", "\"]]"), // subroutine — consumable
  40. ["Hypervisor"] = new("([\"", "\"])"), // stadium — host gateway
  41. ["Vm"] = new("(\"", "\")"), // rounded — virtual machine
  42. ["Container"] = new("{{\"", "\"}}"), // hexagon — lightweight unit
  43. ["System"] = new("[\"", "\"]") // plain rect — fallback
  44. };
  45. private static readonly Shape _fallbackShape = new("[\"", "\"]");
  46. public string Serialise(Graph graph, string direction = "TD") {
  47. var sb = new StringBuilder();
  48. // Right-angle (Manhattan) edge routing — the visual signal that says
  49. // "this is a network diagram", borrowed from every serious topology
  50. // tool. Diagonal/curved lines read as "flowchart".
  51. //
  52. // Edge-label background is made transparent so connection labels read
  53. // as floating annotations rather than chunky chips that fight with
  54. // the line and the nodes for attention.
  55. // ELK renderer + orthogonal edge routing — Mermaid's default `dagre`
  56. // layout is fine for simple flowcharts but produces awkward arrow
  57. // landings on right-angle edges. ELK (Eclipse Layout Kernel) is the
  58. // engine NetBox/yEd/draw.io rely on for clean topology routing.
  59. //
  60. // Spacing values are generous on purpose — homelab diagrams read
  61. // better with air around nodes and between subnet/host clusters.
  62. sb.AppendLine(
  63. "%%{init: {'flowchart': {'defaultRenderer': 'elk', 'curve': 'step', 'nodeSpacing': 60, 'rankSpacing': 80, 'padding': 20, 'subGraphTitleMargin': {'top': 12, 'bottom': 12}}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
  64. sb.Append("flowchart ").AppendLine(direction);
  65. EmitClassDefs(sb);
  66. Dictionary<string, string> idMap = AssignSafeIds(graph.Nodes);
  67. // Index groups & nodes for hierarchical emission.
  68. IReadOnlyList<GraphGroup> groups = graph.Groups ?? [];
  69. var childGroups = groups
  70. .GroupBy(g => g.ParentGroupId ?? string.Empty)
  71. .ToDictionary(g => g.Key, g => g.ToList());
  72. var groupsById = groups.ToDictionary(g => g.Id);
  73. HashSet<string> groupedNodeIds = new(
  74. groups.SelectMany(g => g.NodeIds), StringComparer.OrdinalIgnoreCase);
  75. // Emit top-level groups (parentGroupId == null/empty) — each recursively
  76. // contains its sub-groups and direct nodes.
  77. if (childGroups.TryGetValue(string.Empty, out List<GraphGroup>? topLevel))
  78. foreach (GraphGroup group in topLevel)
  79. EmitGroup(sb, group, childGroups, groupsById, graph.Nodes, idMap, indent: 1);
  80. // Emit any nodes that didn't fall into a group at the top level.
  81. foreach (GraphNode node in graph.Nodes) {
  82. if (groupedNodeIds.Contains(node.Id)) continue;
  83. EmitNode(sb, node, idMap, indent: 1);
  84. }
  85. if (graph.Edges.Count > 0) sb.AppendLine();
  86. foreach (GraphEdge edge in graph.Edges) {
  87. if (!idMap.TryGetValue(edge.Source, out var src) ||
  88. !idMap.TryGetValue(edge.Target, out var dst))
  89. continue;
  90. // Directional edges (runsOn, depends-on …) get an arrowhead so
  91. // the relationship reads correctly. Symmetric edges (port-to-port
  92. // physical connections) stay as plain lines.
  93. var connector = IsDirectional(edge.Kind) ? "-->" : "---";
  94. sb.Append(" ").Append(src);
  95. if (!string.IsNullOrWhiteSpace(edge.Label))
  96. sb.Append(' ').Append(connector).Append("|\"")
  97. .Append(Escape(edge.Label)).Append("\"|");
  98. else
  99. sb.Append(' ').Append(connector);
  100. sb.Append(' ').Append(dst).AppendLine();
  101. }
  102. // Dotted edges matching the dotted node borders. Labels float on top
  103. // (themeVariables.edgeLabelBackground=transparent) so the line stays
  104. // visually continuous through the label region.
  105. if (graph.Edges.Count > 0) {
  106. sb.AppendLine();
  107. sb.Append(" linkStyle default stroke:").Append(_edgeStroke)
  108. .AppendLine(",stroke-width:1.25px,stroke-dasharray:4 4,fill:none");
  109. }
  110. // Apply the group styling class to every subgraph id.
  111. foreach (GraphGroup group in groups) {
  112. sb.Append(" class ").Append(group.Id).Append(' ').Append(_groupClass).AppendLine();
  113. }
  114. return sb.ToString();
  115. }
  116. private void EmitGroup(
  117. StringBuilder sb,
  118. GraphGroup group,
  119. Dictionary<string, List<GraphGroup>> childGroups,
  120. Dictionary<string, GraphGroup> groupsById,
  121. IReadOnlyList<GraphNode> allNodes,
  122. Dictionary<string, string> idMap,
  123. int indent) {
  124. var pad = new string(' ', indent * 4);
  125. sb.Append(pad).Append("subgraph ").Append(group.Id)
  126. .Append(" [\"").Append(Escape(group.Label)).Append("\"]")
  127. .AppendLine();
  128. // Nested groups first
  129. if (childGroups.TryGetValue(group.Id, out List<GraphGroup>? children))
  130. foreach (GraphGroup child in children)
  131. EmitGroup(sb, child, childGroups, groupsById, allNodes, idMap, indent + 1);
  132. // Nodes that belong to this group directly (not via a child group)
  133. HashSet<string> nodesInChildren = new(
  134. (children ?? []).SelectMany(c => CollectAllNodeIds(c, childGroups)),
  135. StringComparer.OrdinalIgnoreCase);
  136. foreach (var nodeId in group.NodeIds) {
  137. if (nodesInChildren.Contains(nodeId)) continue;
  138. GraphNode? node = allNodes.FirstOrDefault(n =>
  139. string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase));
  140. if (node is null) continue;
  141. EmitNode(sb, node, idMap, indent + 1);
  142. }
  143. sb.Append(pad).AppendLine("end");
  144. }
  145. private static IEnumerable<string> CollectAllNodeIds(
  146. GraphGroup group,
  147. Dictionary<string, List<GraphGroup>> childGroups) {
  148. foreach (var id in group.NodeIds) yield return id;
  149. if (!childGroups.TryGetValue(group.Id, out List<GraphGroup>? children)) yield break;
  150. foreach (GraphGroup c in children)
  151. foreach (var id in CollectAllNodeIds(c, childGroups))
  152. yield return id;
  153. }
  154. private void EmitNode(StringBuilder sb, GraphNode node, Dictionary<string, string> idMap, int indent) {
  155. var safeId = idMap[node.Id];
  156. Shape shape = ResolveShape(node.Kind);
  157. var label = BuildLabel(node);
  158. sb.Append(new string(' ', indent * 4)).Append(safeId)
  159. .Append(shape.Open).Append(label).Append(shape.Close)
  160. .Append(":::").Append(_nodeClass)
  161. .AppendLine();
  162. }
  163. private static string BuildLabel(GraphNode node) {
  164. // Two-line label: resource name on top, optional subtitle below.
  165. // Each use case decides what's most useful as a subtitle (kind for
  166. // the topology view, ip[:port] for the logical view) — the serialiser
  167. // is agnostic.
  168. var name = Escape(node.Label);
  169. if (string.IsNullOrWhiteSpace(node.Subtitle)) return name;
  170. return $"{name}<br/>{Escape(node.Subtitle!)}";
  171. }
  172. private static void EmitClassDefs(StringBuilder sb) {
  173. // Dotted node borders + dotted edges (via linkStyle below) keep the
  174. // whole diagram visually quiet — solid borders feel heavier than the
  175. // information they convey.
  176. sb.Append(" classDef ").Append(_nodeClass)
  177. .Append(" fill:").Append(_nodeFill)
  178. .Append(",stroke:").Append(_nodeStroke)
  179. .Append(",color:").Append(_nodeText)
  180. .Append(",stroke-width:1px,stroke-dasharray:3 3")
  181. .AppendLine();
  182. // Group containers: dotted outline, no fill, muted title. The cluster
  183. // background/border theme variables in the init directive cover the
  184. // built-in Mermaid styling; this class adds the dashed outline.
  185. sb.Append(" classDef ").Append(_groupClass)
  186. .Append(" fill:none,stroke:").Append(_groupStroke)
  187. .Append(",color:").Append(_groupText)
  188. .Append(",stroke-width:1px,stroke-dasharray:3 3")
  189. .AppendLine();
  190. sb.AppendLine();
  191. }
  192. private static Dictionary<string, string> AssignSafeIds(IReadOnlyList<GraphNode> nodes) {
  193. // Mermaid node IDs must be a small alphabet (letters, digits, underscore).
  194. // Map resource names → deterministic safe IDs, suffixing on collision.
  195. var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
  196. var taken = new HashSet<string>(StringComparer.Ordinal);
  197. foreach (GraphNode node in nodes) {
  198. var baseId = "n_" + Slug(node.Id);
  199. var candidate = baseId;
  200. var counter = 2;
  201. while (!taken.Add(candidate)) candidate = $"{baseId}_{counter++}";
  202. result[node.Id] = candidate;
  203. }
  204. return result;
  205. }
  206. private static string Slug(string value) {
  207. var sb = new StringBuilder(value.Length);
  208. foreach (var c in value)
  209. sb.Append(char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_');
  210. return sb.Length == 0 ? "node" : sb.ToString();
  211. }
  212. private static Shape ResolveShape(string kind) =>
  213. _shapes.TryGetValue(kind, out Shape shape) ? shape : _fallbackShape;
  214. private static string Escape(string value) =>
  215. value.Replace("\\", "\\\\").Replace("\"", "\\\"");
  216. private static readonly HashSet<string> _directionalEdgeKinds = new(StringComparer.OrdinalIgnoreCase) {
  217. "runsOn",
  218. "dependsOn"
  219. };
  220. private static bool IsDirectional(string kind) =>
  221. _directionalEdgeKinds.Contains(kind);
  222. private readonly record struct Shape(string Open, string Close);
  223. }