Browse Source

Added mermaid diagrams

Tim Jones 2 tuần trước cách đây
mục cha
commit
a3336e32fe
32 tập tin đã thay đổi với 2167 bổ sung262 xóa
  1. 1 0
      .claude/scheduled_tasks.lock
  2. 1 1
      README.md
  3. 32 0
      RackPeek.Domain/Graph/Graph.cs
  4. 262 0
      RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs
  5. 194 0
      RackPeek.Domain/Graph/UseCases/BuildLogicalGraphUseCase.cs
  6. 79 0
      RackPeek.Domain/Graph/UseCases/BuildPhysicalTopologyUseCase.cs
  7. 1 1
      RackPeek.Domain/RpkConstants.cs
  8. 6 2
      RackPeek.Web/Components/App.razor
  9. 166 170
      RackPeek.Web/Components/Pages/Home.razor
  10. 1 1
      RackPeek/RackPeek.csproj
  11. 14 0
      Shared.Rcl/CliBootstrap.cs
  12. 22 0
      Shared.Rcl/Commands/Graph/GraphLogicalCommand.cs
  13. 25 0
      Shared.Rcl/Commands/Graph/GraphTopologyCommand.cs
  14. 86 0
      Shared.Rcl/Components/Graphs/GraphView.razor
  15. 30 2
      Shared.Rcl/Connections/PortConnectionModal.razor
  16. 228 79
      Shared.Rcl/Layout/MainLayout.razor
  17. 167 0
      Shared.Rcl/Visualise/VisualisePage.razor
  18. 1 0
      Shared.Rcl/wwwroot/js/graph/chunks/mermaid-layout-elk.esm.min/chunk-SP2CHFBE.mjs
  19. 0 0
      Shared.Rcl/wwwroot/js/graph/chunks/mermaid-layout-elk.esm.min/render-YY74OMMT.mjs
  20. 220 0
      Shared.Rcl/wwwroot/js/graph/index.js
  21. 1 0
      Shared.Rcl/wwwroot/js/graph/mermaid-layout-elk.min.mjs
  22. 0 0
      Shared.Rcl/wwwroot/js/graph/mermaid.min.js
  23. 58 0
      Shared.Rcl/wwwroot/js/uiHelpers.js
  24. 64 0
      Tests.E2e/AccessPointCardTests.cs
  25. 6 2
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  26. 8 1
      Tests.E2e/PageObjectModels/PortsPom.cs
  27. 6 0
      Tests.E2e/Tests.E2e.csproj
  28. 8 0
      Tests.E2e/xunit.runner.json
  29. 57 0
      Tests/EndToEnd/Graph/GraphTopologyCliTests.cs
  30. 15 3
      Tests/EndToEnd/Infra/YamlCliTestHost.cs
  31. 143 0
      Tests/Graph/BuildPhysicalTopologyUseCaseTests.cs
  32. 265 0
      Tests/Graph/MermaidSerialiserTests.cs

+ 1 - 0
.claude/scheduled_tasks.lock

@@ -0,0 +1 @@
+{"sessionId":"e854264c-60e0-4ad5-bb55-942375978d58","pid":72538,"procStart":"Tue Jun  9 18:39:18 2026","acquiredAt":1781095177636}

+ 1 - 1
README.md

@@ -1,6 +1,6 @@
 [![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png)
 
-![Version](https://img.shields.io/badge/Version-1.4.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
+![Version](https://img.shields.io/badge/Version-2.0.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
 [![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)
 
 RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure.

+ 32 - 0
RackPeek.Domain/Graph/Graph.cs

@@ -0,0 +1,32 @@
+namespace RackPeek.Domain.Graph;
+
+public record GraphNode(
+    string Id,
+    string Label,
+    string Kind,
+    string? Subtitle = null,
+    IReadOnlyDictionary<string, string>? Data = null);
+
+public record GraphEdge(
+    string Source,
+    string Target,
+    string? Label,
+    string Kind,
+    IReadOnlyDictionary<string, string>? Data = null);
+
+/// <summary>
+///     A labelled cluster of nodes. Used to drive Mermaid <c>subgraph</c>
+///     blocks. Groups may nest via <see cref="ParentGroupId"/>.
+/// </summary>
+public record GraphGroup(
+    string Id,
+    string Label,
+    IReadOnlyList<string> NodeIds,
+    string? ParentGroupId = null);
+
+public record Graph(
+    IReadOnlyList<GraphNode> Nodes,
+    IReadOnlyList<GraphEdge> Edges,
+    IReadOnlyList<GraphGroup>? Groups = null) {
+    public static Graph Empty { get; } = new([], [], null);
+}

+ 262 - 0
RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs

@@ -0,0 +1,262 @@
+using System.Text;
+
+namespace RackPeek.Domain.Graph.Serialisers;
+
+/// <summary>
+///     Renders a <see cref="Graph"/> as a Mermaid flowchart string.
+///     Output is deterministic (nodes/edges in insertion order) so the
+///     same inventory always produces the same diagram — important for
+///     golden-file tests and for committing rendered diagrams to docs.
+/// </summary>
+public sealed class MermaidSerialiser {
+    // Single neutral palette for a sleek monochrome look. Resource kind is
+    // signalled by node shape, not colour, so diagrams stay calm even with
+    // every kind of resource mixed in.
+    private const string _nodeFill = "#1f2937";    // gray-800
+    private const string _nodeStroke = "#52525b";  // zinc-600
+    private const string _nodeText = "#e5e7eb";    // gray-200
+    private const string _edgeStroke = "#52525b";  // zinc-600
+    private const string _groupStroke = "#3f3f46"; // zinc-700
+    private const string _groupText = "#a1a1aa";   // zinc-400
+    private const string _nodeClass = "rpknode";
+    private const string _groupClass = "rpkgroup";
+
+    // Mermaid node shape per resource kind. Shape choice borrows from the
+    // network-diagram conventions used by NetBox/draw.io/UniFi: hexagons for
+    // security boundaries, stadiums for gateways, cylinders for compute,
+    // circles for radios, etc. Looking at the silhouette alone should hint
+    // at the role without colour or icons.
+    private static readonly IReadOnlyDictionary<string, Shape> _shapes =
+        new Dictionary<string, Shape>(StringComparer.OrdinalIgnoreCase) {
+            // Physical / topology view shapes
+            ["Firewall"] = new("{{\"", "\"}}"),    // hexagon — boundary
+            ["Router"] = new("([\"", "\"])"),      // stadium — gateway
+            ["Switch"] = new("[[\"", "\"]]"),      // subroutine — distribution
+            ["Server"] = new("[(\"", "\")]"),      // cylinder — compute / storage
+            ["AccessPoint"] = new("((\"", "\"))"), // circle — radio
+            ["Ups"] = new("{\"", "\"}"),           // rhombus — utility
+            ["Desktop"] = new("(\"", "\")"),       // rounded rect — endpoint
+            ["Laptop"] = new("(\"", "\")"),        // rounded rect — endpoint
+
+            // Logical / service view shapes (don't appear with the physical
+            // kinds in the same diagram, so shape reuse across views is OK)
+            ["Service"] = new("[[\"", "\"]]"),     // subroutine — consumable
+            ["Hypervisor"] = new("([\"", "\"])"),  // stadium — host gateway
+            ["Vm"] = new("(\"", "\")"),            // rounded — virtual machine
+            ["Container"] = new("{{\"", "\"}}"),   // hexagon — lightweight unit
+            ["System"] = new("[\"", "\"]")         // plain rect — fallback
+        };
+
+    private static readonly Shape _fallbackShape = new("[\"", "\"]");
+
+    public string Serialise(Graph graph, string direction = "TD") {
+        var sb = new StringBuilder();
+
+        // Right-angle (Manhattan) edge routing — the visual signal that says
+        // "this is a network diagram", borrowed from every serious topology
+        // tool. Diagonal/curved lines read as "flowchart".
+        //
+        // Edge-label background is made transparent so connection labels read
+        // as floating annotations rather than chunky chips that fight with
+        // the line and the nodes for attention.
+        // ELK renderer + orthogonal edge routing — Mermaid's default `dagre`
+        // layout is fine for simple flowcharts but produces awkward arrow
+        // landings on right-angle edges. ELK (Eclipse Layout Kernel) is the
+        // engine NetBox/yEd/draw.io rely on for clean topology routing.
+        //
+        // Spacing values are generous on purpose — homelab diagrams read
+        // better with air around nodes and between subnet/host clusters.
+        sb.AppendLine(
+            "%%{init: {'flowchart': {'defaultRenderer': 'elk', 'curve': 'step', 'nodeSpacing': 60, 'rankSpacing': 80, 'padding': 20, 'subGraphTitleMargin': {'top': 12, 'bottom': 12}}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
+        sb.Append("flowchart ").AppendLine(direction);
+
+        EmitClassDefs(sb);
+
+        Dictionary<string, string> idMap = AssignSafeIds(graph.Nodes);
+
+        // Index groups & nodes for hierarchical emission.
+        IReadOnlyList<GraphGroup> groups = graph.Groups ?? [];
+        var childGroups = groups
+            .GroupBy(g => g.ParentGroupId ?? string.Empty)
+            .ToDictionary(g => g.Key, g => g.ToList());
+        var groupsById = groups.ToDictionary(g => g.Id);
+        HashSet<string> groupedNodeIds = new(
+            groups.SelectMany(g => g.NodeIds), StringComparer.OrdinalIgnoreCase);
+
+        // Emit top-level groups (parentGroupId == null/empty) — each recursively
+        // contains its sub-groups and direct nodes.
+        if (childGroups.TryGetValue(string.Empty, out List<GraphGroup>? topLevel))
+            foreach (GraphGroup group in topLevel)
+                EmitGroup(sb, group, childGroups, groupsById, graph.Nodes, idMap, indent: 1);
+
+        // Emit any nodes that didn't fall into a group at the top level.
+        foreach (GraphNode node in graph.Nodes) {
+            if (groupedNodeIds.Contains(node.Id)) continue;
+            EmitNode(sb, node, idMap, indent: 1);
+        }
+
+        if (graph.Edges.Count > 0) sb.AppendLine();
+
+        foreach (GraphEdge edge in graph.Edges) {
+            if (!idMap.TryGetValue(edge.Source, out var src) ||
+                !idMap.TryGetValue(edge.Target, out var dst))
+                continue;
+
+            // Directional edges (runsOn, depends-on …) get an arrowhead so
+            // the relationship reads correctly. Symmetric edges (port-to-port
+            // physical connections) stay as plain lines.
+            var connector = IsDirectional(edge.Kind) ? "-->" : "---";
+
+            sb.Append("    ").Append(src);
+            if (!string.IsNullOrWhiteSpace(edge.Label))
+                sb.Append(' ').Append(connector).Append("|\"")
+                    .Append(Escape(edge.Label)).Append("\"|");
+            else
+                sb.Append(' ').Append(connector);
+            sb.Append(' ').Append(dst).AppendLine();
+        }
+
+        // Dotted edges matching the dotted node borders. Labels float on top
+        // (themeVariables.edgeLabelBackground=transparent) so the line stays
+        // visually continuous through the label region.
+        if (graph.Edges.Count > 0) {
+            sb.AppendLine();
+            sb.Append("    linkStyle default stroke:").Append(_edgeStroke)
+                .AppendLine(",stroke-width:1.25px,stroke-dasharray:4 4,fill:none");
+        }
+
+        // Apply the group styling class to every subgraph id.
+        foreach (GraphGroup group in groups) {
+            sb.Append("    class ").Append(group.Id).Append(' ').Append(_groupClass).AppendLine();
+        }
+
+        return sb.ToString();
+    }
+
+    private void EmitGroup(
+        StringBuilder sb,
+        GraphGroup group,
+        Dictionary<string, List<GraphGroup>> childGroups,
+        Dictionary<string, GraphGroup> groupsById,
+        IReadOnlyList<GraphNode> allNodes,
+        Dictionary<string, string> idMap,
+        int indent) {
+        var pad = new string(' ', indent * 4);
+        sb.Append(pad).Append("subgraph ").Append(group.Id)
+            .Append(" [\"").Append(Escape(group.Label)).Append("\"]")
+            .AppendLine();
+
+        // Nested groups first
+        if (childGroups.TryGetValue(group.Id, out List<GraphGroup>? children))
+            foreach (GraphGroup child in children)
+                EmitGroup(sb, child, childGroups, groupsById, allNodes, idMap, indent + 1);
+
+        // Nodes that belong to this group directly (not via a child group)
+        HashSet<string> nodesInChildren = new(
+            (children ?? []).SelectMany(c => CollectAllNodeIds(c, childGroups)),
+            StringComparer.OrdinalIgnoreCase);
+
+        foreach (var nodeId in group.NodeIds) {
+            if (nodesInChildren.Contains(nodeId)) continue;
+            GraphNode? node = allNodes.FirstOrDefault(n =>
+                string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase));
+            if (node is null) continue;
+            EmitNode(sb, node, idMap, indent + 1);
+        }
+
+        sb.Append(pad).AppendLine("end");
+    }
+
+    private static IEnumerable<string> CollectAllNodeIds(
+        GraphGroup group,
+        Dictionary<string, List<GraphGroup>> childGroups) {
+        foreach (var id in group.NodeIds) yield return id;
+        if (!childGroups.TryGetValue(group.Id, out List<GraphGroup>? children)) yield break;
+        foreach (GraphGroup c in children)
+            foreach (var id in CollectAllNodeIds(c, childGroups))
+                yield return id;
+    }
+
+    private void EmitNode(StringBuilder sb, GraphNode node, Dictionary<string, string> idMap, int indent) {
+        var safeId = idMap[node.Id];
+        Shape shape = ResolveShape(node.Kind);
+        var label = BuildLabel(node);
+        sb.Append(new string(' ', indent * 4)).Append(safeId)
+            .Append(shape.Open).Append(label).Append(shape.Close)
+            .Append(":::").Append(_nodeClass)
+            .AppendLine();
+    }
+
+    private static string BuildLabel(GraphNode node) {
+        // Two-line label: resource name on top, optional subtitle below.
+        // Each use case decides what's most useful as a subtitle (kind for
+        // the topology view, ip[:port] for the logical view) — the serialiser
+        // is agnostic.
+        var name = Escape(node.Label);
+        if (string.IsNullOrWhiteSpace(node.Subtitle)) return name;
+        return $"{name}<br/>{Escape(node.Subtitle!)}";
+    }
+
+    private static void EmitClassDefs(StringBuilder sb) {
+        // Dotted node borders + dotted edges (via linkStyle below) keep the
+        // whole diagram visually quiet — solid borders feel heavier than the
+        // information they convey.
+        sb.Append("    classDef ").Append(_nodeClass)
+            .Append(" fill:").Append(_nodeFill)
+            .Append(",stroke:").Append(_nodeStroke)
+            .Append(",color:").Append(_nodeText)
+            .Append(",stroke-width:1px,stroke-dasharray:3 3")
+            .AppendLine();
+
+        // Group containers: dotted outline, no fill, muted title. The cluster
+        // background/border theme variables in the init directive cover the
+        // built-in Mermaid styling; this class adds the dashed outline.
+        sb.Append("    classDef ").Append(_groupClass)
+            .Append(" fill:none,stroke:").Append(_groupStroke)
+            .Append(",color:").Append(_groupText)
+            .Append(",stroke-width:1px,stroke-dasharray:3 3")
+            .AppendLine();
+        sb.AppendLine();
+    }
+
+    private static Dictionary<string, string> AssignSafeIds(IReadOnlyList<GraphNode> nodes) {
+        // Mermaid node IDs must be a small alphabet (letters, digits, underscore).
+        // Map resource names → deterministic safe IDs, suffixing on collision.
+        var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+        var taken = new HashSet<string>(StringComparer.Ordinal);
+
+        foreach (GraphNode node in nodes) {
+            var baseId = "n_" + Slug(node.Id);
+            var candidate = baseId;
+            var counter = 2;
+            while (!taken.Add(candidate)) candidate = $"{baseId}_{counter++}";
+            result[node.Id] = candidate;
+        }
+
+        return result;
+    }
+
+    private static string Slug(string value) {
+        var sb = new StringBuilder(value.Length);
+        foreach (var c in value)
+            sb.Append(char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_');
+
+        return sb.Length == 0 ? "node" : sb.ToString();
+    }
+
+    private static Shape ResolveShape(string kind) =>
+        _shapes.TryGetValue(kind, out Shape shape) ? shape : _fallbackShape;
+
+    private static string Escape(string value) =>
+        value.Replace("\\", "\\\\").Replace("\"", "\\\"");
+
+    private static readonly HashSet<string> _directionalEdgeKinds = new(StringComparer.OrdinalIgnoreCase) {
+        "runsOn",
+        "dependsOn"
+    };
+
+    private static bool IsDirectional(string kind) =>
+        _directionalEdgeKinds.Contains(kind);
+
+    private readonly record struct Shape(string Open, string Close);
+}

+ 194 - 0
RackPeek.Domain/Graph/UseCases/BuildLogicalGraphUseCase.cs

@@ -0,0 +1,194 @@
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Services.Networking;
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Domain.Graph.UseCases;
+
+/// <summary>
+///     Logical / service-oriented view: services and systems grouped first
+///     by IP subnet (/24), then by their ultimate parent hardware. Edges
+///     show the immediate <c>runsOn</c> dependency.
+/// </summary>
+public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase {
+    private const int _defaultPrefix = 24;
+
+    public async Task<Graph> ExecuteAsync() {
+        IReadOnlyList<Service> services = await repo.GetAllOfTypeAsync<Service>();
+        IReadOnlyList<SystemResource> systems = await repo.GetAllOfTypeAsync<SystemResource>();
+        IReadOnlyList<Hardware> hardware = repo.HardwareResources;
+
+        var byName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
+        foreach (Hardware hw in hardware) byName[hw.Name] = hw;
+        foreach (SystemResource s in systems) byName[s.Name] = s;
+        foreach (Service svc in services) byName[svc.Name] = svc;
+
+        // Classify each non-hardware resource: which subnet, which parent host,
+        // and what ip[:port] to show as the subtitle.
+        var entries = new List<Entry>();
+        foreach (Resource resource in services.Cast<Resource>().Concat(systems)) {
+            var ip = FindIp(resource, byName);
+            var subnet = SubnetCidr(ip, _defaultPrefix);
+            Hardware? parentHw = FindParentHardware(resource, byName);
+            if (subnet is null) continue; // skip orphans with no IP anywhere up the chain
+            var subtitle = BuildSubtitle(resource, ip);
+            entries.Add(new Entry(resource, subnet, parentHw?.Name, subtitle));
+        }
+
+        var nodes = entries
+            .OrderBy(e => e.Subnet, StringComparer.Ordinal)
+            .ThenBy(e => e.HardwareName ?? string.Empty, StringComparer.OrdinalIgnoreCase)
+            .ThenBy(e => e.Resource.Name, StringComparer.OrdinalIgnoreCase)
+            .Select(e => new GraphNode(
+                e.Resource.Name, e.Resource.Name, NodeKind(e.Resource), e.Subtitle))
+            .ToList();
+
+        List<GraphGroup> groups = BuildGroups(entries);
+
+        // Edges from each resource to its immediate runsOn target if both
+        // ends are nodes in the graph. We omit edges that point at hardware
+        // (hardware is the grouping label, not a node).
+        HashSet<string> nodeIds = new(nodes.Select(n => n.Id), StringComparer.OrdinalIgnoreCase);
+        List<GraphEdge> edges = new();
+        foreach (Entry entry in entries) {
+            var parentName = entry.Resource.RunsOn?.FirstOrDefault();
+            if (parentName is null) continue;
+            if (!nodeIds.Contains(parentName)) continue;
+            edges.Add(new GraphEdge(entry.Resource.Name, parentName, null, "runsOn"));
+        }
+
+        return new Graph(nodes, edges, groups);
+    }
+
+    private static List<GraphGroup> BuildGroups(IReadOnlyList<Entry> entries) {
+        var groups = new List<GraphGroup>();
+
+        IOrderedEnumerable<IGrouping<string, Entry>> bySubnet = entries
+            .GroupBy(e => e.Subnet, StringComparer.Ordinal)
+            .OrderBy(g => g.Key, StringComparer.Ordinal);
+
+        foreach (IGrouping<string, Entry> subnetGroup in bySubnet) {
+            var subnetId = "g_" + Slug(subnetGroup.Key!);
+
+            // Inner groups keyed by parent hardware. Entries with no parent
+            // hardware fall directly into the subnet group.
+            var directNodes = new List<string>();
+            IOrderedEnumerable<IGrouping<string?, Entry>> byHardware = subnetGroup
+                .GroupBy(e => e.HardwareName)
+                .OrderBy(g => g.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+
+            foreach (IGrouping<string?, Entry> hwGroup in byHardware) {
+                if (hwGroup.Key is null) {
+                    directNodes.AddRange(hwGroup.Select(e => e.Resource.Name));
+                    continue;
+                }
+
+                var hwGroupId = subnetId + "__" + Slug(hwGroup.Key);
+                groups.Add(new GraphGroup(
+                    hwGroupId,
+                    hwGroup.Key,
+                    hwGroup.Select(e => e.Resource.Name).ToList(),
+                    subnetId));
+            }
+
+            groups.Add(new GraphGroup(subnetId, subnetGroup.Key!, directNodes, null));
+        }
+
+        return groups;
+    }
+
+    private static string NodeKind(Resource resource) {
+        if (resource is Service) return "Service";
+        if (resource is SystemResource sys) {
+            if (string.IsNullOrWhiteSpace(sys.Type)) return "System";
+            // "vm" → "Vm", "hypervisor" → "Hypervisor"; we look these up in the
+            // serialiser's shape map case-insensitively, so casing doesn't
+            // matter — but a canonical form keeps test assertions tidy.
+            var t = sys.Type.Trim().ToLowerInvariant();
+            return t switch {
+                "hypervisor" => "Hypervisor",
+                "vm" => "Vm",
+                "container" => "Container",
+                _ => "System"
+            };
+        }
+
+        return resource.Kind;
+    }
+
+    private static string? FindIp(Resource resource, Dictionary<string, Resource> byName) {
+        var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        Resource? current = resource;
+        while (current is not null && visited.Add(current.Name)) {
+            switch (current) {
+                case Service { Network.Ip: { Length: > 0 } svcIp }:
+                    return svcIp;
+                case SystemResource { Ip: { Length: > 0 } sysIp }:
+                    return sysIp;
+            }
+
+            var parent = current.RunsOn.FirstOrDefault();
+            if (parent is null) return null;
+            current = byName.GetValueOrDefault(parent);
+        }
+
+        return null;
+    }
+
+    private static Hardware? FindParentHardware(Resource resource, Dictionary<string, Resource> byName) {
+        var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
+        Resource? current = resource;
+        while (current is not null && visited.Add(current.Name)) {
+            if (current is Hardware hw) return hw;
+            var parent = current.RunsOn.FirstOrDefault();
+            if (parent is null) return null;
+            current = byName.GetValueOrDefault(parent);
+        }
+
+        return null;
+    }
+
+    private static string? SubnetCidr(string? ip, int prefix) {
+        if (string.IsNullOrWhiteSpace(ip)) return null;
+        try {
+            var u = IpHelper.ToUInt32(ip);
+            var mask = IpHelper.MaskFromPrefix(prefix);
+            return $"{IpHelper.ToIp(u & mask)}/{prefix}";
+        }
+        catch {
+            return null;
+        }
+    }
+
+    private static string Slug(string value) {
+        var chars = value.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_').ToArray();
+        return new string(chars);
+    }
+
+    private static string? BuildSubtitle(Resource resource, string? ip) {
+        // Services: ip:port (port from Network.Port if present). The ip is
+        // whatever the runsOn chain resolves to — it may belong to the host,
+        // not the service itself, but that's the relevant address for users.
+        if (resource is Service service) {
+            var port = service.Network?.Port;
+            if (!string.IsNullOrWhiteSpace(ip) && port.HasValue) return $"{ip}:{port}";
+            if (!string.IsNullOrWhiteSpace(ip)) return ip;
+            return port.HasValue ? $":{port}" : null;
+        }
+
+        // Systems: just their own IP. We don't show ports for systems because
+        // the data model doesn't track listening ports at the system level.
+        if (resource is SystemResource system && !string.IsNullOrWhiteSpace(system.Ip))
+            return system.Ip;
+
+        return null;
+    }
+
+    private readonly record struct Entry(
+        Resource Resource,
+        string Subnet,
+        string? HardwareName,
+        string? Subtitle);
+}

+ 79 - 0
RackPeek.Domain/Graph/UseCases/BuildPhysicalTopologyUseCase.cs

@@ -0,0 +1,79 @@
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
+namespace RackPeek.Domain.Graph.UseCases;
+
+public class BuildPhysicalTopologyUseCase(IResourceCollection repo) : IUseCase {
+    public async Task<Graph> ExecuteAsync() {
+        IReadOnlyList<Hardware> hardware = repo.HardwareResources;
+        IReadOnlyList<Connection> connections = await repo.GetConnectionsAsync();
+
+        var nodes = hardware
+            .OrderBy(h => h.Kind, StringComparer.OrdinalIgnoreCase)
+            .ThenBy(h => h.Name, StringComparer.OrdinalIgnoreCase)
+            .Select(BuildNode)
+            .ToList();
+
+        var hardwareByName = hardware.ToDictionary(
+            h => h.Name,
+            StringComparer.OrdinalIgnoreCase);
+
+        var edges = connections
+            .Where(c => hardwareByName.ContainsKey(c.A.Resource) && hardwareByName.ContainsKey(c.B.Resource))
+            .Select(c => BuildEdge(c, hardwareByName))
+            .ToList();
+
+        return new Graph(nodes, edges);
+    }
+
+    private static GraphNode BuildNode(Hardware resource) {
+        var data = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
+        if (resource.Tags.Length > 0) data["tags"] = string.Join(",", resource.Tags);
+
+        return new GraphNode(
+            Id: resource.Name,
+            Label: resource.Name,
+            Kind: resource.Kind,
+            Subtitle: resource.Kind.ToLowerInvariant(),
+            Data: data);
+    }
+
+    private static GraphEdge BuildEdge(Connection c, Dictionary<string, Hardware> hardwareByName) {
+        var label = BuildEdgeLabel(c, hardwareByName);
+        return new GraphEdge(
+            Source: c.A.Resource,
+            Target: c.B.Resource,
+            Label: label,
+            Kind: "connection");
+    }
+
+    private static string? BuildEdgeLabel(Connection c, Dictionary<string, Hardware> hardwareByName) {
+        if (!string.IsNullOrWhiteSpace(c.Label))
+            return c.Label;
+
+        var a = PortLabel(c.A, hardwareByName);
+        var b = PortLabel(c.B, hardwareByName);
+
+        if (a is null && b is null) return null;
+        return $"{a ?? "?"} ↔ {b ?? "?"}";
+    }
+
+    private static string? PortLabel(PortReference reference, Dictionary<string, Hardware> hardwareByName) {
+        if (!hardwareByName.TryGetValue(reference.Resource, out Hardware? hardware))
+            return null;
+
+        if (hardware is not IPortResource portResource || portResource.Ports is null)
+            return null;
+
+        if (reference.PortGroup < 0 || reference.PortGroup >= portResource.Ports.Count)
+            return null;
+
+        Port group = portResource.Ports[reference.PortGroup];
+        var type = string.IsNullOrWhiteSpace(group.Type) ? "port" : group.Type;
+        return $"{type}{reference.PortIndex}";
+    }
+}

+ 1 - 1
RackPeek.Domain/RpkConstants.cs

@@ -1,7 +1,7 @@
 namespace RackPeek.Domain;
 
 public static class RpkConstants {
-    public const string Version = "v1.4.0";
+    public const string Version = "v2.0.0";
 
     public static bool HasGitServices { get; set; }
 }

+ 6 - 2
RackPeek.Web/Components/App.razor

@@ -8,8 +8,12 @@
     <ResourcePreloader/>
     <ImportMap/>
     <HeadOutlet @rendermode="InteractiveServer"/>
-    <script src="console.js"></script>
-    <script src="tailwind.js"></script>
+    <script src="console.js" defer></script>
+    <script src="tailwind.js" defer></script>
+    <script src="_content/Shared.Rcl/js/uiHelpers.js" defer></script>
+    <!-- Mermaid (~2.5 MB) is loaded lazily by graph/index.js on first render,
+         so pages without a diagram don't pay its parse/download cost. -->
+    <script src="_content/Shared.Rcl/js/graph/index.js" defer></script>
     <link href="app.css" rel="stylesheet"/>
     <link rel="icon" type="image/png" href="rackpeek_logo_32x32.png"/>
     <title>RackPeek</title>

+ 166 - 170
RackPeek.Web/Components/Pages/Home.razor

@@ -1,12 +1,17 @@
-@page "/"
+@page "/"
+@using RackPeek.Domain.Graph.Serialisers
+@using RackPeek.Domain.Graph.UseCases
 @using RackPeek.Domain.Resources
 @using RackPeek.Domain.Resources.Hardware
 @using RackPeek.Domain.Resources.Services.UseCases
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @using Shared.Rcl.Components
+@using Shared.Rcl.Components.Graphs
 @inject GetSystemSummaryUseCase SystemSummaryUseCase
 @inject GetServiceSummaryUseCase ServiceSummaryUseCase
 @inject GetHardwareUseCaseSummary HardwareSummaryUseCase
+@inject BuildPhysicalTopologyUseCase TopologyUseCase
+@inject BuildLogicalGraphUseCase LogicalUseCase
 
 <PageTitle>Home</PageTitle>
 
@@ -18,200 +23,173 @@
     }
     else
     {
-        <!-- Totals + Tools -->
-        <div class="mb-10 grid grid-cols-1 md:grid-cols-2 gap-6 max-w-4xl">
+        <!--
+            Uniform-height card grid. Every card is `h-80` regardless of content;
+            taller content scrolls inside. `grid-flow-dense` packs the wider
+            diagram cards without leaving holes.
+        -->
+        <div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 auto-rows-[20rem] grid-flow-dense gap-4">
 
             <!-- Totals -->
-            <div class="border border-zinc-800 rounded-md p-4">
-                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
-                    Totals
-                </div>
-
-                <div class="grid grid-cols-2 gap-y-2">
-
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("hardware/tree")">→ Hardware</NavLink>
-                    </div>
-                    <div class="text-right">@_hardware!.TotalHardware</div>
+            <div class="@_cardClass">
+                <div class="@_cardHeaderClass">Totals</div>
+                <div class="@_cardBodyClass">
+                    <div class="grid grid-cols-2 gap-y-2 text-sm">
+                        <div class="hover:text-emerald-300">
+                            <NavLink href="hardware/tree">→ Hardware</NavLink>
+                        </div>
+                        <div class="text-right">@_hardware!.TotalHardware</div>
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("systems/list")">→ Systems</NavLink>
-                    </div>
-                    <div class="text-right">@_system!.TotalSystems</div>
+                        <div class="hover:text-emerald-300">
+                            <NavLink href="systems/list">→ Systems</NavLink>
+                        </div>
+                        <div class="text-right">@_system!.TotalSystems</div>
 
-                    <div class="hover:text-emerald-300">
-                        <NavLink href="@("services/list")">→ Services</NavLink>
+                        <div class="hover:text-emerald-300">
+                            <NavLink href="services/list">→ Services</NavLink>
+                        </div>
+                        <div class="text-right">@_service!.TotalServices</div>
                     </div>
-                    <div class="text-right">@_service!.TotalServices</div>
                 </div>
             </div>
 
             <!-- Tools -->
-            <div class="border border-zinc-800 rounded-md p-4">
-                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
-                    Tools
+            <div class="@_cardClass">
+                <div class="@_cardHeaderClass">Tools</div>
+                <div class="@_cardBodyClass">
+                    <ul class="space-y-2 text-sm">
+                        <li><NavLink href="visualise" class="block hover:text-emerald-300" data-testid="home-tool-visualise">→ Visualise</NavLink></li>
+                        <li><NavLink href="subnets" class="block hover:text-emerald-300" data-testid="home-tool-subnets">→ Subnet Browser</NavLink></li>
+                        <li><NavLink href="cli" class="block hover:text-emerald-300" data-testid="home-tool-cli">→ CLI Emulator</NavLink></li>
+                        <li><NavLink href="yaml" class="block hover:text-emerald-300" data-testid="home-tool-yaml">→ YAML Editor</NavLink></li>
+                        <li><NavLink href="ansible/inventory" class="block hover:text-emerald-300" data-testid="home-tool-ansible">→ Ansible Inventory Generator</NavLink></li>
+                        <li><NavLink href="ssh/export" class="block hover:text-emerald-300" data-testid="home-tool-ssh">→ SSH Config Export</NavLink></li>
+                        <li><NavLink href="hosts/export" class="block hover:text-emerald-300" data-testid="home-tool-hosts">→ Hosts File Export</NavLink></li>
+                        <li><NavLink href="docs" class="block hover:text-emerald-300" data-testid="home-tool-docs">→ Documentation</NavLink></li>
+                    </ul>
                 </div>
-
-                <ul class="space-y-2 text-sm">
-
-                    <li>
-                        <NavLink href="subnets"
-                                 class="block hover:text-emerald-300"
-                                 data-testid="home-tool-subnets">
-                            → Subnet Browser
-                        </NavLink>
-                    </li>
-
-
-                    <li>
-                        <NavLink href="cli"
-                                 class="block hover:text-emerald-300"
-                                 data-testid="home-tool-cli">
-                            → CLI Emulator
-                        </NavLink>
-                    </li>
-
-                    <li>
-                        <NavLink href="yaml"
-                                 class="block hover:text-emerald-300"
-                                 data-testid="home-tool-yaml">
-                            → YAML Editor
-                        </NavLink>
-                    </li>
-
-                    <li>
-                        <NavLink href="ansible/inventory"
-                                 class="block hover:text-emerald-300"
-                                 data-testid="home-tool-ansible">
-                            → Ansible Inventory Generator
-                        </NavLink>
-                    </li>
-                    <li>
-                        <NavLink href="ssh/export"
-                                 class="block hover:text-emerald-300"
-                                 data-testid="home-tool-ansible">
-                            → SSH Config Export
-                        </NavLink>
-                    </li>
-
-                    <li>
-                        <NavLink href="hosts/export"
-                                 class="block hover:text-emerald-300"
-                                 data-testid="home-tool-ansible">
-                            → Hosts File Export
-                        </NavLink>
-                    </li>
-
-                    <li>
-                        <NavLink href="docs"
-                                 class="block hover:text-emerald-300"
-                                 data-testid="home-tool-docs">
-                            → Documentation
-                        </NavLink>
-                    </li>
-
-                </ul>
             </div>
-        </div>
-        <div class="space-y-10 mb-10">
-            <TagListComponent/>
-        </div>
 
-        <div class="space-y-10 mb-10">
-            <LabelListComponent/>
-        </div>
-        <!-- Tree -->
-        <div class="space-y-10">
-
-            <!-- Hardware -->
-            <div>
-                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
-                    Hardware
+            <!-- Physical / Hardware topology -->
+            <div class="@_cardClass sm:col-span-2 sm:row-span-2">
+                <div class="flex items-center justify-between mb-3 flex-shrink-0">
+                    <div class="text-xs text-zinc-500 uppercase tracking-wider">Physical Topology</div>
+                    <NavLink href="visualise/topology"
+                             class="text-xs text-zinc-400 hover:text-emerald-400"
+                             data-testid="home-topology-open">
+                        open →
+                    </NavLink>
+                </div>
+                <div class="flex-1 min-h-0 overflow-hidden">
+                    <GraphView Source="@_topologySource" TestId="home-topology-preview"/>
                 </div>
-
-                <ul class="space-y-2">
-                    <li class="text-zinc-100">
-                        ├─ Hardware (@_hardware!.TotalHardware)
-                    </li>
-
-                    @if (_hardware.HardwareByKind.Any())
-                    {
-                        <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
-                            @foreach (var (kind, count) in _hardware.HardwareByKind.OrderByDescending(x => x.Value))
-                            {
-                                var pluralKind = Resource.KindToPlural(kind);
-                                <NavLink href="@($"/{Uri.EscapeDataString(pluralKind)}/list")" class="block">
-                                    <li class="text-zinc-500 hover:text-emerald-300">
-                                        └─ @pluralKind (@count)
-                                    </li>
-                                </NavLink>
-                            }
-                        </ul>
-                    }
-                </ul>
             </div>
 
+            <!-- Logical / Services & Systems -->
+            <div class="@_cardClass sm:col-span-2 sm:row-span-2">
+                <div class="flex items-center justify-between mb-3 flex-shrink-0">
+                    <div class="text-xs text-zinc-500 uppercase tracking-wider">Logical View</div>
+                    <NavLink href="visualise/logical"
+                             class="text-xs text-zinc-400 hover:text-emerald-400"
+                             data-testid="home-logical-open">
+                        open →
+                    </NavLink>
+                </div>
+                <div class="flex-1 min-h-0 overflow-hidden">
+                    <GraphView Source="@_logicalSource" TestId="home-logical-preview"/>
+                </div>
+            </div>
 
-            <!-- Systems -->
-            <div>
-                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
-                    Systems
+            <!-- Tags (component renders its own internal header) -->
+            <div class="@_cardClass">
+                <div class="@_cardBodyClass">
+                    <TagListComponent/>
                 </div>
+            </div>
 
-                <ul class="space-y-3">
-                    <li>
-                        <div class="text-zinc-100">
-                            ├─ Total (@_system!.TotalSystems)
-                        </div>
+            <!-- Labels -->
+            <div class="@_cardClass">
+                <div class="@_cardBodyClass">
+                    <LabelListComponent/>
+                </div>
+            </div>
 
-                        @if (_system.SystemsByType.Any())
-                        {
-                            <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
-                                <li class="text-zinc-400">Types</li>
-                                @foreach (var (type, count) in _system.SystemsByType.OrderByDescending(x => x.Value))
-                                {
-                                    <li class="text-zinc-500 hover:text-emerald-300">
-                                        <NavLink href="@($"systems/list?type={Uri.EscapeDataString(type)}")"
-                                                 class="block">
-                                            └─ @type (@count)
-                                        </NavLink>
-                                    </li>
-                                }
-                            </ul>
-                        }
+            <!-- Hardware breakdown -->
+            <div class="@_cardClass">
+                <div class="@_cardHeaderClass">Hardware</div>
+                <div class="@_cardBodyClass">
+                    <ul class="space-y-2 text-sm">
+                        <li class="text-zinc-100">├─ Hardware (@_hardware!.TotalHardware)</li>
 
-                        @if (_system.SystemsByOs.Any())
+                        @if (_hardware.HardwareByKind.Any())
                         {
                             <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
-                                <li class="text-zinc-400">Operating Systems</li>
-                                @foreach (var (os, count) in _system.SystemsByOs.OrderByDescending(x => x.Value))
+                                @foreach (var (kind, count) in _hardware.HardwareByKind.OrderByDescending(x => x.Value))
                                 {
-                                    <li class="text-zinc-500 hover:text-emerald-300">
-                                        <NavLink href="@($"systems/list?os={Uri.EscapeDataString(os)}")" class="block">
-                                            └─ @os (@count)
-                                        </NavLink>
-                                    </li>
+                                    var pluralKind = Resource.KindToPlural(kind);
+                                    <NavLink href="@($"/{Uri.EscapeDataString(pluralKind)}/list")" class="block">
+                                        <li class="text-zinc-500 hover:text-emerald-300">
+                                            └─ @pluralKind (@count)
+                                        </li>
+                                    </NavLink>
                                 }
                             </ul>
                         }
-                    </li>
-                </ul>
+                    </ul>
+                </div>
             </div>
 
-            <!-- Services -->
-            <div>
-                <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
-                    Services
+            <!-- Systems breakdown -->
+            <div class="@_cardClass">
+                <div class="@_cardHeaderClass">Systems</div>
+                <div class="@_cardBodyClass">
+                    <ul class="space-y-3 text-sm">
+                        <li>
+                            <div class="text-zinc-100">├─ Total (@_system!.TotalSystems)</div>
+
+                            @if (_system.SystemsByType.Any())
+                            {
+                                <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
+                                    <li class="text-zinc-400">Types</li>
+                                    @foreach (var (type, count) in _system.SystemsByType.OrderByDescending(x => x.Value))
+                                    {
+                                        <li class="text-zinc-500 hover:text-emerald-300">
+                                            <NavLink href="@($"systems/list?type={Uri.EscapeDataString(type)}")" class="block">
+                                                └─ @type (@count)
+                                            </NavLink>
+                                        </li>
+                                    }
+                                </ul>
+                            }
+
+                            @if (_system.SystemsByOs.Any())
+                            {
+                                <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
+                                    <li class="text-zinc-400">Operating Systems</li>
+                                    @foreach (var (os, count) in _system.SystemsByOs.OrderByDescending(x => x.Value))
+                                    {
+                                        <li class="text-zinc-500 hover:text-emerald-300">
+                                            <NavLink href="@($"systems/list?os={Uri.EscapeDataString(os)}")" class="block">
+                                                └─ @os (@count)
+                                            </NavLink>
+                                        </li>
+                                    }
+                                </ul>
+                            }
+                        </li>
+                    </ul>
                 </div>
+            </div>
 
-                <ul>
-                    <li class="text-zinc-100">
-                        └─ Total (@_service!.TotalServices)
-                    </li>
-                    <li class="ml-4 text-zinc-500">
-                        └─ IP Addresses (@_service!.TotalIpAddresses)
-                    </li>
-                </ul>
+            <!-- Services breakdown -->
+            <div class="@_cardClass">
+                <div class="@_cardHeaderClass">Services</div>
+                <div class="@_cardBodyClass">
+                    <ul class="text-sm">
+                        <li class="text-zinc-100">├─ Total (@_service!.TotalServices)</li>
+                        <li class="ml-4 text-zinc-500">└─ IP Addresses (@_service!.TotalIpAddresses)</li>
+                    </ul>
+                </div>
             </div>
         </div>
     }
@@ -223,20 +201,38 @@
     private SystemSummary? _system;
     private AllServicesSummary? _service;
     private HardwareSummary? _hardware;
+    private string? _topologySource;
+    private string? _logicalSource;
+
+    private static readonly MermaidSerialiser _serialiser = new();
+
+    // Shared card chrome — defined once so every card looks identical.
+    // Card height is set by the parent grid's `auto-rows-[20rem]`; cards
+    // that span multiple rows (e.g. diagram cards with `row-span-2`) get
+    // the combined height plus gap automatically.
+    private const string _cardClass =
+        "border border-zinc-800 rounded-md p-4 bg-zinc-900/30 flex flex-col overflow-hidden";
+    private const string _cardHeaderClass =
+        "text-xs text-zinc-500 uppercase tracking-wider mb-3 flex-shrink-0";
+    private const string _cardBodyClass =
+        "flex-1 min-h-0 overflow-y-auto pr-1";
 
     protected override async Task OnInitializedAsync()
     {
-        var systemTask = SystemSummaryUseCase.ExecuteAsync();
-        var serviceTask = ServiceSummaryUseCase.ExecuteAsync();
-        var hardwareTask = HardwareSummaryUseCase.ExecuteAsync();
+        Task<SystemSummary> systemTask = SystemSummaryUseCase.ExecuteAsync();
+        Task<AllServicesSummary> serviceTask = ServiceSummaryUseCase.ExecuteAsync();
+        Task<HardwareSummary> hardwareTask = HardwareSummaryUseCase.ExecuteAsync();
+        Task<RackPeek.Domain.Graph.Graph> topologyTask = TopologyUseCase.ExecuteAsync();
+        Task<RackPeek.Domain.Graph.Graph> logicalTask = LogicalUseCase.ExecuteAsync();
 
-        await Task.WhenAll(systemTask, serviceTask, hardwareTask);
+        await Task.WhenAll(systemTask, serviceTask, hardwareTask, topologyTask, logicalTask);
 
         _system = systemTask.Result;
         _service = serviceTask.Result;
         _hardware = hardwareTask.Result;
+        _topologySource = _serialiser.Serialise(topologyTask.Result);
+        _logicalSource = _serialiser.Serialise(logicalTask.Result);
 
         _loading = false;
     }
-
 }

+ 1 - 1
RackPeek/RackPeek.csproj

@@ -5,7 +5,7 @@
         <TargetFramework>net10.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
-        <AssemblyVersion>1.4.0</AssemblyVersion>
+        <AssemblyVersion>2.0.0</AssemblyVersion>
     </PropertyGroup>
 
     <ItemGroup>

+ 14 - 0
Shared.Rcl/CliBootstrap.cs

@@ -31,6 +31,7 @@ using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls.Labels;
 using Shared.Rcl.Commands.Firewalls.Ports;
 using Shared.Rcl.Commands.Firewalls.Rename;
+using Shared.Rcl.Commands.Graph;
 using Shared.Rcl.Commands.Laptops;
 using Shared.Rcl.Commands.Laptops.Cpus;
 using Shared.Rcl.Commands.Laptops.Drive;
@@ -704,6 +705,19 @@ public static class CliBootstrap {
                     .WithDescription("Generate a /etc/hosts compatible file.");
             });
 
+            // ----------------------------
+            // Graph / visualisation
+            // ----------------------------
+            config.AddBranch("graph", graph => {
+                graph.SetDescription("Render inventory as graph diagrams.");
+
+                graph.AddCommand<GraphTopologyCommand>("topology")
+                    .WithDescription("Emit a Mermaid flowchart of the physical topology (hardware + connections).");
+
+                graph.AddCommand<GraphLogicalCommand>("logical")
+                    .WithDescription("Emit a Mermaid flowchart of services & systems grouped by subnet and host.");
+            });
+
             // ----------------------------
             // Tags discovery
             // ----------------------------

+ 22 - 0
Shared.Rcl/Commands/Graph/GraphLogicalCommand.cs

@@ -0,0 +1,22 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Graph.Serialisers;
+using RackPeek.Domain.Graph.UseCases;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Graph;
+
+public class GraphLogicalCommand(IServiceProvider serviceProvider) : AsyncCommand {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        BuildLogicalGraphUseCase useCase =
+            scope.ServiceProvider.GetRequiredService<BuildLogicalGraphUseCase>();
+
+        RackPeek.Domain.Graph.Graph graph = await useCase.ExecuteAsync();
+        var mermaid = new MermaidSerialiser().Serialise(graph);
+
+        System.Console.Out.Write(mermaid);
+        return 0;
+    }
+}

+ 25 - 0
Shared.Rcl/Commands/Graph/GraphTopologyCommand.cs

@@ -0,0 +1,25 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Graph.Serialisers;
+using RackPeek.Domain.Graph.UseCases;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Graph;
+
+public class GraphTopologyCommand(IServiceProvider serviceProvider) : AsyncCommand {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        BuildPhysicalTopologyUseCase useCase =
+            scope.ServiceProvider.GetRequiredService<BuildPhysicalTopologyUseCase>();
+
+        RackPeek.Domain.Graph.Graph graph = await useCase.ExecuteAsync();
+        var mermaid = new MermaidSerialiser().Serialise(graph);
+
+        // Use Console.Out directly rather than AnsiConsole — Spectre soft-wraps
+        // long lines based on terminal width, which would corrupt the Mermaid
+        // syntax when the user pipes the output to a file or another tool.
+        System.Console.Out.Write(mermaid);
+        return 0;
+    }
+}

+ 86 - 0
Shared.Rcl/Components/Graphs/GraphView.razor

@@ -0,0 +1,86 @@
+@inject IJSRuntime JS
+@implements IAsyncDisposable
+
+<div class="bg-zinc-900/40 overflow-auto h-full w-full"
+     data-testid="@(TestId ?? "graph-view")">
+
+    @if (_isRendering)
+    {
+        <div class="text-zinc-500 text-sm p-4">rendering diagram…</div>
+    }
+    else if (_error is not null)
+    {
+        <div class="text-red-400 text-sm p-4 font-mono whitespace-pre-wrap">@_error</div>
+    }
+
+    <div id="@HostId" class="p-2"></div>
+</div>
+
+@code {
+    [Parameter] public string? Source { get; set; }
+    [Parameter] public string? TestId { get; set; }
+
+    /// <summary>
+    ///     The id of the host element holding the rendered SVG. Defaults to a
+    ///     random GUID per instance; pages that need to call SVG-export JS on
+    ///     this view should pass a stable id.
+    /// </summary>
+    [Parameter] public string? Id { get; set; }
+
+    private string HostId => Id ?? _generatedId;
+    private readonly string _generatedId = $"rpkg-host-{Guid.NewGuid():N}";
+    private string? _renderedSource;
+    private bool _isRendering;
+    private string? _error;
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        // Re-render only when the source actually changes — Blazor calls
+        // OnAfterRender on every state change.
+        if (Source == _renderedSource) return;
+        _renderedSource = Source;
+
+        if (string.IsNullOrWhiteSpace(Source))
+        {
+            try
+            {
+                await JS.InvokeVoidAsync("rackpeekGraph.render", HostId, "");
+            }
+            catch
+            {
+                // Ignore — the host may not be ready / the page is unloading.
+            }
+            return;
+        }
+
+        _isRendering = true;
+        _error = null;
+        StateHasChanged();
+
+        try
+        {
+            await JS.InvokeVoidAsync("rackpeekGraph.render", HostId, Source);
+        }
+        catch (Exception ex)
+        {
+            _error = $"Diagram render failed: {ex.Message}";
+        }
+        finally
+        {
+            _isRendering = false;
+            StateHasChanged();
+        }
+    }
+
+    public async ValueTask DisposeAsync()
+    {
+        try
+        {
+            await JS.InvokeVoidAsync("rackpeekGraph.render", HostId, "");
+        }
+        catch
+        {
+            // ignore on teardown
+        }
+    }
+}

+ 30 - 2
Shared.Rcl/Connections/PortConnectionModal.razor

@@ -154,6 +154,19 @@
 
             </div>
 
+            <div class="mt-6 space-y-1">
+                <label class="text-zinc-400 text-xs uppercase tracking-wider"
+                       for="@($"{BaseTestId}-label-input")">
+                    Label
+                </label>
+                <input id="@($"{BaseTestId}-label-input")"
+                       class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100 text-sm"
+                       data-testid="@($"{BaseTestId}-label")"
+                       placeholder="optional — shown on the topology diagram"
+                       maxlength="80"
+                       @bind="_label"/>
+            </div>
+
             <div class="flex justify-end gap-2 mt-6">
 
                 <button class="px-3 py-1 border border-zinc-700 rounded text-zinc-300"
@@ -210,6 +223,8 @@
     int? _portAIndex;
     int? _portBIndex;
 
+    string _label = string.Empty;
+
 
     int? _resourceAIndex
     {
@@ -325,7 +340,16 @@
             .ToList();
 
         if (SeedPort != null)
-            SeedSinglePortA(SeedPort);
+        {
+            // If the seed port is already part of a connection, hydrate the
+            // whole modal (side B + label) so the user can see and edit what
+            // they already configured. Otherwise just preselect side A.
+            var existing = await Repository.GetConnectionForPortAsync(SeedPort);
+            if (existing != null)
+                SeedConnection(existing);
+            else
+                SeedSinglePortA(SeedPort);
+        }
     }
 
 
@@ -375,6 +399,7 @@
     {
         SeedSinglePortA(conn.A);
         SeedSinglePortB(conn.B);
+        _label = conn.Label ?? string.Empty;
     }
 
     async Task HandleSubmit()
@@ -395,7 +420,9 @@
             PortIndex = _portBIndex!.Value
         };
 
-        await AddConnectionUseCase.ExecuteAsync(a, b);
+        var trimmedLabel = string.IsNullOrWhiteSpace(_label) ? null : _label.Trim();
+
+        await AddConnectionUseCase.ExecuteAsync(a, b, trimmedLabel);
 
         await Cancel();
     }
@@ -403,6 +430,7 @@
 
     async Task Cancel()
     {
+        _label = string.Empty;
         await IsOpenChanged.InvokeAsync(false);
     }
 

+ 228 - 79
Shared.Rcl/Layout/MainLayout.razor

@@ -1,98 +1,135 @@
-@using RackPeek.Domain
+@using RackPeek.Domain
 @using Shared.Rcl.Components
 @inherits LayoutComponentBase
+@implements IAsyncDisposable
+
+@inject NavigationManager Nav
+@inject IJSRuntime JS
+
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono"
      data-testid="app-root">
 
-    <header class="flex items-center justify-between p-4 border-b border-zinc-800 bg-zinc-900"
-            data-testid="app-header">
-        <div class="flex items-center gap-6">
+    <header class="border-b border-zinc-800 bg-zinc-900" data-testid="app-header">
+
+        <!-- Top row: brand + search (always visible) + nav cluster | mobile dropdown -->
+        <div class="flex items-center gap-3 sm:gap-4 px-6 sm:px-8 lg:px-12 py-4">
             <NavLink href=""
                      data-testid="brand-link"
-                     class="hover:text-emerald-400"
+                     class="hover:text-emerald-400 flex-shrink-0"
                      activeClass="text-emerald-400 font-semibold">
 
-                <div class="flex items-center gap-3"
+                <div class="flex items-baseline gap-2"
                      data-testid="brand-text">
-
-                    <span class="text-xl font-bold text-emerald-400 tracking-wider">
-                        rackpeek
-                    </span>
-
-                    <span class="text-[10px]
-                         text-zinc-500
-                         tracking-wide">
-                        @RpkConstants.Version
-                    </span>
-
+                    <span class="text-xl font-bold text-emerald-400 tracking-wider">rackpeek</span>
+                    <span class="text-[10px] text-zinc-500 tracking-wide">@RpkConstants.Version</span>
                 </div>
             </NavLink>
 
-            <GlobalSearch/>
+            <!-- Single search instance, sized responsively. Sits next to the
+                 brand on the left at every viewport — fills available space
+                 between brand and the nav cluster. -->
+            <div class="flex-1 min-w-0 max-w-[180px] sm:max-w-[240px] min-[1000px]:max-w-xs xl:max-w-md">
+                <GlobalSearch/>
+            </div>
+
+            <!-- Desktop: git + nav pushed to the right -->
+            <div class="hidden min-[1000px]:flex items-center gap-5 ml-auto flex-shrink-0">
+                @if (RpkConstants.HasGitServices)
+                {
+                    <GitStatusIndicator/>
+                }
+
+                <nav class="flex items-center gap-4 text-sm" data-testid="main-nav">
+                    @foreach (NavItem item in NavItems)
+                    {
+                        <NavLink href="@item.Href"
+                                 Match="@item.Match"
+                                 class="hover:text-emerald-400"
+                                 activeClass="text-emerald-400 font-semibold"
+                                 data-testid="@($"nav-{item.TestId}")">
+                            @item.Label
+                        </NavLink>
+                    }
+                </nav>
+            </div>
+
+            <!-- Mobile: page dropdown anchored to the right -->
+            <div class="min-[1000px]:hidden ml-auto relative flex-shrink-0" id="rpk-mobile-nav">
+                <button class="flex items-center gap-2 px-3 py-1.5 text-sm rounded border border-zinc-800 text-zinc-200 hover:text-emerald-400 hover:border-zinc-700"
+                        data-testid="nav-toggle"
+                        aria-haspopup="menu"
+                        aria-expanded="@(_dropdownOpen ? "true" : "false")"
+                        @onclick="ToggleDropdown">
+                    <span>@CurrentPageLabel</span>
+                    <svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4" fill="none" viewBox="0 0 24 24"
+                         stroke="currentColor" stroke-width="2">
+                        @if (_dropdownOpen)
+                        {
+                            <path stroke-linecap="round" stroke-linejoin="round" d="M5 15l7-7 7 7"/>
+                        }
+                        else
+                        {
+                            <path stroke-linecap="round" stroke-linejoin="round" d="M19 9l-7 7-7-7"/>
+                        }
+                    </svg>
+                </button>
+
+                @if (_dropdownOpen)
+                {
+                    <div class="absolute right-0 mt-2 w-52 bg-zinc-900 border border-zinc-800 rounded shadow-lg overflow-hidden z-50"
+                         data-testid="mobile-nav"
+                         role="menu">
+                        @foreach (NavItem item in NavItems)
+                        {
+                            <NavLink href="@item.Href"
+                                     Match="@item.Match"
+                                     class="block px-3 py-2 text-sm border-b border-zinc-800 last:border-b-0 hover:text-emerald-400 hover:bg-zinc-800/50"
+                                     activeClass="text-emerald-400 bg-emerald-500/10"
+                                     data-testid="@($"nav-mobile-{item.TestId}")"
+                                     @onclick="CloseDropdown">
+                                @item.Label
+                            </NavLink>
+                        }
+                    </div>
+                }
+            </div>
+
+            <!-- Community / source links — anchored to the far right of the
+                 header at every breakpoint. The two clusters above carry
+                 `ml-auto`; this group sits immediately to their right. -->
+            <div class="flex items-center gap-3 flex-shrink-0">
+                <a href="https://github.com/Timmoth/RackPeek"
+                   target="_blank"
+                   rel="noopener noreferrer"
+                   aria-label="RackPeek on GitHub"
+                   class="text-zinc-400 hover:text-emerald-400 transition"
+                   data-testid="social-github">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
+                         fill="currentColor" class="w-5 h-5">
+                        <path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12"/>
+                    </svg>
+                </a>
+                <a href="https://discord.gg/egXRPdesee"
+                   target="_blank"
+                   rel="noopener noreferrer"
+                   aria-label="Join the RackPeek Discord"
+                   class="text-zinc-400 hover:text-emerald-400 transition"
+                   data-testid="social-discord">
+                    <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"
+                         fill="currentColor" class="w-5 h-5">
+                        <path d="M20.317 4.37a19.791 19.791 0 0 0-4.885-1.515.074.074 0 0 0-.079.037c-.21.375-.444.864-.608 1.25a18.27 18.27 0 0 0-5.487 0 12.64 12.64 0 0 0-.617-1.25.077.077 0 0 0-.079-.037A19.736 19.736 0 0 0 3.677 4.37a.07.07 0 0 0-.032.027C.533 9.046-.32 13.58.099 18.057a.082.082 0 0 0 .031.057 19.9 19.9 0 0 0 5.993 3.03.078.078 0 0 0 .084-.028 14.09 14.09 0 0 0 1.226-1.994.076.076 0 0 0-.041-.106 13.107 13.107 0 0 1-1.872-.892.077.077 0 0 1-.008-.128 10.2 10.2 0 0 0 .372-.292.074.074 0 0 1 .077-.01c3.928 1.793 8.18 1.793 12.062 0a.074.074 0 0 1 .078.01c.12.098.246.198.373.292a.077.077 0 0 1-.006.127 12.299 12.299 0 0 1-1.873.892.077.077 0 0 0-.041.107c.36.698.772 1.362 1.225 1.993a.076.076 0 0 0 .084.028 19.839 19.839 0 0 0 6.002-3.03.077.077 0 0 0 .032-.054c.5-5.177-.838-9.674-3.549-13.66a.061.061 0 0 0-.031-.03zM8.02 15.33c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.956-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.956 2.418-2.157 2.418zm7.975 0c-1.183 0-2.157-1.085-2.157-2.419 0-1.333.955-2.419 2.157-2.419 1.21 0 2.176 1.096 2.157 2.42 0 1.333-.946 2.418-2.157 2.418z"/>
+                    </svg>
+                </a>
+            </div>
         </div>
 
-        <div class="flex items-center gap-6">
-            @if (RpkConstants.HasGitServices)
-            {
+        <!-- Mobile-only second row: git status (only shown when configured) -->
+        @if (RpkConstants.HasGitServices)
+        {
+            <div class="min-[1000px]:hidden border-t border-zinc-800 px-6 py-3">
                 <GitStatusIndicator/>
-            }
-
-            <nav class="space-x-6 text-sm" data-testid="main-nav">
-
-                <NavLink href=""
-                         Match="NavLinkMatch.All"
-                         data-testid="nav-home"
-                         class="hover:text-emerald-400"
-                         activeClass="text-emerald-400 font-semibold">
-                    Home
-                </NavLink>
-
-                <NavLink href="cli"
-                         class="hover:text-emerald-400"
-                         activeClass="text-emerald-400 font-semibold"
-                         data-testid="nav-cli">
-                    CLI
-                </NavLink>
-
-                <NavLink href="yaml"
-                         class="hover:text-emerald-400"
-                         activeClass="text-emerald-400 font-semibold"
-                         data-testid="nav-yaml">
-                    Yaml
-                </NavLink>
-
-                <NavLink href="hardware/tree"
-                         class="hover:text-emerald-400"
-                         activeClass="text-emerald-400 font-semibold"
-                         data-testid="nav-hardware">
-                    Hardware
-                </NavLink>
-
-                <NavLink href="systems/list"
-                         class="hover:text-emerald-400"
-                         activeClass="text-emerald-400 font-semibold"
-                         data-testid="nav-systems">
-                    Systems
-                </NavLink>
-
-                <NavLink href="services/list"
-                         class="hover:text-emerald-400"
-                         activeClass="text-emerald-400 font-semibold"
-                         data-testid="nav-services">
-                    Services
-                </NavLink>
-
-                <NavLink href="docs"
-                         class="hover:text-emerald-400"
-                         activeClass="text-emerald-400 font-semibold"
-                         data-testid="nav-docs">
-                    Docs
-                </NavLink>
-
-
-
-            </nav>
-        </div>
+            </div>
+        }
     </header>
 
     <main class="p-6" data-testid="page-content">
@@ -100,3 +137,115 @@
     </main>
 
 </div>
+
+@code {
+    private const string _outsideDismissId = "rpk-mobile-nav";
+    private const string _containerSelector = "#rpk-mobile-nav";
+
+    private bool _dropdownOpen;
+    private bool _listenerRegistered;
+    private DotNetObjectReference<MainLayout>? _selfRef;
+
+    protected override void OnInitialized()
+    {
+        _selfRef = DotNetObjectReference.Create(this);
+        Nav.LocationChanged += HandleLocationChanged;
+    }
+
+    private void HandleLocationChanged(object? sender, LocationChangedEventArgs e) =>
+        InvokeAsync(StateHasChanged);
+
+    private void ToggleDropdown() => _dropdownOpen = !_dropdownOpen;
+
+    private void CloseDropdown() => _dropdownOpen = false;
+
+    [JSInvokable]
+    public Task DismissDropdownFromJs()
+    {
+        if (!_dropdownOpen) return Task.CompletedTask;
+        _dropdownOpen = false;
+        return InvokeAsync(StateHasChanged);
+    }
+
+    protected override async Task OnAfterRenderAsync(bool firstRender)
+    {
+        // Mirror the dropdown state into the JS dismiss-listener registration
+        // so the listener only exists while it's needed.
+        if (_dropdownOpen && !_listenerRegistered)
+        {
+            await JS.InvokeVoidAsync(
+                "rackpeekUi.registerOutsideDismiss",
+                _outsideDismissId,
+                _containerSelector,
+                _selfRef,
+                nameof(DismissDropdownFromJs));
+            _listenerRegistered = true;
+        }
+        else if (!_dropdownOpen && _listenerRegistered)
+        {
+            await JS.InvokeVoidAsync("rackpeekUi.unregisterOutsideDismiss", _outsideDismissId);
+            _listenerRegistered = false;
+        }
+    }
+
+    public async ValueTask DisposeAsync()
+    {
+        Nav.LocationChanged -= HandleLocationChanged;
+
+        if (_listenerRegistered)
+        {
+            try { await JS.InvokeVoidAsync("rackpeekUi.unregisterOutsideDismiss", _outsideDismissId); }
+            catch { /* page unloading */ }
+        }
+
+        _selfRef?.Dispose();
+    }
+
+    private string CurrentPageLabel
+    {
+        get
+        {
+            var path = Nav.ToBaseRelativePath(Nav.Uri).Split('?')[0].Split('#')[0].Trim('/');
+            NavItem? best = null;
+            var bestLength = -1;
+
+            foreach (NavItem item in NavItems)
+            {
+                if (!Matches(path, item)) continue;
+                if (item.Href.Length > bestLength)
+                {
+                    best = item;
+                    bestLength = item.Href.Length;
+                }
+            }
+
+            return best?.Label ?? "Menu";
+        }
+    }
+
+    private static bool Matches(string path, NavItem item)
+    {
+        if (item.Match == NavLinkMatch.All)
+            return string.Equals(path, item.Href, StringComparison.OrdinalIgnoreCase);
+
+        // Prefix match — but never let the empty-string "Home" entry win as
+        // a prefix of every route.
+        if (string.IsNullOrEmpty(item.Href)) return false;
+        return path.Equals(item.Href, StringComparison.OrdinalIgnoreCase)
+               || path.StartsWith(item.Href + "/", StringComparison.OrdinalIgnoreCase);
+    }
+
+    private static readonly NavItem[] NavItems =
+    {
+        new("", "home", "Home", NavLinkMatch.All),
+        new("cli", "cli", "CLI"),
+        new("yaml", "yaml", "Yaml"),
+        new("hardware/tree", "hardware", "Hardware"),
+        new("systems/list", "systems", "Systems"),
+        new("services/list", "services", "Services"),
+        new("visualise", "visualise", "Visualise"),
+        new("docs", "docs", "Docs")
+    };
+
+    private sealed record NavItem(string Href, string TestId, string Label, NavLinkMatch Match = NavLinkMatch.Prefix);
+}

+ 167 - 0
Shared.Rcl/Visualise/VisualisePage.razor

@@ -0,0 +1,167 @@
+@page "/visualise"
+@page "/visualise/{View}"
+
+@using RackPeek.Domain.Graph.Serialisers
+@using RackPeek.Domain.Graph.UseCases
+@using Shared.Rcl.Components.Graphs
+
+@inject BuildPhysicalTopologyUseCase TopologyUseCase
+@inject BuildLogicalGraphUseCase LogicalUseCase
+@inject NavigationManager Nav
+@inject IJSRuntime JS
+
+<PageTitle>Visualise</PageTitle>
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6"
+     data-testid="visualise-page-root">
+
+    <div class="flex items-center justify-between mb-4">
+        <div class="space-y-1">
+            <div class="text-xs text-zinc-500 uppercase tracking-wider">Knowledge Base</div>
+            <h1 class="text-lg text-zinc-100">
+                Visualise
+                <span class="text-zinc-500">:</span>
+                <span class="text-emerald-400">@ActiveTitle</span>
+            </h1>
+        </div>
+
+        <div class="flex items-center gap-4">
+            <nav class="flex items-center gap-1 text-sm">
+                <button class="@TabClass("topology")"
+                        data-testid="visualise-tab-topology"
+                        @onclick="@(() => SelectView("topology"))">
+                    Physical
+                </button>
+                <button class="@TabClass("logical")"
+                        data-testid="visualise-tab-logical"
+                        @onclick="@(() => SelectView("logical"))">
+                    Logical
+                </button>
+            </nav>
+
+            <div class="flex items-center gap-1 text-sm">
+                <button class="@ExportButtonClass"
+                        disabled="@CannotExport"
+                        data-testid="visualise-export-svg"
+                        @onclick="ExportSvgAsync">
+                    Save SVG
+                </button>
+                <button class="@ExportButtonClass"
+                        disabled="@CannotExport"
+                        data-testid="visualise-export-source"
+                        @onclick="ExportSourceAsync">
+                    Save Mermaid
+                </button>
+            </div>
+        </div>
+    </div>
+
+    @if (_loading)
+    {
+        <div class="text-zinc-500 text-sm">loading inventory…</div>
+    }
+    else if (_error is not null)
+    {
+        <div class="text-red-400 text-sm">@_error</div>
+    }
+    else
+    {
+        <div class="border border-zinc-800 rounded-md bg-zinc-900/30" style="height: 75vh;">
+            <GraphView Source="@_source"
+                       Id="@HostId"
+                       TestId="@($"visualise-graph-{ActiveView}")" />
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public string? View { get; set; }
+
+    private string ActiveView => string.Equals(View, "logical", StringComparison.OrdinalIgnoreCase)
+        ? "logical"
+        : "topology";
+
+    private string ActiveTitle => ActiveView switch
+    {
+        "logical" => "Logical — services & systems",
+        _ => "Physical — hardware topology"
+    };
+
+    private string? _source;
+    private bool _loading = true;
+    private string? _error;
+    private string? _lastLoadedView;
+
+    private readonly MermaidSerialiser _serialiser = new();
+
+    protected override async Task OnParametersSetAsync()
+    {
+        if (_lastLoadedView == ActiveView) return;
+        _lastLoadedView = ActiveView;
+
+        await LoadAsync();
+    }
+
+    private async Task LoadAsync()
+    {
+        _loading = true;
+        _error = null;
+        StateHasChanged();
+
+        try
+        {
+            RackPeek.Domain.Graph.Graph graph = ActiveView switch
+            {
+                "logical" => await LogicalUseCase.ExecuteAsync(),
+                _ => await TopologyUseCase.ExecuteAsync()
+            };
+
+            _source = _serialiser.Serialise(graph);
+        }
+        catch (Exception ex)
+        {
+            _error = $"Failed to build graph: {ex.Message}";
+            _source = null;
+        }
+        finally
+        {
+            _loading = false;
+            StateHasChanged();
+        }
+    }
+
+    private void SelectView(string view)
+    {
+        Nav.NavigateTo($"visualise/{view}");
+    }
+
+    private const string HostId = "visualise-graph-host";
+
+    private bool CannotExport => _loading || _source is null;
+
+    private string ExportButtonClass =>
+        "px-3 py-1 rounded border border-zinc-800 text-zinc-400 " +
+        "hover:text-emerald-400 hover:border-zinc-700 disabled:opacity-40 disabled:hover:text-zinc-400";
+
+    private string ExportFilenameBase =>
+        ActiveView == "logical" ? "rackpeek-logical" : "rackpeek-topology";
+
+    private async Task ExportSvgAsync()
+    {
+        if (CannotExport) return;
+        await JS.InvokeVoidAsync(
+            "rackpeekGraph.downloadSvg", HostId, $"{ExportFilenameBase}.svg");
+    }
+
+    private async Task ExportSourceAsync()
+    {
+        if (CannotExport || _source is null) return;
+        await JS.InvokeVoidAsync(
+            "rackpeekGraph.downloadText", _source, $"{ExportFilenameBase}.mmd", "text/plain");
+    }
+
+    private string TabClass(string view) =>
+        ActiveView == view
+            ? "px-3 py-1 rounded border border-emerald-500/40 bg-emerald-500/10 text-emerald-400"
+            : "px-3 py-1 rounded border border-zinc-800 text-zinc-400 hover:text-emerald-400 hover:border-zinc-700";
+}

+ 1 - 0
Shared.Rcl/wwwroot/js/graph/chunks/mermaid-layout-elk.esm.min/chunk-SP2CHFBE.mjs

@@ -0,0 +1 @@
+var g=Object.create;var e=Object.defineProperty;var h=Object.getOwnPropertyDescriptor;var i=Object.getOwnPropertyNames;var j=Object.getPrototypeOf,k=Object.prototype.hasOwnProperty;var m=(a,b)=>e(a,"name",{value:b,configurable:!0}),n=(a=>typeof require<"u"?require:typeof Proxy<"u"?new Proxy(a,{get:(b,c)=>(typeof require<"u"?require:b)[c]}):a)(function(a){if(typeof require<"u")return require.apply(this,arguments);throw Error('Dynamic require of "'+a+'" is not supported')});var o=(a,b)=>()=>(b||a((b={exports:{}}).exports,b),b.exports);var l=(a,b,c,f)=>{if(b&&typeof b=="object"||typeof b=="function")for(let d of i(b))!k.call(a,d)&&d!==c&&e(a,d,{get:()=>b[d],enumerable:!(f=h(b,d))||f.enumerable});return a};var p=(a,b,c)=>(c=a!=null?g(j(a)):{},l(b||!a||!a.__esModule?e(c,"default",{value:a,enumerable:!0}):c,a));export{m as a,n as b,o as c,p as d};

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
Shared.Rcl/wwwroot/js/graph/chunks/mermaid-layout-elk.esm.min/render-YY74OMMT.mjs


+ 220 - 0
Shared.Rcl/wwwroot/js/graph/index.js

@@ -0,0 +1,220 @@
+// RackPeek graph rendering shim.
+//
+// Public API (called from Blazor via JSInterop):
+//   window.rackpeekGraph.render(elementId, mermaidSource)
+//     – Wipes the target element, runs Mermaid on the source, and inserts
+//       the resulting SVG with pan/zoom enabled.
+//
+// Assumes Mermaid is already loaded globally (via <script src="mermaid.min.js">).
+// The ELK layout plugin is loaded lazily on first render.
+
+(function () {
+    "use strict";
+
+    let _initPromise = null;
+    let _mermaidScriptPromise = null;
+
+    function loadMermaidScript() {
+        // Inject the (large) mermaid UMD bundle on first use rather than
+        // shipping it on every page. Keeps non-graph pages light so Blazor's
+        // SignalR circuit establishes promptly under load.
+        if (_mermaidScriptPromise) return _mermaidScriptPromise;
+
+        _mermaidScriptPromise = new Promise((resolve, reject) => {
+            if (window.mermaid) {
+                resolve();
+                return;
+            }
+            const script = document.createElement("script");
+            script.src = "/_content/Shared.Rcl/js/graph/mermaid.min.js";
+            script.async = true;
+            script.onload = () => resolve();
+            script.onerror = () => reject(new Error("Failed to load mermaid.min.js"));
+            document.head.appendChild(script);
+        });
+
+        return _mermaidScriptPromise;
+    }
+
+    async function ensureInitialised() {
+        if (_initPromise) return _initPromise;
+
+        _initPromise = (async () => {
+            await loadMermaidScript();
+
+            if (!window.mermaid) {
+                throw new Error("Mermaid bundle loaded but window.mermaid is undefined");
+            }
+
+            // Register the ELK layout loader. Mermaid 11 dispatches by the
+            // `layout` config key; "elk" maps to the layered algorithm.
+            try {
+                const elk = await import("./mermaid-layout-elk.min.mjs");
+                window.mermaid.registerLayoutLoaders(elk.default);
+            } catch (e) {
+                // Fall back silently to the default dagre layout — still
+                // renders, just with the less polished arrow routing.
+                console.warn("[rackpeekGraph] ELK plugin failed to load:", e);
+            }
+
+            window.mermaid.initialize({
+                startOnLoad: false,
+                securityLevel: "loose",
+                theme: "dark",
+                fontFamily: "ui-sans-serif, system-ui, -apple-system, sans-serif"
+            });
+        })();
+
+        return _initPromise;
+    }
+
+    async function render(elementId, source) {
+        const host = document.getElementById(elementId);
+        if (host) host.innerHTML = "";
+
+        // Empty source = "clear" request. Don't hand "" to mermaid.render —
+        // it treats that as malformed input and produces a "Syntax error in
+        // text" SVG which can leak out into the page if the host element has
+        // already been detached (e.g. component disposal during navigation).
+        if (!source || !source.trim()) {
+            cleanupOrphans();
+            return;
+        }
+
+        // Defer the (heavy) mermaid + ELK work to browser idle time so the
+        // Blazor circuit and nav-click handlers stay responsive on pages
+        // that render diagrams (e.g. the homepage). If the host element is
+        // gone by the time idle fires (user navigated away), bail.
+        await waitForIdle();
+        if (!document.getElementById(elementId)) {
+            cleanupOrphans();
+            return;
+        }
+
+        await ensureInitialised();
+
+        // The host may have been detached while ensureInitialised was awaiting
+        // (especially on first render). Re-fetch and bail if it's gone.
+        const liveHost = document.getElementById(elementId);
+        if (!liveHost) {
+            cleanupOrphans();
+            return;
+        }
+
+        // A unique id per render avoids collisions when the same element is
+        // re-rendered with different source.
+        const renderId = `rpkg-${elementId}-${Date.now()}`;
+        let result;
+        try {
+            result = await window.mermaid.render(renderId, source);
+        } finally {
+            // Mermaid creates a scratch <div id="d{renderId}"> in <body> for
+            // measurement and normally removes it; sweep up just in case.
+            const scratch = document.getElementById("d" + renderId);
+            if (scratch && scratch.parentElement) scratch.parentElement.removeChild(scratch);
+        }
+
+        // Host may have been disposed during the render await.
+        const stillLive = document.getElementById(elementId);
+        if (!stillLive) {
+            cleanupOrphans();
+            return;
+        }
+
+        stillLive.innerHTML = result.svg;
+
+        const svgEl = stillLive.querySelector("svg");
+        if (svgEl) {
+            // Let the SVG fill its container rather than honouring the
+            // intrinsic max-width Mermaid sets, so pan/zoom feels natural.
+            svgEl.removeAttribute("width");
+            svgEl.removeAttribute("height");
+            svgEl.style.maxWidth = "100%";
+            svgEl.style.width = "100%";
+            svgEl.style.height = "100%";
+        }
+
+        if (result.bindFunctions) result.bindFunctions(stillLive);
+    }
+
+    function waitForIdle() {
+        return new Promise((resolve) => {
+            if (typeof window.requestIdleCallback === "function") {
+                // 2s timeout means we still fire eventually if the browser
+                // never goes idle.
+                window.requestIdleCallback(() => resolve(), { timeout: 2000 });
+            } else {
+                // Safari < 16 has no requestIdleCallback — fall back to a
+                // short defer that still yields the current event loop.
+                setTimeout(resolve, 50);
+            }
+        });
+    }
+
+    function cleanupOrphans() {
+        // Mermaid sometimes leaves "d{renderId}" scratch nodes attached to
+        // <body> when the originating host is gone — remove any that match
+        // our renderId prefix.
+        document.querySelectorAll("body > [id^='drpkg-']").forEach((el) => {
+            el.parentElement?.removeChild(el);
+        });
+    }
+
+    function triggerDownload(blob, filename) {
+        const url = URL.createObjectURL(blob);
+        const a = document.createElement("a");
+        a.href = url;
+        a.download = filename;
+        document.body.appendChild(a);
+        a.click();
+        document.body.removeChild(a);
+        // Defer revoke so Safari has time to start the download.
+        setTimeout(() => URL.revokeObjectURL(url), 1000);
+    }
+
+    function downloadSvg(elementId, filename) {
+        const host = document.getElementById(elementId);
+        if (!host) {
+            console.warn(`[rackpeekGraph] element '${elementId}' not found`);
+            return;
+        }
+
+        const svg = host.querySelector("svg");
+        if (!svg) {
+            console.warn(`[rackpeekGraph] no SVG in element '${elementId}' to export`);
+            return;
+        }
+
+        // Clone so the in-page interactive copy isn't modified. Ensure
+        // xmlns + a viewBox-derived width/height so the file renders cleanly
+        // in any standalone viewer.
+        const clone = svg.cloneNode(true);
+        if (!clone.getAttribute("xmlns")) {
+            clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
+        }
+        if (!clone.getAttribute("xmlns:xlink")) {
+            clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink");
+        }
+        const vb = clone.getAttribute("viewBox");
+        if (vb && !clone.getAttribute("width")) {
+            const parts = vb.split(/\s+/);
+            if (parts.length === 4) {
+                clone.setAttribute("width", parts[2]);
+                clone.setAttribute("height", parts[3]);
+            }
+        }
+
+        const serialiser = new XMLSerializer();
+        const body = serialiser.serializeToString(clone);
+        const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + body;
+        triggerDownload(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }), filename);
+    }
+
+    function downloadText(content, filename, mime) {
+        triggerDownload(
+            new Blob([content ?? ""], { type: (mime ?? "text/plain") + ";charset=utf-8" }),
+            filename);
+    }
+
+    window.rackpeekGraph = { render, downloadSvg, downloadText };
+})();

+ 1 - 0
Shared.Rcl/wwwroot/js/graph/mermaid-layout-elk.min.mjs

@@ -0,0 +1 @@
+import{a as o}from"./chunks/mermaid-layout-elk.esm.min/chunk-SP2CHFBE.mjs";var a=o(async()=>await import("./chunks/mermaid-layout-elk.esm.min/render-YY74OMMT.mjs"),"loader"),t=["elk.stress","elk.force","elk.mrtree","elk.sporeOverlap"],r=[{name:"elk",loader:a,algorithm:"elk.layered"},...t.map(e=>({name:e,loader:a,algorithm:e}))],l=r;export{l as default};

Những thai đổi đã bị hủy bỏ vì nó quá lớn
+ 0 - 0
Shared.Rcl/wwwroot/js/graph/mermaid.min.js


+ 58 - 0
Shared.Rcl/wwwroot/js/uiHelpers.js

@@ -0,0 +1,58 @@
+// RackPeek UI helpers — lightweight Blazor JSInterop primitives that don't
+// fit cleanly into a Razor component on their own. Currently exposes a
+// "dismiss me when the user interacts outside this element" helper used by
+// the mobile nav dropdown.
+//
+// Public API (called from Blazor via JSInterop):
+//   window.rackpeekUi.registerOutsideDismiss(id, containerSelector, dotnetRef, methodName)
+//     Listens for clicks/touches/Escape outside `containerSelector` and
+//     invokes `methodName` on the supplied .NET ref. Idempotent — calling
+//     twice with the same id replaces the prior handler.
+//
+//   window.rackpeekUi.unregisterOutsideDismiss(id)
+//     Removes the previously-registered handlers for `id`.
+
+(function () {
+    "use strict";
+
+    const _state = new Map();
+
+    function registerOutsideDismiss(id, containerSelector, dotnetRef, methodName) {
+        unregisterOutsideDismiss(id);
+
+        const dismiss = () => {
+            dotnetRef.invokeMethodAsync(methodName).catch(() => {
+                // Component already disposed — drop the call silently.
+            });
+        };
+
+        const pointerHandler = (e) => {
+            const container = document.querySelector(containerSelector);
+            if (!container) return;
+            if (container.contains(e.target)) return;
+            dismiss();
+        };
+
+        const keyHandler = (e) => {
+            if (e.key === "Escape") dismiss();
+        };
+
+        // Listen on pointerdown rather than click so the dropdown closes
+        // immediately when interaction starts elsewhere — feels snappier and
+        // also catches drags / scrolls beginning outside the menu.
+        document.addEventListener("pointerdown", pointerHandler, true);
+        document.addEventListener("keydown", keyHandler, true);
+
+        _state.set(id, { pointerHandler, keyHandler });
+    }
+
+    function unregisterOutsideDismiss(id) {
+        const entry = _state.get(id);
+        if (!entry) return;
+        document.removeEventListener("pointerdown", entry.pointerHandler, true);
+        document.removeEventListener("keydown", entry.keyHandler, true);
+        _state.delete(id);
+    }
+
+    window.rackpeekUi = { registerOutsideDismiss, unregisterOutsideDismiss };
+})();

+ 64 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -393,4 +393,68 @@ public class AccessPointCardTests(
             await context.CloseAsync();
         }
     }
+
+    [Fact]
+    public async Task Connection_Modal_Stores_And_Replays_The_Label() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var ap1 = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var ap2 = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var label = $"link-{Guid.NewGuid():N}"[..12];
+
+        try {
+            await page.GotoAsync(_fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            // Two APs each with a port group
+            await list.AddAccessPointAsync(ap1);
+            await page.WaitForURLAsync($"**/resources/hardware/{ap1}");
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(ap1);
+            await card.AddPortGroupAsync("rj45", "1", 2);
+
+            await layout.GotoHardwareAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+            await list.AddAccessPointAsync(ap2);
+            await page.WaitForURLAsync($"**/resources/hardware/{ap2}");
+            await card.AssertCardVisibleAsync(ap2);
+            await card.AddPortGroupAsync("sfp+", "2.5", 2);
+
+            // Open AP1 and create a connection with a label
+            await layout.GotoHardwareAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+            await list.OpenAccessPointAsync(ap1);
+            await card.AssertCardVisibleAsync(ap1);
+
+            await card.OpenConnectionFromPortAsync(0, 0);
+            await card.CreateConnectionAsync(
+                ap1, "rj45 — 1 Gbps (2)", "Port 1",
+                ap2, "sfp+ — 2.5 Gbps (2)", "Port 1",
+                label: label);
+
+            // Re-open the modal on the same port — the stored label should
+            // pre-populate the input, proving it round-tripped to the YAML
+            // and back through `GetConnectionForPortAsync`.
+            await card.OpenConnectionFromPortAsync(0, 0);
+
+            await Assertions.Expect(card.ConnectionLabelInput()).ToHaveValueAsync(label);
+
+            await context.CloseAsync();
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }

+ 6 - 2
Tests.E2e/PageObjectModels/AccessPointCardPom.cs

@@ -178,7 +178,8 @@ public class AccessPointCardPom(IPage page) {
         string portA,
         string resourceB,
         string groupB,
-        string portB) {
+        string portB,
+        string? label = null) {
         await Ports.CreateConnectionAsync(
             _portsPrefix,
             resourceA,
@@ -186,6 +187,9 @@ public class AccessPointCardPom(IPage page) {
             portA,
             resourceB,
             groupB,
-            portB);
+            portB,
+            label);
     }
+
+    public ILocator ConnectionLabelInput() => Ports.LabelInput(_portsPrefix);
 }

+ 8 - 1
Tests.E2e/PageObjectModels/PortsPom.cs

@@ -74,6 +74,9 @@ public class PortsPom(IPage page) {
     public ILocator SubmitConnection(string testIdPrefix)
         => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-submit");
 
+    public ILocator LabelInput(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-label");
+
     // -------------------------------------------------
     // Assertions
     // -------------------------------------------------
@@ -105,7 +108,8 @@ public class PortsPom(IPage page) {
         string portA,
         string resourceB,
         string groupB,
-        string portB) {
+        string portB,
+        string? label = null) {
         await ResourceASelect(prefix).SelectOptionAsync(
             new SelectOptionValue { Label = resourceA });
 
@@ -124,6 +128,9 @@ public class PortsPom(IPage page) {
         await PortBSelect(prefix).SelectOptionAsync(
             new SelectOptionValue { Label = portB });
 
+        if (label is not null)
+            await LabelInput(prefix).FillAsync(label);
+
         await SubmitConnection(prefix).ClickAsync();
     }
 

+ 6 - 0
Tests.E2e/Tests.E2e.csproj

@@ -28,6 +28,12 @@
         <Using Include="Xunit"/>
     </ItemGroup>
 
+    <ItemGroup>
+        <None Update="xunit.runner.json">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+    </ItemGroup>
+
     <ItemGroup>
         <ProjectReference Include="..\RackPeek.Web.Viewer\RackPeek.Web.Viewer.csproj"/>
         <ProjectReference Include="..\RackPeek.Web\RackPeek.Web.csproj"/>

+ 8 - 0
Tests.E2e/xunit.runner.json

@@ -0,0 +1,8 @@
+{
+    "$schema": "https://xunit.net/schema/current/xunit.runner.schema.json",
+
+    "_comment": "Each E2E test boots its own Testcontainers container running Blazor Server. Under default parallelism (= CPU count) the host gets overloaded and Blazor's SignalR circuit drops on the slowest containers, producing flaky failures where the new page never finishes rendering (the 'Rejoin failed' reconnect modal appears). Capping concurrency keeps containers responsive.",
+
+    "parallelizeTestCollections": true,
+    "maxParallelThreads": 4
+}

+ 57 - 0
Tests/EndToEnd/Graph/GraphTopologyCliTests.cs

@@ -0,0 +1,57 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.Graph;
+
+[Collection("Yaml CLI tests")]
+public class GraphTopologyCliTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<string> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+        var output = await YamlCliTestHost.RunAsync(args, fs.Root, outputHelper, "config.yaml");
+        outputHelper.WriteLine(output);
+        return output;
+    }
+
+    [Fact]
+    public async Task Topology_Includes_All_Hardware_And_Connection() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("firewalls", "add", "fw-01");
+        await ExecuteAsync("switches", "add", "sw-01");
+        await ExecuteAsync("servers", "add", "srv-01");
+
+        // Give each resource at least one port group so connections have something to attach to.
+        await ExecuteAsync("firewalls", "port", "add", "fw-01", "--type", "RJ45", "--speed", "1", "--count", "4");
+        await ExecuteAsync("switches", "port", "add", "sw-01", "--type", "RJ45", "--speed", "1", "--count", "24");
+        await ExecuteAsync("servers", "nic", "add", "srv-01", "--type", "RJ45", "--speed", "1", "--ports", "2");
+
+        await ExecuteAsync("connections", "add", "fw-01", "0", "0", "sw-01", "0", "0");
+
+        var output = await ExecuteAsync("graph", "topology");
+
+        Assert.Contains("flowchart TD", output);
+        Assert.Contains("fw-01", output);
+        Assert.Contains("sw-01", output);
+        Assert.Contains("srv-01", output);
+        // All nodes share the rpknode class with a kind subtitle.
+        Assert.Contains(":::rpknode", output);
+        Assert.Contains("fw-01<br/>firewall", output);
+        Assert.Contains("sw-01<br/>switch", output);
+        Assert.Contains("srv-01<br/>server", output);
+        Assert.Contains("n_fw_01 ---", output);
+        Assert.Contains("n_sw_01", output);
+    }
+
+    [Fact]
+    public async Task Topology_With_No_Hardware_Renders_Header_Only() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        var output = await ExecuteAsync("graph", "topology");
+
+        Assert.Contains("flowchart TD", output);
+        Assert.Contains("classDef rpknode", output);
+        // No actual node entries.
+        Assert.DoesNotContain("[\"", output);
+    }
+}

+ 15 - 3
Tests/EndToEnd/Infra/YamlCliTestHost.cs

@@ -39,8 +39,20 @@ public static class YamlCliTestHost {
 
         CliBootstrap.BuildApp(app);
 
-        await app.RunAsync(args);
-
-        return console.Output;
+        // Some commands deliberately bypass Spectre and write raw to
+        // System.Console.Out (e.g. `graph topology`, which must emit
+        // unwrapped Mermaid). Capture that too so tests see the full output.
+        TextWriter originalOut = Console.Out;
+        var rawCapture = new StringWriter();
+        Console.SetOut(rawCapture);
+
+        try {
+            await app.RunAsync(args);
+        }
+        finally {
+            Console.SetOut(originalOut);
+        }
+
+        return console.Output + rawCapture.ToString();
     }
 }

+ 143 - 0
Tests/Graph/BuildPhysicalTopologyUseCaseTests.cs

@@ -0,0 +1,143 @@
+using NSubstitute;
+using RackPeek.Domain.Graph;
+using RackPeek.Domain.Graph.UseCases;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+using RackPeek.Domain.Resources.Switches;
+
+namespace Tests.Graph;
+
+public sealed class BuildPhysicalTopologyUseCaseTests {
+    private readonly IResourceCollection _repo = Substitute.For<IResourceCollection>();
+    private readonly BuildPhysicalTopologyUseCase _useCase;
+
+    public BuildPhysicalTopologyUseCaseTests() {
+        _useCase = new BuildPhysicalTopologyUseCase(_repo);
+    }
+
+    private void Seed(IReadOnlyList<Hardware> hardware, params Connection[] connections) {
+        _repo.HardwareResources.Returns(hardware);
+        _repo.GetConnectionsAsync().Returns(connections);
+    }
+
+    private static Server Server(string name) => new() { Name = name, Kind = "Server" };
+    private static Switch Switch(string name, params Port[] ports) =>
+        new() { Name = name, Kind = "Switch", Ports = ports.ToList() };
+    private static Firewall Firewall(string name, params Port[] ports) =>
+        new() { Name = name, Kind = "Firewall", Ports = ports.ToList() };
+
+    [Fact]
+    public async Task Empty_Inventory_Produces_Empty_Graph() {
+        Seed([]);
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        Assert.Empty(graph.Nodes);
+        Assert.Empty(graph.Edges);
+    }
+
+    [Fact]
+    public async Task Each_Hardware_Resource_Becomes_A_Node() {
+        Seed([Server("srv-01"), Switch("sw-01"), Firewall("fw-01")]);
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        Assert.Equal(3, graph.Nodes.Count);
+        Assert.Contains(graph.Nodes, n => n.Id == "srv-01" && n.Kind == "Server");
+        Assert.Contains(graph.Nodes, n => n.Id == "sw-01" && n.Kind == "Switch");
+        Assert.Contains(graph.Nodes, n => n.Id == "fw-01" && n.Kind == "Firewall");
+    }
+
+    [Fact]
+    public async Task Nodes_Are_Sorted_For_Deterministic_Output() {
+        Seed([Server("srv-02"), Server("srv-01"), Switch("sw-01")]);
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        // Sorted by kind then name for a stable diagram across runs.
+        Assert.Equal(new[] { "Server", "Server", "Switch" },
+            graph.Nodes.Select(n => n.Kind).ToArray());
+        Assert.Equal(new[] { "srv-01", "srv-02", "sw-01" },
+            graph.Nodes.Select(n => n.Id).ToArray());
+    }
+
+    [Fact]
+    public async Task Connection_Between_Two_Hardware_Resources_Becomes_An_Edge() {
+        Seed(
+            [Server("srv-01"), Switch("sw-01")],
+            new Connection {
+                A = new PortReference { Resource = "srv-01", PortGroup = 0, PortIndex = 0 },
+                B = new PortReference { Resource = "sw-01", PortGroup = 0, PortIndex = 1 }
+            });
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        GraphEdge edge = Assert.Single(graph.Edges);
+        Assert.Equal("srv-01", edge.Source);
+        Assert.Equal("sw-01", edge.Target);
+        Assert.Equal("connection", edge.Kind);
+    }
+
+    [Fact]
+    public async Task Edge_Label_Uses_Explicit_Connection_Label_When_Present() {
+        Seed(
+            [Server("srv-01"), Switch("sw-01")],
+            new Connection {
+                Label = "primary uplink",
+                A = new PortReference { Resource = "srv-01", PortGroup = 0, PortIndex = 0 },
+                B = new PortReference { Resource = "sw-01", PortGroup = 0, PortIndex = 1 }
+            });
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        Assert.Equal("primary uplink", graph.Edges[0].Label);
+    }
+
+    [Fact]
+    public async Task Edge_Label_Derived_From_Port_Group_Types_When_No_Explicit_Label() {
+        Seed(
+            [
+                Switch("sw-01", new Port { Type = "RJ45", Count = 24 }),
+                Firewall("fw-01", new Port { Type = "SFP+", Count = 4 })
+            ],
+            new Connection {
+                A = new PortReference { Resource = "sw-01", PortGroup = 0, PortIndex = 3 },
+                B = new PortReference { Resource = "fw-01", PortGroup = 0, PortIndex = 1 }
+            });
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        Assert.Equal("RJ453 ↔ SFP+1", graph.Edges[0].Label);
+    }
+
+    [Fact]
+    public async Task Connection_Referencing_Unknown_Resource_Is_Dropped() {
+        // Defensive: stale connection pointing at a deleted resource shouldn't
+        // crash the diagram or produce a dangling edge.
+        Seed(
+            [Server("srv-01")],
+            new Connection {
+                A = new PortReference { Resource = "srv-01", PortGroup = 0, PortIndex = 0 },
+                B = new PortReference { Resource = "deleted-host", PortGroup = 0, PortIndex = 0 }
+            });
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        Assert.Empty(graph.Edges);
+    }
+
+    [Fact]
+    public async Task Tags_Are_Carried_Onto_The_Node_For_Future_Filtering() {
+        Server server = Server("srv-01");
+        server.Tags = ["homelab", "prod"];
+        Seed([server]);
+
+        RackPeek.Domain.Graph.Graph graph = await _useCase.ExecuteAsync();
+
+        Assert.Equal("homelab,prod", graph.Nodes[0].Data!["tags"]);
+    }
+}

+ 265 - 0
Tests/Graph/MermaidSerialiserTests.cs

@@ -0,0 +1,265 @@
+using RackPeek.Domain.Graph;
+using RackPeek.Domain.Graph.Serialisers;
+
+namespace Tests.Graph;
+
+public sealed class MermaidSerialiserTests {
+    private readonly MermaidSerialiser _serialiser = new();
+
+    [Fact]
+    public void Empty_Graph_Renders_Header_And_ClassDef_Only() {
+        var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
+
+        Assert.Contains("flowchart TD", output);
+        Assert.Contains("classDef rpknode", output);
+        // No node lines because the only content after the classDef is blank.
+        Assert.DoesNotContain("[\"", output);
+    }
+
+    [Fact]
+    public void Renders_Step_Curve_Init_Directive() {
+        // Right-angle (Manhattan) edge routing — the convention for network
+        // diagrams. Anything else (linear/curved) reads as a flowchart.
+        var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
+        Assert.Contains("'curve': 'step'", output);
+    }
+
+    [Fact]
+    public void Direction_Override_Is_Honoured() {
+        var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty, "LR");
+        Assert.Contains("flowchart LR", output);
+    }
+
+    [Fact]
+    public void Node_Renders_Name_Only_When_No_Subtitle() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("srv-01", "srv-01", "Server")],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_srv_01[(\"srv-01\")]:::rpknode", output);
+        Assert.DoesNotContain("srv-01<br/>", output);
+    }
+
+    [Fact]
+    public void Subtitle_Renders_As_Second_Label_Line() {
+        // The serialiser is agnostic — each use case decides what's useful
+        // as a subtitle (kind for topology, ip[:port] for logical view).
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("srv-01", "srv-01", "Server", Subtitle: "192.168.0.10:8080")],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_srv_01[(\"srv-01<br/>192.168.0.10:8080\")]:::rpknode", output);
+    }
+
+    [Theory]
+    [InlineData("Firewall", "{{\"", "\"}}")]    // hexagon — security boundary
+    [InlineData("Router", "([\"", "\"])")]      // stadium — gateway
+    [InlineData("Switch", "[[\"", "\"]]")]      // subroutine — distribution
+    [InlineData("Server", "[(\"", "\")]")]      // cylinder — compute/storage
+    [InlineData("AccessPoint", "((\"", "\"))")] // circle — radio
+    [InlineData("Ups", "{\"", "\"}")]           // rhombus — utility
+    [InlineData("Desktop", "(\"", "\")")]       // rounded rect — endpoint
+    [InlineData("Laptop", "(\"", "\")")]        // rounded rect — endpoint
+    public void Kind_Maps_To_Documented_Mermaid_Shape(string kind, string openBracket, string closeBracket) {
+        // Shape conveys role at a glance without colour or icons — pin the
+        // mapping so a future refactor can't silently change diagrams.
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("x", "x", kind)],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains($"n_x{openBracket}x{closeBracket}", output);
+    }
+
+    [Fact]
+    public void Unknown_Kind_Falls_Back_To_Plain_Rectangle() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("x", "x", "Toaster")],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_x[\"x\"]:::rpknode", output);
+    }
+
+    [Fact]
+    public void All_Nodes_Share_A_Single_Visual_Class_Regardless_Of_Kind() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [
+                new GraphNode("a", "a", "Firewall"),
+                new GraphNode("b", "b", "Server"),
+                new GraphNode("c", "c", "Mystery")
+            ],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        // All three classed identically — no per-kind colour.
+        Assert.Equal(3, CountOccurrences(output, ":::rpknode"));
+    }
+
+    [Fact]
+    public void No_Emoji_Or_Icon_Appears_In_Output() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [
+                new GraphNode("srv-01", "srv-01", "Server"),
+                new GraphNode("fw-01", "fw-01", "Firewall"),
+                new GraphNode("sw-01", "sw-01", "Switch")
+            ],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        // Pin against the previous emoji-y design.
+        Assert.DoesNotContain("🖥", output);
+        Assert.DoesNotContain("🛡", output);
+        Assert.DoesNotContain("🔀", output);
+    }
+
+    [Fact]
+    public void Edge_With_Label_Renders_With_Pipe_Syntax() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
+            [new GraphEdge("a", "b", "eth0 ↔ port1", "connection")]);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_a ---|\"eth0 ↔ port1\"| n_b", output);
+    }
+
+    [Fact]
+    public void Edge_Without_Label_Renders_Plain_Line() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
+            [new GraphEdge("a", "b", null, "connection")]);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_a --- n_b", output);
+    }
+
+    [Fact]
+    public void RunsOn_Edge_Gets_An_Arrow() {
+        // Directional relationships need a visible arrowhead so the reader
+        // can tell which side depends on which.
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("svc", "svc", "Service"), new GraphNode("vm", "vm", "Vm")],
+            [new GraphEdge("svc", "vm", null, "runsOn")]);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_svc --> n_vm", output);
+        Assert.DoesNotContain("n_svc --- n_vm", output);
+    }
+
+    [Fact]
+    public void Connection_Edge_Stays_Plain_Line() {
+        // Port-to-port physical connections are symmetric; no arrow.
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
+            [new GraphEdge("a", "b", null, "connection")]);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_a --- n_b", output);
+        Assert.DoesNotContain("n_a --> n_b", output);
+    }
+
+    [Fact]
+    public void Edges_Get_Muted_Stroke_Via_LinkStyle() {
+        // A single linkStyle default rule keeps edges visually quiet so they
+        // never compete with node labels.
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
+            [new GraphEdge("a", "b", null, "connection")]);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("linkStyle default stroke:", output);
+    }
+
+    [Fact]
+    public void Node_Borders_Are_Dotted() {
+        // Pin the dashed-border styling — keeps the look intentionally light.
+        var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
+        Assert.Contains("stroke-dasharray:3 3", output);
+    }
+
+    [Fact]
+    public void Connection_Lines_Are_Dotted() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("a", "a", "Server"), new GraphNode("b", "b", "Switch")],
+            [new GraphEdge("a", "b", null, "connection")]);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("linkStyle default ", output);
+        Assert.Contains("stroke-dasharray:4 4", output);
+    }
+
+    [Fact]
+    public void Edge_Label_Background_Is_Transparent() {
+        // Solid label boxes feel chunky and clip the connection line. A
+        // transparent background lets labels read as floating annotations.
+        var output = _serialiser.Serialise(RackPeek.Domain.Graph.Graph.Empty);
+        Assert.Contains("'edgeLabelBackground': 'transparent'", output);
+    }
+
+    private static int CountOccurrences(string haystack, string needle) {
+        var count = 0;
+        var idx = 0;
+        while ((idx = haystack.IndexOf(needle, idx, StringComparison.Ordinal)) >= 0) {
+            count++;
+            idx += needle.Length;
+        }
+
+        return count;
+    }
+
+    [Fact]
+    public void Duplicate_Slugs_Get_Disambiguated() {
+        // "srv-01" and "srv_01" both slug to "srv_01" — the serialiser must
+        // produce distinct IDs so Mermaid doesn't collapse them into one node.
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [
+                new GraphNode("srv-01", "srv-01", "Server"),
+                new GraphNode("srv_01", "srv_01", "Server")
+            ],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("n_srv_01[", output);
+        Assert.Contains("n_srv_01_2[", output);
+    }
+
+    [Fact]
+    public void Special_Characters_In_Labels_Are_Escaped() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("x", "host \"with\" quotes", "Server")],
+            []);
+
+        var output = _serialiser.Serialise(graph);
+
+        Assert.Contains("host \\\"with\\\" quotes", output);
+    }
+
+    [Fact]
+    public void Edge_Referencing_Missing_Node_Is_Dropped() {
+        var graph = new RackPeek.Domain.Graph.Graph(
+            [new GraphNode("a", "a", "Server")],
+            [new GraphEdge("a", "ghost", null, "connection")]);
+
+        var output = _serialiser.Serialise(graph);
+
+        // Edge with a missing target must be silently dropped — Mermaid would
+        // otherwise emit a syntax error and the whole diagram would fail.
+        Assert.DoesNotContain("ghost", output);
+    }
+
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác