瀏覽代碼

Merge pull request #300 from Timmoth/feature/cli-tags

V2.0.0 - Diagrams
Tim Jones 3 周之前
父節點
當前提交
a5ab9df36e
共有 84 個文件被更改,包括 7230 次插入2054 次删除
  1. 1 1
      README.md
  2. 17 0
      RackPeek.Domain/Git/LibGit2GitRepository.cs
  3. 52 0
      RackPeek.Domain/Graph/Graph.cs
  4. 465 0
      RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs
  5. 188 0
      RackPeek.Domain/Graph/UseCases/BuildLogicalGraphUseCase.cs
  6. 79 0
      RackPeek.Domain/Graph/UseCases/BuildPhysicalTopologyUseCase.cs
  7. 23 12
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  8. 4 4
      RackPeek.Domain/RackPeek.Domain.csproj
  9. 1 1
      RackPeek.Domain/RpkConstants.cs
  10. 1 1
      RackPeek.Domain/ServiceCollectionExtensions.cs
  11. 167 172
      RackPeek.Web.Viewer/Pages/Home.razor
  12. 6 3
      RackPeek.Web.Viewer/RackPeek.Web.Viewer.csproj
  13. 4 0
      RackPeek.Web.Viewer/wwwroot/index.html
  14. 6 2
      RackPeek.Web/Components/App.razor
  15. 166 170
      RackPeek.Web/Components/Pages/Home.razor
  16. 11 11
      RackPeek/RackPeek.csproj
  17. 107 0
      Shared.Rcl/CliBootstrap.cs
  18. 22 0
      Shared.Rcl/Commands/Graph/GraphLogicalCommand.cs
  19. 25 0
      Shared.Rcl/Commands/Graph/GraphTopologyCommand.cs
  20. 9 0
      Shared.Rcl/Commands/Tags/ResourceTagSettings.cs
  21. 24 0
      Shared.Rcl/Commands/Tags/TagAddCommand.cs
  22. 24 0
      Shared.Rcl/Commands/Tags/TagRemoveCommand.cs
  23. 34 0
      Shared.Rcl/Commands/Tags/TagsListCommand.cs
  24. 40 0
      Shared.Rcl/Commands/Tags/TagsShowCommand.cs
  25. 86 0
      Shared.Rcl/Components/Graphs/GraphView.razor
  26. 30 2
      Shared.Rcl/Connections/PortConnectionModal.razor
  27. 8 1
      Shared.Rcl/Layout/GitStatusIndicator.razor
  28. 228 79
      Shared.Rcl/Layout/MainLayout.razor
  29. 2 2
      Shared.Rcl/Shared.Rcl.csproj
  30. 169 0
      Shared.Rcl/Visualise/VisualisePage.razor
  31. 101 14
      Shared.Rcl/YamlFileComponent.razor
  32. 108 7
      Shared.Rcl/YamlImportPage.razor
  33. 1 0
      Shared.Rcl/wwwroot/js/graph/chunks/mermaid-layout-elk.esm.min/chunk-SP2CHFBE.mjs
  34. 0 0
      Shared.Rcl/wwwroot/js/graph/chunks/mermaid-layout-elk.esm.min/render-YY74OMMT.mjs
  35. 378 0
      Shared.Rcl/wwwroot/js/graph/index.js
  36. 1 0
      Shared.Rcl/wwwroot/js/graph/mermaid-layout-elk.min.mjs
  37. 0 0
      Shared.Rcl/wwwroot/js/graph/mermaid.min.js
  38. 58 0
      Shared.Rcl/wwwroot/js/uiHelpers.js
  39. 227 141
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  40. 437 119
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  41. 1 0
      Shared.Rcl/wwwroot/raw_docs/docs-index.json
  42. 10 2
      Shared.Rcl/wwwroot/raw_docs/git-integration.md
  43. 227 0
      Shared.Rcl/wwwroot/raw_docs/inventory-api.md
  44. 64 0
      Tests.E2e/AccessPointCardTests.cs
  45. 6 2
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  46. 8 1
      Tests.E2e/PageObjectModels/PortsPom.cs
  47. 11 5
      Tests.E2e/Tests.E2e.csproj
  48. 8 0
      Tests.E2e/xunit.runner.json
  49. 182 0
      Tests/Api/InventoryEndpointTests.cs
  50. 30 0
      Tests/Api/InventoryEndpointUnconfiguredTests.cs
  51. 76 0
      Tests/EndToEnd/ExporterTests/HostsExportWorkflowTests.cs
  52. 147 0
      Tests/EndToEnd/ExporterTests/SshExportWorkflowTests.cs
  53. 57 0
      Tests/EndToEnd/Graph/GraphTopologyCliTests.cs
  54. 15 3
      Tests/EndToEnd/Infra/YamlCliTestHost.cs
  55. 74 0
      Tests/EndToEnd/Tags/TagsWorkflowTests.cs
  56. 78 0
      Tests/Git/AddRemoteUseCaseTests.cs
  57. 176 0
      Tests/Git/GitConfigurationTests.cs
  58. 118 0
      Tests/Git/GitIntegrationDocClaimsTests.cs
  59. 170 0
      Tests/Git/GitRepositoryAvailabilityTests.cs
  60. 30 0
      Tests/Git/TokenCredentialsProviderTests.cs
  61. 143 0
      Tests/Graph/BuildPhysicalTopologyUseCaseTests.cs
  62. 265 0
      Tests/Graph/MermaidSerialiserTests.cs
  63. 7 4
      Tests/Tests.csproj
  64. 227 141
      docs/CommandIndex.md
  65. 721 126
      docs/Commands.md
  66. 34 3
      vhs/capture.js
  67. 二進制
      vhs/rpk-demo.gif
  68. 24 0
      vhs/rpk.tape
  69. 1004 509
      vhs/sample_config/config.yaml
  70. 0 208
      vhs/sample_config/hardware.yaml
  71. 0 153
      vhs/sample_config/services.yaml
  72. 0 154
      vhs/sample_config/systems.yaml
  73. 7 1
      vhs/webui_capture.sh
  74. 二進制
      vhs/webui_screenshots/localhost:5287.png
  75. 二進制
      vhs/webui_screenshots/localhost:5287_cli.png
  76. 二進制
      vhs/webui_screenshots/localhost:5287_hardware_tree.png
  77. 二進制
      vhs/webui_screenshots/localhost:5287_resources_hardware_proxmox-node01.png
  78. 二進制
      vhs/webui_screenshots/localhost:5287_servers_list.png
  79. 二進制
      vhs/webui_screenshots/localhost:5287_services_list.png
  80. 二進制
      vhs/webui_screenshots/localhost:5287_systems_list.png
  81. 二進制
      vhs/webui_screenshots/localhost:5287_visualise_logical.png
  82. 二進制
      vhs/webui_screenshots/localhost:5287_visualise_topology.png
  83. 二進制
      vhs/webui_screenshots/localhost:5287_yaml.png
  84. 二進制
      vhs/webui_screenshots/output.gif

+ 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.

+ 17 - 0
RackPeek.Domain/Git/LibGit2GitRepository.cs

@@ -35,10 +35,26 @@ public sealed class LibGit2GitRepository : IGitRepository {
         bool insecureTls = false) {
         _configDirectory = configDirectory;
         _credentials = credentialsProvider.GetHandler();
+        // The user opted into git by setting GIT_TOKEN. Auto-init on a fresh
+        // config directory so the UI can immediately offer Add Remote — without
+        // this, first-time users hit "Git is not available." on every action.
+        // Init is idempotent for existing repos (IsValid skips the call) and
+        // does not touch existing files; it only creates .git/. Failure (e.g.
+        // a read-only mount) must not throw out of the singleton factory — the
+        // UI relies on IsAvailable=false to render the writability warning.
+        if (Directory.Exists(configDirectory) && !Repository.IsValid(configDirectory))
+            try {
+                Repository.Init(configDirectory);
+            }
+            catch {
+                // Leave IsAvailable=false; surfaced via the writability warning.
+            }
+
         _isAvailable = Repository.IsValid(configDirectory);
         // When insecureTls is true, accept any TLS certificate. Required for
         // self-hosted forges (Gitea, GitLab) behind a private CA or self-signed
         // cert. Public hosts already ship trusted certs; leave it off for them.
+        InsecureTls = insecureTls;
         _certificateCheck = insecureTls
             ? (_, _, _) => true
             : null;
@@ -57,6 +73,7 @@ public sealed class LibGit2GitRepository : IGitRepository {
     private bool _isAvailable;
 
     public bool IsAvailable => _isAvailable;
+    public bool InsecureTls { get; }
 
     public void Init() {
         Repository.Init(_configDirectory);

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

@@ -0,0 +1,52 @@
+namespace RackPeek.Domain.Graph;
+
+public record GraphNode(
+    string Id,
+    string Label,
+    string Kind,
+    string? Subtitle = null,
+    IReadOnlyDictionary<string, string>? Data = null,
+    IReadOnlyList<GraphNodeRow>? Rows = null);
+
+/// <summary>
+///     A bullet/list row rendered inside a node label. Used by the logical
+///     view to fold a host's services into a single host card.
+/// </summary>
+public record GraphNodeRow(string Name, string? Detail = 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);
+
+/// <summary>
+///     How a graph should be rendered. <see cref="Standard"/> is the
+///     hardware-topology view: one node per resource with shape-based kind
+///     signalling. <see cref="Compact"/> is the logical-services view:
+///     each host is a single card listing its services as rows, no edges,
+///     siblings packed vertically.
+/// </summary>
+public enum GraphRenderHint {
+    Standard,
+    Compact
+}
+
+public record Graph(
+    IReadOnlyList<GraphNode> Nodes,
+    IReadOnlyList<GraphEdge> Edges,
+    IReadOnlyList<GraphGroup>? Groups = null,
+    GraphRenderHint RenderHint = GraphRenderHint.Standard) {
+    public static Graph Empty { get; } = new([], [], null);
+}

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

@@ -0,0 +1,465 @@
+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";
+    private const string _smallRowClass = "rpkrow";
+
+    // Compact-mode (logical view) tuning. Small-row size controls how many
+    // single-service host cards pack into one invisible row before wrapping.
+    private const int _compactSmallRowSize = 4;
+    private const int _compactTableColumns = 3;
+
+    // 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") {
+        if (graph.RenderHint == GraphRenderHint.Compact)
+            return SerialiseCompact(graph, direction);
+
+        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.
+        // - `layout: elk`              : use the Mermaid 11 ELK plugin (the
+        //                                older `flowchart.defaultRenderer`
+        //                                still works but is the legacy path).
+        // - `elk.aspectRatio: 0.5`     : ask ELK to favour tall over wide so
+        //                                a host with dozens of services
+        //                                doesn't fan out into a single row
+        //                                kilometres long.
+        // - `layered.wrapping.strategy : MULTI_EDGE
+        //                                wraps an overlong layer into several
+        //                                shorter ones — exactly what large
+        //                                logical/service diagrams need.
+        sb.AppendLine(
+            "%%{init: {'layout': 'elk', 'flowchart': {'curve': 'step', 'nodeSpacing': 60, 'rankSpacing': 80, 'padding': 20, 'subGraphTitleMargin': {'top': 12, 'bottom': 12}}, 'elk': {'algorithm': 'layered', 'aspectRatio': 0.5, 'layered.wrapping.strategy': 'MULTI_EDGE', 'layered.nodePlacement.strategy': 'BRANDES_KOEPF'}, '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);
+
+    // ---------------------------------------------------------------------
+    // Compact mode (logical view): each system becomes a single "host card"
+    // whose label is an HTML table of its services. No edges are drawn —
+    // subgraph containment carries the runs-on relationship. Sibling cards
+    // are chained vertically via invisible ~~~ links so ELK doesn't fan
+    // them out into a kilometre-wide row, and single-row hosts are packed
+    // into invisible row subgraphs of N to use the horizontal space.
+    // ---------------------------------------------------------------------
+    private string SerialiseCompact(Graph graph, string direction) {
+        var sb = new StringBuilder();
+
+        // htmlLabels + securityLevel: 'loose' let us put raw HTML inside the
+        // node labels. aspectRatio is set above 0.5 because compact mode
+        // already wraps long sibling lists itself via the small-row packing.
+        sb.AppendLine(
+            "%%{init: {'layout': 'elk', 'flowchart': {'curve': 'step', 'nodeSpacing': 10, 'rankSpacing': 10, 'padding': 0, 'htmlLabels': true, 'subGraphTitleMargin': {'top': 0, 'bottom': 0}, 'titleTopMargin': 0}, 'securityLevel': 'loose', 'elk': {'algorithm': 'layered', 'padding': '[top=0,bottom=4,left=6,right=6]', 'spacing.nodeNode': 8, 'spacing.nodeNodeBetweenLayers': 8, 'spacing.componentComponent': 6, 'layered.spacing.nodeNodeBetweenLayers': 8, 'nodeLabels.placement': '[H_CENTER, V_TOP, INSIDE]'}, 'themeVariables': {'edgeLabelBackground': 'transparent', 'clusterBkg': 'transparent', 'clusterBorder': '" + _groupStroke + "'}}}%%");
+        sb.Append("flowchart ").AppendLine(direction);
+
+        EmitClassDefs(sb);
+        sb.Append("    classDef ").Append(_smallRowClass)
+            .AppendLine(" fill:none,stroke:none,color:transparent");
+        sb.AppendLine();
+
+        Dictionary<string, string> idMap = AssignSafeIds(graph.Nodes);
+
+        IReadOnlyList<GraphGroup> groups = graph.Groups ?? [];
+        var childGroups = groups
+            .GroupBy(g => g.ParentGroupId ?? string.Empty)
+            .ToDictionary(g => g.Key, g => g.ToList());
+        HashSet<string> groupedNodeIds = new(
+            groups.SelectMany(g => g.NodeIds), StringComparer.OrdinalIgnoreCase);
+
+        // Invisible chains and packed-row ids are collected during traversal
+        // and emitted in a block at the end.
+        var chains = new List<IReadOnlyList<string>>();
+        var smallRowIds = new List<string>();
+
+        void Emit(GraphGroup group, int indent) {
+            var pad = new string(' ', indent * 4);
+            sb.Append(pad).Append("subgraph ").Append(group.Id)
+                .Append(" [\"").Append(Escape(group.Label)).Append("\"]").AppendLine();
+
+            List<GraphGroup> subChildren =
+                childGroups.TryGetValue(group.Id, out List<GraphGroup>? cs) ? cs : new();
+            foreach (GraphGroup child in subChildren) Emit(child, indent + 1);
+            if (subChildren.Count > 1)
+                chains.Add(subChildren.Select(c => c.Id).ToList());
+
+            HashSet<string> nodesInChildren = new(
+                subChildren.SelectMany(c => CollectAllNodeIds(c, childGroups)),
+                StringComparer.OrdinalIgnoreCase);
+
+            // Partition the group's direct nodes into "big" cards (host with
+            // multiple service rows) and "small" cards (no rows or one row).
+            // Bigs get a dedicated row each; smalls pack horizontally.
+            var bigs = new List<GraphNode>();
+            var smalls = new List<GraphNode>();
+            foreach (var nodeId in group.NodeIds) {
+                if (nodesInChildren.Contains(nodeId)) continue;
+                GraphNode? node = graph.Nodes.FirstOrDefault(n =>
+                    string.Equals(n.Id, nodeId, StringComparison.OrdinalIgnoreCase));
+                if (node is null) continue;
+                if ((node.Rows?.Count ?? 0) > 1) bigs.Add(node);
+                else smalls.Add(node);
+            }
+
+            var verticalChain = new List<string>();
+
+            foreach (GraphNode b in bigs) {
+                EmitCompactNode(sb, b, idMap, indent + 1);
+                verticalChain.Add(idMap[b.Id]);
+            }
+
+            for (int i = 0, rowIdx = 0; i < smalls.Count; i += _compactSmallRowSize, rowIdx++) {
+                var slice = smalls.Skip(i).Take(_compactSmallRowSize).ToList();
+                // Single small host doesn't need an invisible row wrapper —
+                // wrapping adds another nested subgraph (with its own
+                // padding/title overhead) for no layout benefit.
+                if (slice.Count == 1) {
+                    EmitCompactNode(sb, slice[0], idMap, indent + 1);
+                    verticalChain.Add(idMap[slice[0].Id]);
+                    continue;
+                }
+                var rowId = group.Id + "__srow" + rowIdx;
+                smallRowIds.Add(rowId);
+                verticalChain.Add(rowId);
+                sb.Append(pad).Append("    subgraph ").Append(rowId).AppendLine(" [\" \"]");
+                sb.Append(pad).Append("        direction LR").AppendLine();
+                foreach (GraphNode s in slice)
+                    EmitCompactNode(sb, s, idMap, indent + 2);
+                sb.Append(pad).AppendLine("    end");
+                sb.Append(pad).Append("    ");
+                sb.AppendJoin(" ~~~ ", slice.Select(s => idMap[s.Id]));
+                sb.AppendLine();
+            }
+
+            if (verticalChain.Count > 1) chains.Add(verticalChain);
+
+            sb.Append(pad).AppendLine("end");
+        }
+
+        if (childGroups.TryGetValue(string.Empty, out List<GraphGroup>? topLevel)) {
+            foreach (GraphGroup g in topLevel) Emit(g, 1);
+            if (topLevel.Count > 1)
+                chains.Add(topLevel.Select(g => g.Id).ToList());
+        }
+
+        // Ungrouped nodes (uncommon in compact mode but render them sanely).
+        foreach (GraphNode node in graph.Nodes) {
+            if (groupedNodeIds.Contains(node.Id)) continue;
+            EmitCompactNode(sb, node, idMap, 1);
+        }
+
+        // Invisible vertical chains last — these are what tell ELK to stack
+        // siblings vertically instead of flowing into one long row.
+        if (chains.Count > 0) sb.AppendLine();
+        foreach (IReadOnlyList<string> chain in chains) {
+            if (chain.Count < 2) continue;
+            sb.Append("    ");
+            sb.AppendJoin(" ~~~ ", chain);
+            sb.AppendLine();
+        }
+
+        sb.AppendLine();
+        foreach (GraphGroup group in groups)
+            sb.Append("    class ").Append(group.Id).Append(' ').Append(_groupClass).AppendLine();
+        foreach (var rowId in smallRowIds)
+            sb.Append("    class ").Append(rowId).Append(' ').Append(_smallRowClass).AppendLine();
+
+        return sb.ToString();
+    }
+
+    private void EmitCompactNode(StringBuilder sb, GraphNode node, Dictionary<string, string> idMap, int indent) {
+        var safeId = idMap[node.Id];
+        Shape shape = ResolveShape(node.Kind);
+        var label = BuildCompactLabel(node);
+        sb.Append(new string(' ', indent * 4)).Append(safeId)
+            .Append(shape.Open).Append(label).Append(shape.Close)
+            .Append(":::").Append(_nodeClass)
+            .AppendLine();
+    }
+
+    private static string BuildCompactLabel(GraphNode node) {
+        var sb = new StringBuilder();
+        sb.Append("<div style='text-align:left;font-family:system-ui;padding:4px 6px'>");
+        sb.Append("<div style='font-weight:600;font-size:14px'>");
+        sb.Append(EscapeHtml(node.Label));
+        if (!string.IsNullOrWhiteSpace(node.Subtitle)) {
+            sb.Append(" - <span style='color:#9ca3af'>");
+            sb.Append(EscapeHtml(node.Subtitle!));
+            sb.Append("</span>");
+        }
+        sb.Append("</div>");
+
+        if (node.Rows is { Count: > 0 }) {
+            sb.Append("<hr style='border:none;border-top:1px dashed #52525b;margin:6px 0'>");
+            sb.Append("<table style='border-collapse:collapse;font-size:11px'>");
+            for (var i = 0; i < node.Rows.Count; i += _compactTableColumns) {
+                sb.Append("<tr>");
+                for (var c = 0; c < _compactTableColumns; c++) {
+                    var idx = i + c;
+                    if (idx >= node.Rows.Count) { sb.Append("<td></td>"); continue; }
+                    GraphNodeRow row = node.Rows[idx];
+                    sb.Append("<td style='padding:2px 10px 2px 0;white-space:nowrap'>");
+                    sb.Append("<span style='color:#e5e7eb'>").Append(EscapeHtml(row.Name)).Append("</span>");
+                    if (!string.IsNullOrEmpty(row.Detail))
+                        sb.Append("<span style='color:#71717a'>").Append(EscapeHtml(row.Detail!)).Append("</span>");
+                    sb.Append("</td>");
+                }
+                sb.Append("</tr>");
+            }
+            sb.Append("</table>");
+        }
+        sb.Append("</div>");
+        // Mermaid label is wrapped in "...", so any " in our HTML must be
+        // entity-encoded. We avoid literal " in inline styles by using
+        // single quotes; this last pass catches anything still embedded.
+        return sb.ToString().Replace("\"", "&quot;");
+    }
+
+    private static string EscapeHtml(string s) =>
+        s.Replace("&", "&amp;").Replace("<", "&lt;").Replace(">", "&gt;");
+}

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

@@ -0,0 +1,188 @@
+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. Each system (hypervisor, VM, LXC,
+///     container) becomes a single "host card" whose body lists every
+///     service running on it. Cards are grouped subnet → hardware. No edges
+///     are emitted — containment alone conveys "runs on", and the
+///     serialiser stacks siblings vertically via invisible links.
+/// </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;
+
+        // Group services by the system they ultimately run on. We resolve
+        // the immediate runsOn first — that's the host the service was
+        // declared against. Services whose immediate runsOn isn't a known
+        // system (e.g. it points at hardware or is missing) are dropped from
+        // the compact view since they have no host card to live inside.
+        var servicesByHost = new Dictionary<string, List<Service>>(StringComparer.OrdinalIgnoreCase);
+        foreach (Service service in services) {
+            var parent = service.RunsOn.FirstOrDefault();
+            if (parent is null) continue;
+            if (!byName.TryGetValue(parent, out Resource? parentResource)) continue;
+            if (parentResource is not SystemResource) continue;
+            if (!servicesByHost.TryGetValue(parent, out List<Service>? list))
+                servicesByHost[parent] = list = new List<Service>();
+            list.Add(service);
+        }
+
+        // Each system becomes a host card. Hosts without services still
+        // appear as a labelled card (e.g. a hypervisor that only contains
+        // VMs has no services running directly on it, but is still a
+        // meaningful logical entity).
+        var hostEntries = new List<HostEntry>();
+        foreach (SystemResource sys in systems) {
+            var ip = FindIp(sys, byName);
+            var subnet = SubnetCidr(ip, _defaultPrefix);
+            if (subnet is null) continue;
+            Hardware? parentHw = FindParentHardware(sys, byName);
+            servicesByHost.TryGetValue(sys.Name, out List<Service>? hostServices);
+            var rows = (hostServices ?? new List<Service>())
+                .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
+                .Select(s => new GraphNodeRow(s.Name, ServiceDetail(s)))
+                .ToList();
+            hostEntries.Add(new HostEntry(sys, subnet, parentHw?.Name, ip, rows));
+        }
+
+        var nodes = hostEntries
+            .OrderBy(e => e.Subnet, StringComparer.Ordinal)
+            .ThenBy(e => e.HardwareName ?? string.Empty, StringComparer.OrdinalIgnoreCase)
+            .ThenByDescending(e => e.Rows.Count) // big cards first within a hardware bucket
+            .ThenBy(e => e.System.Name, StringComparer.OrdinalIgnoreCase)
+            .Select(e => new GraphNode(
+                e.System.Name,
+                e.System.Name,
+                NodeKind(e.System),
+                e.Ip,
+                Rows: e.Rows.Count > 0 ? e.Rows : null))
+            .ToList();
+
+        List<GraphGroup> groups = BuildGroups(hostEntries);
+
+        return new Graph(nodes, [], groups, GraphRenderHint.Compact);
+    }
+
+    private static List<GraphGroup> BuildGroups(IReadOnlyList<HostEntry> entries) {
+        var groups = new List<GraphGroup>();
+
+        IOrderedEnumerable<IGrouping<string, HostEntry>> bySubnet = entries
+            .GroupBy(e => e.Subnet, StringComparer.Ordinal)
+            .OrderBy(g => g.Key, StringComparer.Ordinal);
+
+        foreach (IGrouping<string, HostEntry> subnetGroup in bySubnet) {
+            var subnetId = "g_" + Slug(subnetGroup.Key);
+
+            var directNodes = new List<string>();
+            IOrderedEnumerable<IGrouping<string?, HostEntry>> byHardware = subnetGroup
+                .GroupBy(e => e.HardwareName)
+                .OrderBy(g => g.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase);
+
+            foreach (IGrouping<string?, HostEntry> hwGroup in byHardware) {
+                if (hwGroup.Key is null) {
+                    directNodes.AddRange(hwGroup.Select(e => e.System.Name));
+                    continue;
+                }
+
+                var hwGroupId = subnetId + "__" + Slug(hwGroup.Key);
+                groups.Add(new GraphGroup(
+                    hwGroupId,
+                    hwGroup.Key,
+                    hwGroup.Select(e => e.System.Name).ToList(),
+                    subnetId));
+            }
+
+            groups.Add(new GraphGroup(subnetId, subnetGroup.Key, directNodes, null));
+        }
+
+        return groups;
+    }
+
+    private static string NodeKind(SystemResource sys) {
+        if (string.IsNullOrWhiteSpace(sys.Type)) return "System";
+        var t = sys.Type.Trim().ToLowerInvariant();
+        return t switch {
+            "hypervisor" => "Hypervisor",
+            "vm" => "Vm",
+            "container" => "Container",
+            _ => "System"
+        };
+    }
+
+    private static string? ServiceDetail(Service service) {
+        var port = service.Network?.Port;
+        return port.HasValue ? ":" + port.Value : null;
+    }
+
+    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 readonly record struct HostEntry(
+        SystemResource System,
+        string Subnet,
+        string? HardwareName,
+        string? Ip,
+        IReadOnlyList<GraphNodeRow> Rows);
+}

+ 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}";
+    }
+}

+ 23 - 12
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -182,23 +182,34 @@ public sealed class YamlResourceCollection(
     }
 
     public async Task LoadAsync() {
-        var yaml = await fileStore.ReadAllTextAsync(filePath);
+        // Routes.razor calls LoadAsync on every Blazor circuit init, so
+        // multiple tabs / fresh page loads can run this concurrently. Without
+        // the lock, two callers can interleave Resources.Clear() and
+        // AddRange() and corrupt the List<T>'s internal _size, producing
+        // "Index was outside the bounds of the array" out of List.Clear.
+        await resourceCollection.FileLock.WaitAsync();
+        try {
+            var yaml = await fileStore.ReadAllTextAsync(filePath);
 
-        YamlRoot root = await migrationService.DeserializeAsync(
-            yaml,
-            async originalYaml => await BackupOriginalAsync(originalYaml),
-            async migratedRoot => await SaveRootAsync(migratedRoot)
-        );
+            YamlRoot root = await migrationService.DeserializeAsync(
+                yaml,
+                async originalYaml => await BackupOriginalAsync(originalYaml),
+                async migratedRoot => await SaveRootAsync(migratedRoot)
+            );
 
-        resourceCollection.Resources.Clear();
+            resourceCollection.Resources.Clear();
 
-        if (root.Resources != null)
-            resourceCollection.Resources.AddRange(root.Resources);
+            if (root.Resources != null)
+                resourceCollection.Resources.AddRange(root.Resources);
 
-        resourceCollection.Connections.Clear();
+            resourceCollection.Connections.Clear();
 
-        if (root.Connections != null)
-            resourceCollection.Connections.AddRange(root.Connections);
+            if (root.Connections != null)
+                resourceCollection.Connections.AddRange(root.Connections);
+        }
+        finally {
+            resourceCollection.FileLock.Release();
+        }
     }
 
     public Task AddAsync(Resource resource) {

+ 4 - 4
RackPeek.Domain/RackPeek.Domain.csproj

@@ -9,10 +9,10 @@
     <ItemGroup>
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
         <PackageReference Include="LibGit2Sharp" Version="0.31.0" />
-        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8" />
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8" />
-        <PackageReference Include="YamlDotNet" Version="17.1.0" />
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
+        <PackageReference Include="YamlDotNet" Version="18.0.0" />
     </ItemGroup>
 
 </Project>

+ 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; }
 }

+ 1 - 1
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -28,7 +28,7 @@ public static class ServiceCollectionExtensions {
         IConfiguration config,
         string? yamlPath = null) {
         var gitToken = config["GIT_TOKEN"];
-        if (!string.IsNullOrEmpty(gitToken) && !string.IsNullOrWhiteSpace(yamlPath)) {
+        if (!string.IsNullOrWhiteSpace(gitToken) && !string.IsNullOrWhiteSpace(yamlPath)) {
             var gitUsername = config["GIT_USERNAME"] ?? "git";
             var insecureTls = string.Equals(
                 config["GIT_INSECURE_TLS"], "true", StringComparison.OrdinalIgnoreCase);

+ 167 - 172
RackPeek.Web.Viewer/Pages/Home.razor

@@ -1,218 +1,195 @@
-@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>
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
+
     @if (_loading)
     {
         <div class="text-zinc-500">loading summary…</div>
     }
     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">
-                        ├─ Total (@_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>
     }
@@ -224,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;
     }
-
 }

+ 6 - 3
RackPeek.Web.Viewer/RackPeek.Web.Viewer.csproj

@@ -8,10 +8,13 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.8" PrivateAssets="all"/>
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
+        <!-- Microsoft.DotNet.HotReload.WebAssembly.Browser is provided by the
+             .NET 10 BlazorWebAssembly SDK — adding an explicit PackageReference
+             causes NU1504 "duplicate PackageReference" during restore. -->
         <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.55.2"/>
+        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0" />
     </ItemGroup>
 
     <ItemGroup>

+ 4 - 0
RackPeek.Web.Viewer/wwwroot/index.html

@@ -11,6 +11,10 @@
     <script src="tailwind.js"></script>
     <script src="./storage.js"></script>
     <script src="console.js"></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>
     <script type="importmap"></script>
     <link href="app.css" rel="stylesheet"/>
 </head>

+ 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;
     }
-
 }

+ 11 - 11
RackPeek/RackPeek.csproj

@@ -5,21 +5,21 @@
         <TargetFramework>net10.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
-        <AssemblyVersion>1.4.0</AssemblyVersion>
+        <AssemblyVersion>2.0.0</AssemblyVersion>
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8"/>
-        <PackageReference Include="Spectre.Console" Version="0.55.2"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.9"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9"/>
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9"/>
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9"/>
+        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.9"/>
+        <PackageReference Include="Spectre.Console" Version="0.56.0"/>
         <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.55.2"/>
-        <PackageReference Include="YamlDotNet" Version="17.1.0"/>
+        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0"/>
+        <PackageReference Include="YamlDotNet" Version="18.0.0"/>
     </ItemGroup>
 
     <ItemGroup>

+ 107 - 0
Shared.Rcl/CliBootstrap.cs

@@ -5,6 +5,15 @@ using RackPeek.Domain;
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.SystemResources;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
@@ -22,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;
@@ -49,6 +59,7 @@ using Shared.Rcl.Commands.Switches.Rename;
 using Shared.Rcl.Commands.Systems;
 using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Rename;
+using Shared.Rcl.Commands.Tags;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
 using Shared.Rcl.Commands.Ups.Rename;
@@ -204,6 +215,13 @@ public static class CliBootstrap {
                     label.AddCommand<ServerLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a server.");
                 });
+
+                // Server Tags
+                server.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a server.");
+                    tag.AddCommand<TagAddCommand<Server>>("add").WithDescription("Add a tag to a server.");
+                    tag.AddCommand<TagRemoveCommand<Server>>("remove").WithDescription("Remove a tag from a server.");
+                });
             });
 
             // ----------------------------
@@ -249,6 +267,12 @@ public static class CliBootstrap {
                     label.AddCommand<SwitchLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a switch.");
                 });
+
+                switches.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a switch.");
+                    tag.AddCommand<TagAddCommand<Switch>>("add").WithDescription("Add a tag to a switch.");
+                    tag.AddCommand<TagRemoveCommand<Switch>>("remove").WithDescription("Remove a tag from a switch.");
+                });
             });
 
             // ----------------------------
@@ -294,6 +318,12 @@ public static class CliBootstrap {
                     label.AddCommand<RouterLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a router.");
                 });
+
+                routers.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a router.");
+                    tag.AddCommand<TagAddCommand<Router>>("add").WithDescription("Add a tag to a router.");
+                    tag.AddCommand<TagRemoveCommand<Router>>("remove").WithDescription("Remove a tag from a router.");
+                });
             });
 
             // ----------------------------
@@ -339,6 +369,13 @@ public static class CliBootstrap {
                     label.AddCommand<FirewallLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a firewall.");
                 });
+
+                firewalls.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a firewall.");
+                    tag.AddCommand<TagAddCommand<Firewall>>("add").WithDescription("Add a tag to a firewall.");
+                    tag.AddCommand<TagRemoveCommand<Firewall>>("remove")
+                        .WithDescription("Remove a tag from a firewall.");
+                });
             });
 
             // ----------------------------
@@ -375,6 +412,13 @@ public static class CliBootstrap {
                     label.AddCommand<SystemLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a system.");
                 });
+
+                system.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a system.");
+                    tag.AddCommand<TagAddCommand<SystemResource>>("add").WithDescription("Add a tag to a system.");
+                    tag.AddCommand<TagRemoveCommand<SystemResource>>("remove")
+                        .WithDescription("Remove a tag from a system.");
+                });
             });
 
             // ----------------------------
@@ -409,6 +453,14 @@ public static class CliBootstrap {
                     label.AddCommand<AccessPointLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from an access point.");
                 });
+
+                ap.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on an access point.");
+                    tag.AddCommand<TagAddCommand<AccessPoint>>("add")
+                        .WithDescription("Add a tag to an access point.");
+                    tag.AddCommand<TagRemoveCommand<AccessPoint>>("remove")
+                        .WithDescription("Remove a tag from an access point.");
+                });
             });
 
             // ----------------------------
@@ -442,6 +494,14 @@ public static class CliBootstrap {
                     label.AddCommand<UpsLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a UPS unit.");
                 });
+
+                ups.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a UPS unit.");
+                    tag.AddCommand<TagAddCommand<RackPeek.Domain.Resources.UpsUnits.Ups>>("add")
+                        .WithDescription("Add a tag to a UPS unit.");
+                    tag.AddCommand<TagRemoveCommand<RackPeek.Domain.Resources.UpsUnits.Ups>>("remove")
+                        .WithDescription("Remove a tag from a UPS unit.");
+                });
             });
 
             // ----------------------------
@@ -507,6 +567,13 @@ public static class CliBootstrap {
                     label.AddCommand<DesktopLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a desktop.");
                 });
+
+                desktops.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a desktop.");
+                    tag.AddCommand<TagAddCommand<Desktop>>("add").WithDescription("Add a tag to a desktop.");
+                    tag.AddCommand<TagRemoveCommand<Desktop>>("remove")
+                        .WithDescription("Remove a tag from a desktop.");
+                });
             });
 
             // ----------------------------
@@ -562,6 +629,13 @@ public static class CliBootstrap {
                     label.AddCommand<LaptopLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a laptop.");
                 });
+
+                laptops.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a laptop.");
+                    tag.AddCommand<TagAddCommand<Laptop>>("add").WithDescription("Add a tag to a laptop.");
+                    tag.AddCommand<TagRemoveCommand<Laptop>>("remove")
+                        .WithDescription("Remove a tag from a laptop.");
+                });
             });
 
             // ----------------------------
@@ -598,6 +672,13 @@ public static class CliBootstrap {
                     label.AddCommand<ServiceLabelRemoveCommand>("remove")
                         .WithDescription("Remove a label from a service.");
                 });
+
+                service.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on a service.");
+                    tag.AddCommand<TagAddCommand<Service>>("add").WithDescription("Add a tag to a service.");
+                    tag.AddCommand<TagRemoveCommand<Service>>("remove")
+                        .WithDescription("Remove a tag from a service.");
+                });
             });
 
             // ----------------------------
@@ -624,6 +705,32 @@ 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
+            // ----------------------------
+            config.AddBranch("tags", tags => {
+                tags.SetDescription("Discover tags across resources.");
+
+                tags.AddCommand<TagsListCommand>("list")
+                    .WithDescription("List all tags in use with usage counts.");
+
+                tags.AddCommand<TagsShowCommand>("show")
+                    .WithDescription("List resources carrying a specific tag.");
+            });
+
             config.AddBranch("connections", connections => {
                 connections.SetDescription("Manage physical or logical port connections.");
 

+ 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;
+    }
+}

+ 9 - 0
Shared.Rcl/Commands/Tags/ResourceTagSettings.cs

@@ -0,0 +1,9 @@
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class ResourceTagSettings : CommandSettings {
+    [CommandArgument(0, "<name>")] public string Name { get; set; } = default!;
+
+    [CommandArgument(1, "<tag>")] public string Tag { get; set; } = default!;
+}

+ 24 - 0
Shared.Rcl/Commands/Tags/TagAddCommand.cs

@@ -0,0 +1,24 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.UseCases.Tags;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagAddCommand<T>(IServiceProvider serviceProvider)
+    : AsyncCommand<ResourceTagSettings>
+    where T : Resource {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ResourceTagSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IAddTagUseCase<T> useCase = scope.ServiceProvider.GetRequiredService<IAddTagUseCase<T>>();
+
+        await useCase.ExecuteAsync(settings.Name, settings.Tag);
+
+        AnsiConsole.MarkupLine($"[green]Tag '{settings.Tag}' added to '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 24 - 0
Shared.Rcl/Commands/Tags/TagRemoveCommand.cs

@@ -0,0 +1,24 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.UseCases.Tags;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagRemoveCommand<T>(IServiceProvider serviceProvider)
+    : AsyncCommand<ResourceTagSettings>
+    where T : Resource {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ResourceTagSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRemoveTagUseCase<T> useCase = scope.ServiceProvider.GetRequiredService<IRemoveTagUseCase<T>>();
+
+        await useCase.ExecuteAsync(settings.Name, settings.Tag);
+
+        AnsiConsole.MarkupLine($"[green]Tag '{settings.Tag}' removed from '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 34 - 0
Shared.Rcl/Commands/Tags/TagsListCommand.cs

@@ -0,0 +1,34 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Persistence;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagsListCommand(IServiceProvider serviceProvider) : AsyncCommand {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IResourceCollection repo = scope.ServiceProvider.GetRequiredService<IResourceCollection>();
+
+        Dictionary<string, int> tags = await repo.GetTagsAsync();
+
+        if (tags.Count == 0) {
+            AnsiConsole.MarkupLine("[grey]No tags in use.[/]");
+            return 0;
+        }
+
+        Table table = new Table()
+            .Border(TableBorder.Rounded)
+            .Title("[bold]Tags[/]")
+            .AddColumn("Tag")
+            .AddColumn(new TableColumn("Count").RightAligned());
+
+        foreach ((var tag, var count) in tags.OrderByDescending(t => t.Value).ThenBy(t => t.Key))
+            table.AddRow(tag.EscapeMarkup(), count.ToString());
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 40 - 0
Shared.Rcl/Commands/Tags/TagsShowCommand.cs

@@ -0,0 +1,40 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Tags;
+
+public class TagsShowSettings : CommandSettings {
+    [CommandArgument(0, "<tag>")] public string Tag { get; set; } = default!;
+}
+
+public class TagsShowCommand(IServiceProvider serviceProvider) : AsyncCommand<TagsShowSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        TagsShowSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IResourceCollection repo = scope.ServiceProvider.GetRequiredService<IResourceCollection>();
+
+        IReadOnlyList<Resource> resources = await repo.GetByTagAsync(settings.Tag);
+
+        if (resources.Count == 0) {
+            AnsiConsole.MarkupLine($"[grey]No resources tagged '{settings.Tag.EscapeMarkup()}'.[/]");
+            return 0;
+        }
+
+        Table table = new Table()
+            .Border(TableBorder.Rounded)
+            .Title($"[bold]Resources tagged '{settings.Tag.EscapeMarkup()}'[/]")
+            .AddColumn("Name")
+            .AddColumn("Kind");
+
+        foreach (Resource resource in resources.OrderBy(r => r.Kind).ThenBy(r => r.Name))
+            table.AddRow(resource.Name.EscapeMarkup(), resource.Kind.EscapeMarkup());
+
+        AnsiConsole.Write(table);
+        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);
     }
 

+ 8 - 1
Shared.Rcl/Layout/GitStatusIndicator.razor

@@ -43,7 +43,14 @@
         </button>
     }
 
-    @if (!_hasRemote)
+    @if (_status == GitRepoStatus.NotAvailable)
+    {
+        <span class="flex items-center gap-1 text-amber-400" data-testid="git-unavailable">
+            <span class="w-2 h-2 rounded-full bg-amber-400"></span>
+            Git configured but config directory is not writable
+        </span>
+    }
+    else if (!_hasRemote)
     {
         <span class="text-zinc-600">·</span>
 

+ 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);
+}

+ 2 - 2
Shared.Rcl/Shared.Rcl.csproj

@@ -15,9 +15,9 @@
 
     <ItemGroup>
         <PackageReference Include="Markdig" Version="1.2.0"/>
-        <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.8"/>
+        <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.9" />
         <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.55.2"/>
+        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0" />
     </ItemGroup>
 
     <ItemGroup>

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

@@ -0,0 +1,169 @@
+@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-png"
+                        @onclick="ExportPngAsync">
+                    Save PNG
+                </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 ExportPngAsync()
+    {
+        if (CannotExport) return;
+        await JS.InvokeVoidAsync(
+            "rackpeekGraph.downloadPng", HostId, $"{ExportFilenameBase}.png", ExportBackground, 2);
+    }
+
+    private const string ExportBackground = "#09090b";
+
+    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";
+}

+ 101 - 14
Shared.Rcl/YamlFileComponent.razor

@@ -1,4 +1,6 @@
 @using System.Collections.Specialized
+@using System.Text
+@using YamlDotNet.Core
 @using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Persistence.Yaml
 @using RackPeek.Domain.Resources
@@ -59,10 +61,38 @@
         </textarea>
 
 
-        @if (!string.IsNullOrEmpty(_validationError))
+        @if (_error is not null)
         {
-            <div class="mt-2 text-red-400 text-xs">
-                @_validationError
+            <div class="mt-3 border border-red-500/40 bg-red-500/10 rounded p-3"
+                 data-testid="yaml-file-error"
+                 role="alert">
+
+                <div class="flex items-start gap-2">
+                    <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 flex-shrink-0 text-red-400 mt-0.5"
+                         fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                        <path stroke-linecap="round" stroke-linejoin="round"
+                              d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
+                    </svg>
+
+                    <div class="min-w-0 flex-1">
+                        <div class="text-red-400 text-sm font-semibold">
+                            @_error.Headline
+                        </div>
+
+                        @if (_error.Line is long line)
+                        {
+                            <div class="text-zinc-400 text-xs mt-1">
+                                Line @line@(_error.Column is long col ? $", column {col}" : "")
+                            </div>
+                        }
+
+                        @if (!string.IsNullOrEmpty(_error.Snippet))
+                        {
+                            <pre class="mt-2 p-2 rounded bg-zinc-950 border border-zinc-800 text-xs overflow-x-auto text-zinc-300"
+                                 data-testid="yaml-file-error-snippet">@_error.Snippet</pre>
+                        }
+                    </div>
+                </div>
             </div>
         }
     }
@@ -97,7 +127,7 @@
 
     string _currentText = "";
     string _editText = "";
-    string? _validationError;
+    YamlEditError? _error;
 
     protected override async Task OnParametersSetAsync()
     {
@@ -117,21 +147,21 @@
     void BeginEdit()
     {
         _editText = _currentText;
-        _validationError = null;
+        _error = null;
         _isEditing = true;
     }
 
     void Cancel()
     {
         _isEditing = false;
-        _validationError = null;
+        _error = null;
     }
 
     async Task Save()
     {
-        if (!ValidateYamlRoundTrip(_editText, out var error))
+        if (!ValidateYamlRoundTrip(_editText, out var err))
         {
-            _validationError = error;
+            _error = err;
             return;
         }
 
@@ -160,13 +190,13 @@
             await OnDeleted.InvokeAsync(Path);
     }
 
-    private bool ValidateYamlRoundTrip(string yaml, out string? error)
+    private bool ValidateYamlRoundTrip(string yaml, out YamlEditError? error)
     {
         try
         {
             if (string.IsNullOrWhiteSpace(yaml))
             {
-                error = "YAML is empty.";
+                error = new YamlEditError("YAML is empty.", null, null, null);
                 return false;
             }
 
@@ -197,7 +227,7 @@
 
             if (root?.Resources == null)
             {
-                error = "No resources section found.";
+                error = new YamlEditError("No resources section found.", null, null, null);
                 return false;
             }
 
@@ -218,7 +248,7 @@
 
             if (root2?.Resources == null)
             {
-                error = "Round-trip serialization failed.";
+                error = new YamlEditError("Round-trip serialization failed.", null, null, null);
                 return false;
             }
 
@@ -229,7 +259,7 @@
 
             if (dup != null)
             {
-                error = $"Duplicate resource name: '{dup.Key}'";
+                error = new YamlEditError($"Duplicate resource name: '{dup.Key}'", null, null, null);
                 return false;
             }
 
@@ -238,9 +268,66 @@
         }
         catch (Exception ex)
         {
-            error = $"YAML validation failed: {ex.Message}";
+            error = BuildEditError(ex, yaml);
             return false;
         }
     }
 
+    private static YamlEditError BuildEditError(Exception ex, string yaml)
+    {
+        YamlException? ye = FindYamlException(ex);
+        if (ye is not null)
+        {
+            long? line = ye.Start.Line > 0 ? ye.Start.Line : null;
+            long? col = ye.Start.Column > 0 ? ye.Start.Column : null;
+            return new YamlEditError(
+                $"YAML invalid: {FirstLine(ye.Message)}",
+                line,
+                col,
+                line is long l ? ExtractSnippet(yaml, (int)l) : null);
+        }
+
+        return new YamlEditError($"YAML validation failed: {FirstLine(ex.Message)}", null, null, null);
+    }
+
+    private static YamlException? FindYamlException(Exception? ex)
+    {
+        while (ex is not null)
+        {
+            if (ex is YamlException ye) return ye;
+            ex = ex.InnerException;
+        }
+        return null;
+    }
+
+    private static string FirstLine(string message)
+    {
+        if (string.IsNullOrEmpty(message)) return string.Empty;
+        var nl = message.IndexOf('\n');
+        return nl < 0 ? message.Trim() : message[..nl].Trim();
+    }
+
+    private static string ExtractSnippet(string yaml, int lineNumber, int context = 2)
+    {
+        var lines = yaml.Replace("\r\n", "\n").Split('\n');
+        if (lineNumber < 1 || lineNumber > lines.Length) return string.Empty;
+
+        var start = Math.Max(1, lineNumber - context);
+        var end = Math.Min(lines.Length, lineNumber + context);
+
+        var sb = new StringBuilder();
+        for (int i = start; i <= end; i++)
+        {
+            sb.Append(i == lineNumber ? "→ " : "  ")
+                .Append(i.ToString().PadLeft(4))
+                .Append("  ")
+                .Append(lines[i - 1]);
+            if (i < end) sb.Append('\n');
+        }
+
+        return sb.ToString();
+    }
+
+    private sealed record YamlEditError(string Headline, long? Line, long? Column, string? Snippet);
+
 }

+ 108 - 7
Shared.Rcl/YamlImportPage.razor

@@ -1,6 +1,9 @@
 @page "/yaml/import"
+@using System.ComponentModel.DataAnnotations
+@using System.Text
 @using RackPeek.Domain.Api
 @using RackPeek.Domain.Persistence
+@using YamlDotNet.Core
 <PageTitle>Yaml Import</PageTitle>
 
 @inject UpsertInventoryUseCase ImportUseCase
@@ -53,10 +56,39 @@
               @bind:after="ComputeDiff">
     </textarea>
 
-    @if (!string.IsNullOrEmpty(_validationError))
+    @if (_error is not null)
     {
-        <div class="text-red-400 text-xs mb-3">
-            @_validationError
+        <div class="border border-red-500/40 bg-red-500/10 rounded p-3 mb-3"
+             data-testid="yaml-import-error"
+             role="alert">
+
+            <div class="flex items-start gap-2">
+                <!-- alert icon -->
+                <svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5 flex-shrink-0 text-red-400 mt-0.5"
+                     fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2">
+                    <path stroke-linecap="round" stroke-linejoin="round"
+                          d="M12 9v3.75m9-.75a9 9 0 11-18 0 9 9 0 0118 0zm-9 3.75h.008v.008H12v-.008z"/>
+                </svg>
+
+                <div class="min-w-0 flex-1">
+                    <div class="text-red-400 text-sm font-semibold">
+                        @_error.Headline
+                    </div>
+
+                    @if (_error.Line is long line)
+                    {
+                        <div class="text-zinc-400 text-xs mt-1">
+                            Line @line@(_error.Column is long col ? $", column {col}" : "")
+                        </div>
+                    }
+
+                    @if (!string.IsNullOrEmpty(_error.Snippet))
+                    {
+                        <pre class="mt-2 p-2 rounded bg-zinc-950 border border-zinc-800 text-xs overflow-x-auto text-zinc-300"
+                             data-testid="yaml-import-error-snippet">@_error.Snippet</pre>
+                    }
+                </div>
+            </div>
         </div>
     }
 
@@ -177,7 +209,7 @@
     private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
 
     private string _inputYaml = "";
-    private string? _validationError;
+    private ImportError? _error;
     private bool _isValid;
 
     private MergeMode _mode = MergeMode.Merge;
@@ -190,7 +222,7 @@
 
     async Task ComputeDiff()
     {
-        _validationError = null;
+        _error = null;
         _isValid = false;
 
         _added.Clear();
@@ -222,7 +254,7 @@
         }
         catch (Exception ex)
         {
-            _validationError = $"YAML invalid: {ex.Message}";
+            _error = BuildError(ex, headlinePrefix: "YAML invalid");
         }
     }
 
@@ -249,8 +281,77 @@
         }
         catch (Exception ex)
         {
-            _validationError = $"Apply failed: {ex.Message}";
+            _error = BuildError(ex, headlinePrefix: "Apply failed");
+        }
+    }
+
+    private ImportError BuildError(Exception ex, string headlinePrefix)
+    {
+        // YamlDotNet wraps the underlying parser error; walk InnerExceptions
+        // to find the YamlException so we can extract Line/Column for the
+        // user.
+        YamlException? yamlEx = FindYamlException(ex);
+        if (yamlEx is not null)
+        {
+            long? line = yamlEx.Start.Line > 0 ? yamlEx.Start.Line : null;
+            long? col = yamlEx.Start.Column > 0 ? yamlEx.Start.Column : null;
+            return new ImportError(
+                $"{headlinePrefix}: {FirstLine(yamlEx.Message)}",
+                line,
+                col,
+                line is long l ? ExtractSnippet(_inputYaml, (int)l) : null);
         }
+
+        // Domain validation errors (duplicate names, missing version, etc.)
+        // are already user-friendly — just surface them prominently.
+        if (ex is ValidationException ve)
+            return new ImportError(ve.Message, null, null, null);
+
+        return new ImportError($"{headlinePrefix}: {FirstLine(ex.Message)}", null, null, null);
     }
 
+    private static YamlException? FindYamlException(Exception? ex)
+    {
+        while (ex is not null)
+        {
+            if (ex is YamlException ye) return ye;
+            ex = ex.InnerException;
+        }
+
+        return null;
+    }
+
+    private static string FirstLine(string message)
+    {
+        if (string.IsNullOrEmpty(message)) return string.Empty;
+        var nl = message.IndexOf('\n');
+        return nl < 0 ? message.Trim() : message[..nl].Trim();
+    }
+
+    private static string ExtractSnippet(string yaml, int lineNumber, int context = 2)
+    {
+        // Render `context` lines before and after the offending line, prefix
+        // each with its 1-based line number, and mark the bad line with an
+        // arrow so the eye lands on it instantly.
+        var lines = yaml.Replace("\r\n", "\n").Split('\n');
+        if (lineNumber < 1 || lineNumber > lines.Length) return string.Empty;
+
+        var start = Math.Max(1, lineNumber - context);
+        var end = Math.Min(lines.Length, lineNumber + context);
+
+        var sb = new StringBuilder();
+        for (int i = start; i <= end; i++)
+        {
+            sb.Append(i == lineNumber ? "→ " : "  ")
+                .Append(i.ToString().PadLeft(4))
+                .Append("  ")
+                .Append(lines[i - 1]);
+            if (i < end) sb.Append('\n');
+        }
+
+        return sb.ToString();
+    }
+
+    private sealed record ImportError(string Headline, long? Line, long? Column, string? Snippet);
+
 }

+ 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};

文件差異過大導致無法顯示
+ 0 - 0
Shared.Rcl/wwwroot/js/graph/chunks/mermaid-layout-elk.esm.min/render-YY74OMMT.mjs


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

@@ -0,0 +1,378 @@
+// 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, background) {
+        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]);
+            }
+        }
+
+        // Inject a full-bleed background rect so exports match the in-app
+        // appearance instead of rendering on a transparent canvas (which
+        // shows as white in most viewers / dark in others depending on OS).
+        const bg = (background ?? "#18181b").trim();
+        if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
+            const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+            const vbParts = (vb || "").split(/\s+/);
+            if (vbParts.length === 4) {
+                rect.setAttribute("x", vbParts[0]);
+                rect.setAttribute("y", vbParts[1]);
+                rect.setAttribute("width", vbParts[2]);
+                rect.setAttribute("height", vbParts[3]);
+            } else {
+                rect.setAttribute("x", "0");
+                rect.setAttribute("y", "0");
+                rect.setAttribute("width", "100%");
+                rect.setAttribute("height", "100%");
+            }
+            rect.setAttribute("fill", bg);
+            clone.insertBefore(rect, clone.firstChild);
+        }
+
+        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);
+    }
+
+    function buildExportSvg(host, background) {
+        const svg = host.querySelector("svg");
+        if (!svg) return null;
+
+        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");
+        }
+
+        // Prefer viewBox dimensions — Mermaid sets `width="100%"` on the
+        // live SVG so that the responsive page layout can size it. Parsing
+        // that as a number gives 100 (px), producing a postage-stamp PNG.
+        // The viewBox carries the real document dimensions.
+        const vb = clone.getAttribute("viewBox");
+        const vbParts = (vb || "").split(/\s+/);
+        let width = 0, height = 0;
+        if (vbParts.length === 4) {
+            width = parseFloat(vbParts[2]) || 0;
+            height = parseFloat(vbParts[3]) || 0;
+        }
+        if (!width || !height) {
+            const rect = svg.getBoundingClientRect();
+            if (!width) width = rect.width;
+            if (!height) height = rect.height;
+        }
+        // Pin explicit pixel dimensions so `new Image()` knows how to size
+        // the bitmap. Strip any % units inherited from the live element.
+        clone.setAttribute("width", String(width));
+        clone.setAttribute("height", String(height));
+        clone.removeAttribute("style");
+
+        const bg = (background ?? "#18181b").trim();
+        if (bg && bg.toLowerCase() !== "transparent" && bg.toLowerCase() !== "none") {
+            const rect = document.createElementNS("http://www.w3.org/2000/svg", "rect");
+            if (vbParts.length === 4) {
+                rect.setAttribute("x", vbParts[0]);
+                rect.setAttribute("y", vbParts[1]);
+                rect.setAttribute("width", vbParts[2]);
+                rect.setAttribute("height", vbParts[3]);
+            } else {
+                rect.setAttribute("x", "0");
+                rect.setAttribute("y", "0");
+                rect.setAttribute("width", String(width));
+                rect.setAttribute("height", String(height));
+            }
+            rect.setAttribute("fill", bg);
+            clone.insertBefore(rect, clone.firstChild);
+        }
+
+        const serialiser = new XMLSerializer();
+        const body = serialiser.serializeToString(clone);
+        return { xml: body, width, height };
+    }
+
+    function downloadSvg(elementId, filename, background) {
+        const host = document.getElementById(elementId);
+        if (!host) {
+            console.warn(`[rackpeekGraph] element '${elementId}' not found`);
+            return;
+        }
+        const built = buildExportSvg(host, background);
+        if (!built) {
+            console.warn(`[rackpeekGraph] no SVG in element '${elementId}' to export`);
+            return;
+        }
+        const xml = '<?xml version="1.0" encoding="UTF-8" standalone="no"?>\n' + built.xml;
+        triggerDownload(new Blob([xml], { type: "image/svg+xml;charset=utf-8" }), filename);
+    }
+
+    function downloadPng(elementId, filename, background, scale) {
+        const host = document.getElementById(elementId);
+        if (!host) {
+            console.warn(`[rackpeekGraph] element '${elementId}' not found`);
+            return Promise.resolve();
+        }
+        const built = buildExportSvg(host, background);
+        if (!built || !built.width || !built.height) {
+            console.warn(`[rackpeekGraph] cannot rasterise SVG in element '${elementId}'`);
+            return Promise.resolve();
+        }
+
+        // Render at 2× DPI by default so the PNG is sharp on retina displays
+        // and when zoomed in for documentation.
+        const ratio = scale && scale > 0 ? scale : 2;
+
+        // A data URL (vs Blob URL) avoids a class of foreignObject taint
+        // issues in some browsers — the SVG is treated as same-origin and
+        // doesn't get caught by the canvas security checks. The trade-off
+        // is a longer string, which is fine for diagram-sized payloads.
+        const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(built.xml);
+
+        return new Promise((resolve) => {
+            const img = new Image();
+            img.onload = () => {
+                try {
+                    const canvas = document.createElement("canvas");
+                    canvas.width = Math.ceil(built.width * ratio);
+                    canvas.height = Math.ceil(built.height * ratio);
+                    const ctx = canvas.getContext("2d");
+                    ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
+                    canvas.toBlob((png) => {
+                        if (png) {
+                            triggerDownload(png, filename);
+                        } else {
+                            // Tainted canvas — fall back to toDataURL which
+                            // throws SecurityError instead of returning null.
+                            try {
+                                const dataUrl = canvas.toDataURL("image/png");
+                                fetch(dataUrl)
+                                    .then(r => r.blob())
+                                    .then(b => triggerDownload(b, filename))
+                                    .catch(e => console.warn("[rackpeekGraph] PNG fallback failed", e))
+                                    .finally(resolve);
+                                return;
+                            } catch (e) {
+                                console.warn("[rackpeekGraph] canvas tainted, cannot export PNG", e);
+                            }
+                        }
+                        resolve();
+                    }, "image/png");
+                } catch (e) {
+                    console.warn("[rackpeekGraph] PNG render failed", e);
+                    resolve();
+                }
+            };
+            img.onerror = (err) => {
+                console.warn("[rackpeekGraph] SVG could not be loaded as image (likely a foreignObject/HTML-label rendering issue in this browser)", err);
+                resolve();
+            };
+            img.src = url;
+        });
+    }
+
+    window.rackpeekGraph = { render, downloadSvg, downloadPng, 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};

文件差異過大導致無法顯示
+ 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 };
+})();

+ 227 - 141
Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md

@@ -1,142 +1,228 @@
+
 - [rpk](docs/Commands.md#rpk)
-    - [summary](docs/Commands.md#rpk-summary) - Show a summarized report of all resources in the system
-    - [servers](docs/Commands.md#rpk-servers) - Manage servers and their components
-        - [summary](docs/Commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers
-        - [add](docs/Commands.md#rpk-servers-add) - Add a new server to the inventory
-        - [get](docs/Commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name
-        - [describe](docs/Commands.md#rpk-servers-describe) - Display detailed information about a specific server
-        - [set](docs/Commands.md#rpk-servers-set) - Update properties of an existing server
-        - [del](docs/Commands.md#rpk-servers-del) - Delete a server from the inventory
-        - [tree](docs/Commands.md#rpk-servers-tree) - Display the dependency tree of a server
-        - [cpu](docs/Commands.md#rpk-servers-cpu) - Manage CPUs attached to a server
-            - [add](docs/Commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server
-            - [set](docs/Commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU
-            - [del](docs/Commands.md#rpk-servers-cpu-del) - Remove a CPU from a server
-        - [drive](docs/Commands.md#rpk-servers-drive) - Manage drives attached to a server
-            - [add](docs/Commands.md#rpk-servers-drive-add) - Add a storage drive to a server
-            - [set](docs/Commands.md#rpk-servers-drive-set) - Update properties of a server drive
-            - [del](docs/Commands.md#rpk-servers-drive-del) - Remove a drive from a server
-        - [gpu](docs/Commands.md#rpk-servers-gpu) - Manage GPUs attached to a server
-            - [add](docs/Commands.md#rpk-servers-gpu-add) - Add a GPU to a server
-            - [set](docs/Commands.md#rpk-servers-gpu-set) - Update properties of a server GPU
-            - [del](docs/Commands.md#rpk-servers-gpu-del) - Remove a GPU from a server
-        - [nic](docs/Commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server
-            - [add](docs/Commands.md#rpk-servers-nic-add) - Add a NIC to a server
-            - [set](docs/Commands.md#rpk-servers-nic-set) - Update properties of a server NIC
-            - [del](docs/Commands.md#rpk-servers-nic-del) - Remove a NIC from a server
-    - [switches](docs/Commands.md#rpk-switches) - Manage network switches
-        - [summary](docs/Commands.md#rpk-switches-summary) - Show a hardware report for all switches
-        - [add](docs/Commands.md#rpk-switches-add) - Add a new network switch to the inventory
-        - [list](docs/Commands.md#rpk-switches-list) - List all switches in the system
-        - [get](docs/Commands.md#rpk-switches-get) - Retrieve details of a specific switch by name
-        - [describe](docs/Commands.md#rpk-switches-describe) - Show detailed information about a switch
-        - [set](docs/Commands.md#rpk-switches-set) - Update properties of a switch
-        - [del](docs/Commands.md#rpk-switches-del) - Delete a switch from the inventory
-        - [port](docs/Commands.md#rpk-switches-port) - Manage ports on a network switch
-            - [add](docs/Commands.md#rpk-switches-port-add) - Add a port to a switch
-            - [set](docs/Commands.md#rpk-switches-port-set) - Update a switch port
-            - [del](docs/Commands.md#rpk-switches-port-del) - Remove a port from a switch
-    - [routers](docs/Commands.md#rpk-routers) - Manage network routers
-        - [summary](docs/Commands.md#rpk-routers-summary) - Show a hardware report for all routers
-        - [add](docs/Commands.md#rpk-routers-add) - Add a new network router to the inventory
-        - [list](docs/Commands.md#rpk-routers-list) - List all routers in the system
-        - [get](docs/Commands.md#rpk-routers-get) - Retrieve details of a specific router by name
-        - [describe](docs/Commands.md#rpk-routers-describe) - Show detailed information about a router
-        - [set](docs/Commands.md#rpk-routers-set) - Update properties of a router
-        - [del](docs/Commands.md#rpk-routers-del) - Delete a router from the inventory
-        - [port](docs/Commands.md#rpk-routers-port) - Manage ports on a router
-            - [add](docs/Commands.md#rpk-routers-port-add) - Add a port to a router
-            - [set](docs/Commands.md#rpk-routers-port-set) - Update a router port
-            - [del](docs/Commands.md#rpk-routers-port-del) - Remove a port from a router
-    - [firewalls](docs/Commands.md#rpk-firewalls) - Manage firewalls
-        - [summary](docs/Commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls
-        - [add](docs/Commands.md#rpk-firewalls-add) - Add a new firewall to the inventory
-        - [list](docs/Commands.md#rpk-firewalls-list) - List all firewalls in the system
-        - [get](docs/Commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name
-        - [describe](docs/Commands.md#rpk-firewalls-describe) - Show detailed information about a firewall
-        - [set](docs/Commands.md#rpk-firewalls-set) - Update properties of a firewall
-        - [del](docs/Commands.md#rpk-firewalls-del) - Delete a firewall from the inventory
-        - [port](docs/Commands.md#rpk-firewalls-port) - Manage ports on a firewall
-            - [add](docs/Commands.md#rpk-firewalls-port-add) - Add a port to a firewall
-            - [set](docs/Commands.md#rpk-firewalls-port-set) - Update a firewall port
-            - [del](docs/Commands.md#rpk-firewalls-port-del) - Remove a port from a firewall
-    - [systems](docs/Commands.md#rpk-systems) - Manage systems and their dependencies
-        - [summary](docs/Commands.md#rpk-systems-summary) - Show a summary report for all systems
-        - [add](docs/Commands.md#rpk-systems-add) - Add a new system to the inventory
-        - [list](docs/Commands.md#rpk-systems-list) - List all systems
-        - [get](docs/Commands.md#rpk-systems-get) - Retrieve a system by name
-        - [describe](docs/Commands.md#rpk-systems-describe) - Display detailed information about a system
-        - [set](docs/Commands.md#rpk-systems-set) - Update properties of a system
-        - [del](docs/Commands.md#rpk-systems-del) - Delete a system from the inventory
-        - [tree](docs/Commands.md#rpk-systems-tree) - Display the dependency tree for a system
-    - [accesspoints](docs/Commands.md#rpk-accesspoints) - Manage access points
-        - [summary](docs/Commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points
-        - [add](docs/Commands.md#rpk-accesspoints-add) - Add a new access point
-        - [list](docs/Commands.md#rpk-accesspoints-list) - List all access points
-        - [get](docs/Commands.md#rpk-accesspoints-get) - Retrieve an access point by name
-        - [describe](docs/Commands.md#rpk-accesspoints-describe) - Show detailed information about an access point
-        - [set](docs/Commands.md#rpk-accesspoints-set) - Update properties of an access point
-        - [del](docs/Commands.md#rpk-accesspoints-del) - Delete an access point
-    - [ups](docs/Commands.md#rpk-ups) - Manage UPS units
-        - [summary](docs/Commands.md#rpk-ups-summary) - Show a hardware report for all UPS units
-        - [add](docs/Commands.md#rpk-ups-add) - Add a new UPS unit
-        - [list](docs/Commands.md#rpk-ups-list) - List all UPS units
-        - [get](docs/Commands.md#rpk-ups-get) - Retrieve a UPS unit by name
-        - [describe](docs/Commands.md#rpk-ups-describe) - Show detailed information about a UPS unit
-        - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit
-        - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit
-    - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
-        - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
-        - [list](docs/Commands.md#rpk-desktops-list) - List all desktops
-        - [get](docs/Commands.md#rpk-desktops-get) - Retrieve a desktop by name
-        - [describe](docs/Commands.md#rpk-desktops-describe) - Show detailed information about a desktop
-        - [set](docs/Commands.md#rpk-desktops-set) - Update properties of a desktop
-        - [del](docs/Commands.md#rpk-desktops-del) - Delete a desktop from the inventory
-        - [summary](docs/Commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops
-        - [tree](docs/Commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop
-        - [cpu](docs/Commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops
-            - [add](docs/Commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop
-            - [set](docs/Commands.md#rpk-desktops-cpu-set) - Update a desktop CPU
-            - [del](docs/Commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop
-        - [drive](docs/Commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops
-            - [add](docs/Commands.md#rpk-desktops-drive-add) - Add a drive to a desktop
-            - [set](docs/Commands.md#rpk-desktops-drive-set) - Update a desktop drive
-            - [del](docs/Commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop
-        - [gpu](docs/Commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops
-            - [add](docs/Commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop
-            - [set](docs/Commands.md#rpk-desktops-gpu-set) - Update a desktop GPU
-            - [del](docs/Commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop
-        - [nic](docs/Commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops
-            - [add](docs/Commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop
-            - [set](docs/Commands.md#rpk-desktops-nic-set) - Update a desktop NIC
-            - [del](docs/Commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop
-    - [Laptops](docs/Commands.md#rpk-laptops) - Manage Laptop computers and their components
-        - [add](docs/Commands.md#rpk-laptops-add) - Add a new Laptop
-        - [list](docs/Commands.md#rpk-laptops-list) - List all Laptops
-        - [get](docs/Commands.md#rpk-laptops-get) - Retrieve a Laptop by name
-        - [describe](docs/Commands.md#rpk-laptops-describe) - Show detailed information about a Laptop
-        - [del](docs/Commands.md#rpk-laptops-del) - Delete a Laptop from the inventory
-        - [summary](docs/Commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops
-        - [tree](docs/Commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop
-        - [cpu](docs/Commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops
-            - [add](docs/Commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop
-            - [set](docs/Commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU
-            - [del](docs/Commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop
-        - [drive](docs/Commands.md#rpk-laptops-drive) - Manage storage drives attached to Laptops
-            - [add](docs/Commands.md#rpk-laptops-drive-add) - Add a drive to a Laptop
-            - [set](docs/Commands.md#rpk-laptops-drive-set) - Update a Laptop drive
-            - [del](docs/Commands.md#rpk-laptops-drive-del) - Remove a drive from a Laptop
-        - [gpu](docs/Commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops
-            - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop
-            - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU
-            - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop
-    - [services](docs/Commands.md#rpk-services) - Manage services and their configurations
-        - [summary](docs/Commands.md#rpk-services-summary) - Show a summary report for all services
-        - [add](docs/Commands.md#rpk-services-add) - Add a new service
-        - [list](docs/Commands.md#rpk-services-list) - List all services
-        - [get](docs/Commands.md#rpk-services-get) - Retrieve a service by name
-        - [describe](docs/Commands.md#rpk-services-describe) - Show detailed information about a service
-        - [set](docs/Commands.md#rpk-services-set) - Update properties of a service
-        - [del](docs/Commands.md#rpk-services-del) - Delete a service
-        - [subnets](docs/Commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered
-          by CIDR
+  - [summary](docs/Commands.md#rpk-summary) - Show a summarized report of all resources in the system
+  - [servers](docs/Commands.md#rpk-servers) - Manage servers and their components
+    - [summary](docs/Commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers
+    - [add](docs/Commands.md#rpk-servers-add) - Add a new server to the inventory
+    - [get](docs/Commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name
+    - [describe](docs/Commands.md#rpk-servers-describe) - Display detailed information about a specific server
+    - [set](docs/Commands.md#rpk-servers-set) - Update properties of an existing server
+    - [del](docs/Commands.md#rpk-servers-del) - Delete a server from the inventory
+    - [rename](docs/Commands.md#rpk-servers-rename) - Rename a server to a new name
+    - [tree](docs/Commands.md#rpk-servers-tree) - Display the dependency tree of a server
+    - [cpu](docs/Commands.md#rpk-servers-cpu) - Manage CPUs attached to a server
+      - [add](docs/Commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server
+      - [set](docs/Commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU
+      - [del](docs/Commands.md#rpk-servers-cpu-del) - Remove a CPU from a server
+    - [drive](docs/Commands.md#rpk-servers-drive) - Manage drives attached to a server
+      - [add](docs/Commands.md#rpk-servers-drive-add) - Add a storage drive to a server
+      - [set](docs/Commands.md#rpk-servers-drive-set) - Update properties of a server drive
+      - [del](docs/Commands.md#rpk-servers-drive-del) - Remove a drive from a server
+    - [gpu](docs/Commands.md#rpk-servers-gpu) - Manage GPUs attached to a server
+      - [add](docs/Commands.md#rpk-servers-gpu-add) - Add a GPU to a server
+      - [set](docs/Commands.md#rpk-servers-gpu-set) - Update properties of a server GPU
+      - [del](docs/Commands.md#rpk-servers-gpu-del) - Remove a GPU from a server
+    - [nic](docs/Commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server
+      - [add](docs/Commands.md#rpk-servers-nic-add) - Add a NIC to a server
+      - [set](docs/Commands.md#rpk-servers-nic-set) - Update properties of a server NIC
+      - [del](docs/Commands.md#rpk-servers-nic-del) - Remove a NIC from a server
+    - [label](docs/Commands.md#rpk-servers-label) - Manage labels on a server
+      - [add](docs/Commands.md#rpk-servers-label-add) - Add a label to a server
+      - [remove](docs/Commands.md#rpk-servers-label-remove) - Remove a label from a server
+    - [tag](docs/Commands.md#rpk-servers-tag) - Manage tags on a server
+      - [add](docs/Commands.md#rpk-servers-tag-add) - Add a tag to a server
+      - [remove](docs/Commands.md#rpk-servers-tag-remove) - Remove a tag from a server
+  - [switches](docs/Commands.md#rpk-switches) - Manage network switches
+    - [summary](docs/Commands.md#rpk-switches-summary) - Show a hardware report for all switches
+    - [add](docs/Commands.md#rpk-switches-add) - Add a new network switch to the inventory
+    - [list](docs/Commands.md#rpk-switches-list) - List all switches in the system
+    - [get](docs/Commands.md#rpk-switches-get) - Retrieve details of a specific switch by name
+    - [describe](docs/Commands.md#rpk-switches-describe) - Show detailed information about a switch
+    - [set](docs/Commands.md#rpk-switches-set) - Update properties of a switch
+    - [del](docs/Commands.md#rpk-switches-del) - Delete a switch from the inventory
+    - [rename](docs/Commands.md#rpk-switches-rename) - Rename a switch to a new name
+    - [port](docs/Commands.md#rpk-switches-port) - Manage ports on a network switch
+      - [add](docs/Commands.md#rpk-switches-port-add) - Add a port to a switch
+      - [set](docs/Commands.md#rpk-switches-port-set) - Update a switch port
+      - [del](docs/Commands.md#rpk-switches-port-del) - Remove a port from a switch
+    - [label](docs/Commands.md#rpk-switches-label) - Manage labels on a switch
+      - [add](docs/Commands.md#rpk-switches-label-add) - Add a label to a switch
+      - [remove](docs/Commands.md#rpk-switches-label-remove) - Remove a label from a switch
+    - [tag](docs/Commands.md#rpk-switches-tag) - Manage tags on a switch
+      - [add](docs/Commands.md#rpk-switches-tag-add) - Add a tag to a switch
+      - [remove](docs/Commands.md#rpk-switches-tag-remove) - Remove a tag from a switch
+  - [routers](docs/Commands.md#rpk-routers) - Manage network routers
+    - [summary](docs/Commands.md#rpk-routers-summary) - Show a hardware report for all routers
+    - [add](docs/Commands.md#rpk-routers-add) - Add a new network router to the inventory
+    - [list](docs/Commands.md#rpk-routers-list) - List all routers in the system
+    - [get](docs/Commands.md#rpk-routers-get) - Retrieve details of a specific router by name
+    - [describe](docs/Commands.md#rpk-routers-describe) - Show detailed information about a router
+    - [set](docs/Commands.md#rpk-routers-set) - Update properties of a router
+    - [del](docs/Commands.md#rpk-routers-del) - Delete a router from the inventory
+    - [rename](docs/Commands.md#rpk-routers-rename) - Rename a router to a new name
+    - [port](docs/Commands.md#rpk-routers-port) - Manage ports on a router
+      - [add](docs/Commands.md#rpk-routers-port-add) - Add a port to a router
+      - [set](docs/Commands.md#rpk-routers-port-set) - Update a router port
+      - [del](docs/Commands.md#rpk-routers-port-del) - Remove a port from a router
+    - [label](docs/Commands.md#rpk-routers-label) - Manage labels on a router
+      - [add](docs/Commands.md#rpk-routers-label-add) - Add a label to a router
+      - [remove](docs/Commands.md#rpk-routers-label-remove) - Remove a label from a router
+    - [tag](docs/Commands.md#rpk-routers-tag) - Manage tags on a router
+      - [add](docs/Commands.md#rpk-routers-tag-add) - Add a tag to a router
+      - [remove](docs/Commands.md#rpk-routers-tag-remove) - Remove a tag from a router
+  - [firewalls](docs/Commands.md#rpk-firewalls) - Manage firewalls
+    - [summary](docs/Commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls
+    - [add](docs/Commands.md#rpk-firewalls-add) - Add a new firewall to the inventory
+    - [list](docs/Commands.md#rpk-firewalls-list) - List all firewalls in the system
+    - [get](docs/Commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name
+    - [describe](docs/Commands.md#rpk-firewalls-describe) - Show detailed information about a firewall
+    - [set](docs/Commands.md#rpk-firewalls-set) - Update properties of a firewall
+    - [del](docs/Commands.md#rpk-firewalls-del) - Delete a firewall from the inventory
+    - [rename](docs/Commands.md#rpk-firewalls-rename) - Rename a firewall to a new name
+    - [port](docs/Commands.md#rpk-firewalls-port) - Manage ports on a firewall
+      - [add](docs/Commands.md#rpk-firewalls-port-add) - Add a port to a firewall
+      - [set](docs/Commands.md#rpk-firewalls-port-set) - Update a firewall port
+      - [del](docs/Commands.md#rpk-firewalls-port-del) - Remove a port from a firewall
+    - [label](docs/Commands.md#rpk-firewalls-label) - Manage labels on a firewall
+      - [add](docs/Commands.md#rpk-firewalls-label-add) - Add a label to a firewall
+      - [remove](docs/Commands.md#rpk-firewalls-label-remove) - Remove a label from a firewall
+    - [tag](docs/Commands.md#rpk-firewalls-tag) - Manage tags on a firewall
+      - [add](docs/Commands.md#rpk-firewalls-tag-add) - Add a tag to a firewall
+      - [remove](docs/Commands.md#rpk-firewalls-tag-remove) - Remove a tag from a firewall
+  - [systems](docs/Commands.md#rpk-systems) - Manage systems and their dependencies
+    - [summary](docs/Commands.md#rpk-systems-summary) - Show a summary report for all systems
+    - [add](docs/Commands.md#rpk-systems-add) - Add a new system to the inventory
+    - [list](docs/Commands.md#rpk-systems-list) - List all systems
+    - [get](docs/Commands.md#rpk-systems-get) - Retrieve a system by name
+    - [describe](docs/Commands.md#rpk-systems-describe) - Display detailed information about a system
+    - [set](docs/Commands.md#rpk-systems-set) - Update properties of a system
+    - [del](docs/Commands.md#rpk-systems-del) - Delete a system from the inventory
+    - [rename](docs/Commands.md#rpk-systems-rename) - Rename a system to a new name
+    - [tree](docs/Commands.md#rpk-systems-tree) - Display the dependency tree for a system
+    - [label](docs/Commands.md#rpk-systems-label) - Manage labels on a system
+      - [add](docs/Commands.md#rpk-systems-label-add) - Add a label to a system
+      - [remove](docs/Commands.md#rpk-systems-label-remove) - Remove a label from a system
+    - [tag](docs/Commands.md#rpk-systems-tag) - Manage tags on a system
+      - [add](docs/Commands.md#rpk-systems-tag-add) - Add a tag to a system
+      - [remove](docs/Commands.md#rpk-systems-tag-remove) - Remove a tag from a system
+  - [accesspoints](docs/Commands.md#rpk-accesspoints) - Manage access points
+    - [summary](docs/Commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points
+    - [add](docs/Commands.md#rpk-accesspoints-add) - Add a new access point
+    - [list](docs/Commands.md#rpk-accesspoints-list) - List all access points
+    - [get](docs/Commands.md#rpk-accesspoints-get) - Retrieve an access point by name
+    - [describe](docs/Commands.md#rpk-accesspoints-describe) - Show detailed information about an access point
+    - [set](docs/Commands.md#rpk-accesspoints-set) - Update properties of an access point
+    - [del](docs/Commands.md#rpk-accesspoints-del) - Delete an access point
+    - [rename](docs/Commands.md#rpk-accesspoints-rename) - Rename an access point to a new name
+    - [label](docs/Commands.md#rpk-accesspoints-label) - Manage labels on an access point
+      - [add](docs/Commands.md#rpk-accesspoints-label-add) - Add a label to an access point
+      - [remove](docs/Commands.md#rpk-accesspoints-label-remove) - Remove a label from an access point
+    - [tag](docs/Commands.md#rpk-accesspoints-tag) - Manage tags on an access point
+      - [add](docs/Commands.md#rpk-accesspoints-tag-add) - Add a tag to an access point
+      - [remove](docs/Commands.md#rpk-accesspoints-tag-remove) - Remove a tag from an access point
+  - [ups](docs/Commands.md#rpk-ups) - Manage UPS units
+    - [summary](docs/Commands.md#rpk-ups-summary) - Show a hardware report for all UPS units
+    - [add](docs/Commands.md#rpk-ups-add) - Add a new UPS unit
+    - [list](docs/Commands.md#rpk-ups-list) - List all UPS units
+    - [get](docs/Commands.md#rpk-ups-get) - Retrieve a UPS unit by name
+    - [describe](docs/Commands.md#rpk-ups-describe) - Show detailed information about a UPS unit
+    - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit
+    - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit
+    - [rename](docs/Commands.md#rpk-ups-rename) - Rename a UPS unit to a new name
+    - [label](docs/Commands.md#rpk-ups-label) - Manage labels on a UPS unit
+      - [add](docs/Commands.md#rpk-ups-label-add) - Add a label to a UPS unit
+      - [remove](docs/Commands.md#rpk-ups-label-remove) - Remove a label from a UPS unit
+    - [tag](docs/Commands.md#rpk-ups-tag) - Manage tags on a UPS unit
+      - [add](docs/Commands.md#rpk-ups-tag-add) - Add a tag to a UPS unit
+      - [remove](docs/Commands.md#rpk-ups-tag-remove) - Remove a tag from a UPS unit
+  - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
+    - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
+    - [list](docs/Commands.md#rpk-desktops-list) - List all desktops
+    - [get](docs/Commands.md#rpk-desktops-get) - Retrieve a desktop by name
+    - [describe](docs/Commands.md#rpk-desktops-describe) - Show detailed information about a desktop
+    - [set](docs/Commands.md#rpk-desktops-set) - Update properties of a desktop
+    - [del](docs/Commands.md#rpk-desktops-del) - Delete a desktop from the inventory
+    - [rename](docs/Commands.md#rpk-desktops-rename) - Rename a desktop to a new name
+    - [summary](docs/Commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops
+    - [tree](docs/Commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop
+    - [cpu](docs/Commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops
+      - [add](docs/Commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop
+      - [set](docs/Commands.md#rpk-desktops-cpu-set) - Update a desktop CPU
+      - [del](docs/Commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop
+    - [drive](docs/Commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops
+      - [add](docs/Commands.md#rpk-desktops-drive-add) - Add a drive to a desktop
+      - [set](docs/Commands.md#rpk-desktops-drive-set) - Update a desktop drive
+      - [del](docs/Commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop
+    - [gpu](docs/Commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops
+      - [add](docs/Commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop
+      - [set](docs/Commands.md#rpk-desktops-gpu-set) - Update a desktop GPU
+      - [del](docs/Commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop
+    - [nic](docs/Commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops
+      - [add](docs/Commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop
+      - [set](docs/Commands.md#rpk-desktops-nic-set) - Update a desktop NIC
+      - [del](docs/Commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop
+    - [label](docs/Commands.md#rpk-desktops-label) - Manage labels on a desktop
+      - [add](docs/Commands.md#rpk-desktops-label-add) - Add a label to a desktop
+      - [remove](docs/Commands.md#rpk-desktops-label-remove) - Remove a label from a desktop
+    - [tag](docs/Commands.md#rpk-desktops-tag) - Manage tags on a desktop
+      - [add](docs/Commands.md#rpk-desktops-tag-add) - Add a tag to a desktop
+      - [remove](docs/Commands.md#rpk-desktops-tag-remove) - Remove a tag from a desktop
+  - [laptops](docs/Commands.md#rpk-laptops) - Manage Laptop computers and their components
+    - [add](docs/Commands.md#rpk-laptops-add) - Add a new Laptop
+    - [list](docs/Commands.md#rpk-laptops-list) - List all Laptops
+    - [get](docs/Commands.md#rpk-laptops-get) - Retrieve a Laptop by name
+    - [describe](docs/Commands.md#rpk-laptops-describe) - Show detailed information about a Laptop
+    - [set](docs/Commands.md#rpk-laptops-set) - Update properties of a laptop
+    - [del](docs/Commands.md#rpk-laptops-del) - Delete a Laptop from the inventory
+    - [rename](docs/Commands.md#rpk-laptops-rename) - Rename a Laptop to a new name
+    - [summary](docs/Commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops
+    - [tree](docs/Commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop
+    - [cpu](docs/Commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops
+      - [add](docs/Commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop
+      - [set](docs/Commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU
+      - [del](docs/Commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop
+    - [drive](docs/Commands.md#rpk-laptops-drive) - Manage storage drives attached to Laptops
+      - [add](docs/Commands.md#rpk-laptops-drive-add) - Add a drive to a Laptop
+      - [set](docs/Commands.md#rpk-laptops-drive-set) - Update a Laptop drive
+      - [del](docs/Commands.md#rpk-laptops-drive-del) - Remove a drive from a Laptop
+    - [gpu](docs/Commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops
+      - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop
+      - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU
+      - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop
+    - [label](docs/Commands.md#rpk-laptops-label) - Manage labels on a laptop
+      - [add](docs/Commands.md#rpk-laptops-label-add) - Add a label to a laptop
+      - [remove](docs/Commands.md#rpk-laptops-label-remove) - Remove a label from a laptop
+    - [tag](docs/Commands.md#rpk-laptops-tag) - Manage tags on a laptop
+      - [add](docs/Commands.md#rpk-laptops-tag-add) - Add a tag to a laptop
+      - [remove](docs/Commands.md#rpk-laptops-tag-remove) - Remove a tag from a laptop
+  - [services](docs/Commands.md#rpk-services) - Manage services and their configurations
+    - [summary](docs/Commands.md#rpk-services-summary) - Show a summary report for all services
+    - [add](docs/Commands.md#rpk-services-add) - Add a new service
+    - [list](docs/Commands.md#rpk-services-list) - List all services
+    - [get](docs/Commands.md#rpk-services-get) - Retrieve a service by name
+    - [describe](docs/Commands.md#rpk-services-describe) - Show detailed information about a service
+    - [set](docs/Commands.md#rpk-services-set) - Update properties of a service
+    - [del](docs/Commands.md#rpk-services-del) - Delete a service
+    - [rename](docs/Commands.md#rpk-services-rename) - Rename a service to a new name
+    - [subnets](docs/Commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered by CIDR
+    - [label](docs/Commands.md#rpk-services-label) - Manage labels on a service
+      - [add](docs/Commands.md#rpk-services-label-add) - Add a label to a service
+      - [remove](docs/Commands.md#rpk-services-label-remove) - Remove a label from a service
+    - [tag](docs/Commands.md#rpk-services-tag) - Manage tags on a service
+      - [add](docs/Commands.md#rpk-services-tag-add) - Add a tag to a service
+      - [remove](docs/Commands.md#rpk-services-tag-remove) - Remove a tag from a service
+  - [ansible](docs/Commands.md#rpk-ansible) - Generate and manage Ansible inventory
+    - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
+  - [ssh](docs/Commands.md#rpk-ssh) - Generate SSH configuration from infrastructure
+    - [export](docs/Commands.md#rpk-ssh-export) - Generate an SSH config file
+  - [hosts](docs/Commands.md#rpk-hosts) - Generate a hosts file from infrastructure
+    - [export](docs/Commands.md#rpk-hosts-export) - Generate a /etc/hosts compatible file
+  - [graph](docs/Commands.md#rpk-graph) - Render inventory as graph diagrams
+    - [topology](docs/Commands.md#rpk-graph-topology) - Emit a Mermaid flowchart of the physical topology (hardware + connections)
+    - [logical](docs/Commands.md#rpk-graph-logical) - Emit a Mermaid flowchart of services & systems grouped by subnet and host
+  - [tags](docs/Commands.md#rpk-tags) - Discover tags across resources
+    - [list](docs/Commands.md#rpk-tags-list) - List all tags in use with usage counts
+    - [show](docs/Commands.md#rpk-tags-show) - List resources carrying a specific tag
+  - [connections](docs/Commands.md#rpk-connections) - Manage physical or logical port connections
+    - [add](docs/Commands.md#rpk-connections-add) - Create a connection between two ports
+    - [remove](docs/Commands.md#rpk-connections-remove) - Remove the connection from a specific port

文件差異過大導致無法顯示
+ 437 - 119
Shared.Rcl/wwwroot/raw_docs/cli-commands.md


+ 1 - 0
Shared.Rcl/wwwroot/raw_docs/docs-index.json

@@ -9,5 +9,6 @@
   "cli-commands-index.md",
   "ssh-config-export.md",
   "hosts-file-export.md",
+  "inventory-api.md",
   "versioning.md"
 ]

+ 10 - 2
Shared.Rcl/wwwroot/raw_docs/git-integration.md

@@ -75,8 +75,10 @@ docker run -d \
 ## Wiring up the remote
 
 Open RackPeek in the browser. With `GIT_TOKEN` set, a Git status indicator
-appears in the header. Enable Git when prompted, then enter the repository
-remote URL — for example:
+appears in the header. RackPeek initialises a local git repository inside
+the config directory automatically on startup — no extra click required.
+Click **Add Remote** in the indicator and enter the repository URL — for
+example:
 
 * `https://github.com/youruser/rackpeek-config.git`
 * `https://gitea.example.com/youruser/rackpeek-config.git`
@@ -84,6 +86,12 @@ remote URL — for example:
 
 RackPeek will commit and sync configuration changes from there on.
 
+If the indicator shows **"Git configured but config directory is not
+writable"**, the container can't create the `.git/` folder inside
+`/app/config`. This is almost always a host filesystem permission issue
+on a bind mount — see the [Installation Guide](install-guide) for the
+ownership fix (`chown -R 1000:1000 /path/on/host/rackpeek`).
+
 ## Security notes
 
 * The token is read from the environment at container start; it is not

+ 227 - 0
Shared.Rcl/wwwroot/raw_docs/inventory-api.md

@@ -0,0 +1,227 @@
+# Inventory API
+
+RackPeek exposes a small HTTP API for upserting inventory from scripts and
+external systems — useful for automatic ingestion from Ansible, Terraform,
+discovery scanners, or any other tool that can produce a YAML or JSON
+description of your infrastructure.
+
+The API is served by the same process as the Web UI, on the same port.
+
+## What you need
+
+1. **The server URL** — wherever RackPeek is running, e.g.
+   `http://localhost:8080`.
+2. **An API key** — a shared secret you choose, set via the `RPK_API_KEY`
+   environment variable on the server. Requests without a matching key
+   are rejected.
+
+## Configuring the API key
+
+Set `RPK_API_KEY` on the RackPeek container or process. Until you set it,
+the inventory endpoint returns `503 Service Unavailable` for every request.
+
+| Variable | Required | Description |
+|---|---|---|
+| `RPK_API_KEY` | Yes (to use the API) | Shared secret. Clients must send this in the `X-Api-Key` header. |
+
+Example with Docker Compose:
+
+```yaml
+version: "3.9"
+
+services:
+  rackpeek:
+    image: aptacode/rackpeek:latest
+    container_name: rackpeek
+    ports:
+      - "8080:8080"
+    volumes:
+      - rackpeek-config:/app/config
+    environment:
+      - RPK_API_KEY=change-me-to-a-long-random-string
+    restart: unless-stopped
+
+volumes:
+  rackpeek-config:
+```
+
+Pick a long random value (e.g. `openssl rand -hex 32`). The server compares
+keys in constant time, so timing-based guessing is not viable, but a weak
+key still loses to brute force — treat it like a password.
+
+## Endpoints
+
+### `GET /health`
+
+Liveness check. No authentication. Returns `200 OK` with body `rackpeek` as
+`text/plain`. Used by the Docker healthcheck.
+
+```bash
+curl http://localhost:8080/health
+# rackpeek
+```
+
+### `POST /api/inventory`
+
+Upsert resources and connections. Requires the `X-Api-Key` header.
+
+#### Request body
+
+| Field | Type | Required | Description |
+|---|---|---|---|
+| `yaml` | string | one of `yaml` or `json` | Raw YAML payload, same schema as `config.yaml`. |
+| `json` | object | one of `yaml` or `json` | Structured JSON payload (see shape below). |
+| `mode` | `"Merge"` \| `"Replace"` | No (default `Merge`) | How incoming resources combine with existing ones. |
+| `dryRun` | bool | No (default `false`) | Compute the diff and return it without writing to disk. |
+
+Exactly one of `yaml` or `json` must be supplied — sending both is rejected.
+
+#### Merge semantics
+
+For resources whose `name` doesn't already exist, the resource is added.
+For resources whose `name` already exists:
+
+- **`Merge`** — fields present in the incoming payload overwrite the existing
+  values; fields that are omitted, `null`, empty lists, or empty maps in the
+  incoming payload are left untouched. Use this for partial updates.
+- **`Replace`** — the incoming resource fully replaces the existing one,
+  even for omitted fields. Use this when the incoming payload is the
+  authoritative full picture.
+
+If an incoming resource has a different `kind` than the existing one with
+the same name (e.g. server → switch), the existing resource is fully
+replaced regardless of mode.
+
+#### JSON payload shape
+
+The `json` field accepts the same shape as `config.yaml` — a root object
+with `version`, `resources`, and optional `connections`. The canonical
+schemas live in [`schemas/`](https://github.com/Timmoth/RackPeek/tree/main/schemas)
+in the repository.
+
+```json
+{
+  "version": 3,
+  "resources": [
+    {
+      "kind": "Server",
+      "name": "web-01",
+      "tags": ["homelab", "prod"],
+      "labels": { "env": "production" },
+      "ipmi": true,
+      "ram": { "size": 64 }
+    }
+  ],
+  "connections": []
+}
+```
+
+The `kind` discriminator is case-sensitive. Use the exact value for each
+resource type: `Server`, `Switch`, `Router`, `Firewall`, `AccessPoint`,
+`Ups`, `Desktop`, `Laptop`, `Service`, `System`.
+
+#### Response
+
+`200 OK` with a diff summary:
+
+| Field | Description |
+|---|---|
+| `added` | Names of resources that were newly created. |
+| `updated` | Names of existing resources whose fields changed under `Merge` mode. |
+| `replaced` | Names of resources fully overwritten (either `Replace` mode, or a kind mismatch). |
+| `oldYaml` | YAML snapshot of each updated/replaced resource **before** the change, keyed by name. |
+| `newYaml` | YAML snapshot of each incoming resource **after** the change, keyed by name. |
+
+`oldYaml` is omitted for newly-added resources (nothing existed to snapshot).
+
+#### Error responses
+
+| Status | When |
+|---|---|
+| `400 Bad Request` | Validation failure — neither `yaml` nor `json` supplied, both supplied, malformed payload, duplicate resource names, etc. Body: `{ "error": "..." }`. |
+| `401 Unauthorized` | `X-Api-Key` header missing or doesn't match `RPK_API_KEY`. |
+| `503 Service Unavailable` | `RPK_API_KEY` is not configured on the server. |
+
+## Examples
+
+### Upsert from a YAML file with curl
+
+```bash
+curl -X POST http://localhost:8080/api/inventory \
+  -H "X-Api-Key: $RPK_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d "$(jq -Rs --arg mode Merge '{yaml: ., mode: $mode}' < inventory.yaml)"
+```
+
+### Upsert a single resource as JSON
+
+```bash
+curl -X POST http://localhost:8080/api/inventory \
+  -H "X-Api-Key: $RPK_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "mode": "Merge",
+    "json": {
+      "version": 3,
+      "resources": [
+        {
+          "kind": "Server",
+          "name": "web-01",
+          "tags": ["homelab"],
+          "ram": { "size": 64 }
+        }
+      ]
+    }
+  }'
+```
+
+### Preview a change with `dryRun`
+
+```bash
+curl -X POST http://localhost:8080/api/inventory \
+  -H "X-Api-Key: $RPK_API_KEY" \
+  -H "Content-Type: application/json" \
+  -d '{
+    "dryRun": true,
+    "mode": "Replace",
+    "json": {
+      "version": 3,
+      "resources": [
+        { "kind": "Server", "name": "web-01", "ram": { "size": 128 } }
+      ]
+    }
+  }'
+```
+
+The response shows what would change without modifying `config.yaml`.
+
+### Bash helper
+
+```bash
+rpk_ingest() {
+  local file="$1"
+  curl -fsS -X POST "${RPK_URL:-http://localhost:8080}/api/inventory" \
+    -H "X-Api-Key: $RPK_API_KEY" \
+    -H "Content-Type: application/json" \
+    -d "$(jq -Rs '{yaml: ., mode: "Merge"}' < "$file")"
+}
+
+rpk_ingest inventory.yaml
+```
+
+## Tips
+
+- Run with `dryRun: true` first when wiring up a new pipeline — the response
+  tells you exactly what would change.
+- Prefer `Merge` when your script only knows about a subset of fields (e.g.
+  a discovery scanner that only sees CPU/RAM but doesn't know about
+  labels/tags). Use `Replace` when the script owns the full resource.
+- The `tags` array (a list) is replaced wholesale by any non-empty incoming
+  value, even in `Merge` mode — if you want to add a single tag without
+  overwriting the others, fetch the resource first or use the CLI
+  (`rpk <kind> tag add ...`).
+- The `labels` map (a dictionary) is merged key-wise in `Merge` mode —
+  existing keys absent from the incoming payload are preserved; keys
+  present in the incoming payload overwrite the existing value.
+- The same YAML you commit to Git via the Git integration is what the API
+  reads and writes — there is no separate API store.

+ 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();
     }
 

+ 11 - 5
Tests.E2e/Tests.E2e.csproj

@@ -8,15 +8,15 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="coverlet.collector" Version="10.0.0">
+        <PackageReference Include="coverlet.collector" Version="10.0.1">
             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
             <PrivateAssets>all</PrivateAssets>
         </PackageReference>
         <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1"/>
-        <PackageReference Include="Microsoft.Playwright" Version="1.59.0"/>
-        <PackageReference Include="Microsoft.Playwright.Xunit" Version="1.59.0"/>
-        <PackageReference Include="Testcontainers" Version="4.11.0"/>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
+        <PackageReference Include="Microsoft.Playwright" Version="1.60.0" />
+        <PackageReference Include="Microsoft.Playwright.Xunit" Version="1.60.0" />
+        <PackageReference Include="Testcontainers" Version="4.12.0" />
         <PackageReference Include="xunit" Version="2.9.3"/>
         <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -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
+}

+ 182 - 0
Tests/Api/InventoryEndpointTests.cs

@@ -666,4 +666,186 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
 
         Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
     }
+
+    [Fact]
+    public async Task Health_Endpoint_Is_Anonymous_And_Returns_rackpeek() {
+        HttpClient client = CreateClient();
+
+        HttpResponseMessage response = await client.GetAsync("/health");
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+        Assert.Equal("text/plain", response.Content.Headers.ContentType?.MediaType);
+        Assert.Equal("rackpeek", await response.Content.ReadAsStringAsync());
+    }
+
+    [Fact]
+    public async Task Wrong_Api_Key_Returns_401() {
+        HttpClient client = Factory.CreateClient();
+        client.DefaultRequestHeaders.Add("X-Api-Key", "definitely-wrong");
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = "resources:\n  - kind: Server\n    name: x" });
+
+        Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Bad_Request_Body_Contains_Error_Field() {
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+
+        Dictionary<string, string>? body =
+            await response.Content.ReadFromJsonAsync<Dictionary<string, string>>();
+
+        Assert.NotNull(body);
+        Assert.True(body!.ContainsKey("error"));
+        Assert.False(string.IsNullOrWhiteSpace(body["error"]));
+    }
+
+    [Fact]
+    public async Task Added_Resource_Has_No_OldYaml_Entry() {
+        HttpClient client = CreateClient(true);
+
+        var yaml = """
+                   resources:
+                     - kind: Server
+                       name: fresh-server
+                   """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory", new { yaml });
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("fresh-server", result!.Added);
+        Assert.DoesNotContain("fresh-server", result.OldYaml.Keys);
+        Assert.Contains("fresh-server", result.NewYaml.Keys);
+    }
+
+    [Fact]
+    public async Task Merge_Replaces_Tags_Wholesale() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: tag-merge
+                          tags:
+                            - alpha
+                            - beta
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     resources:
+                       - kind: Server
+                         name: tag-merge
+                         tags:
+                           - gamma
+                     """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("tag-merge", result!.Updated);
+
+        var newYaml = result.NewYaml["tag-merge"];
+        Assert.Contains("gamma", newYaml);
+        Assert.DoesNotContain("alpha", newYaml);
+        Assert.DoesNotContain("beta", newYaml);
+    }
+
+    [Theory]
+    [InlineData("Server")]
+    [InlineData("Switch")]
+    [InlineData("Router")]
+    [InlineData("Firewall")]
+    [InlineData("AccessPoint")]
+    [InlineData("Ups")]
+    [InlineData("Desktop")]
+    [InlineData("Laptop")]
+    [InlineData("Service")]
+    [InlineData("System")]
+    public async Task Documented_Kind_Discriminator_Values_Are_Accepted(string kind) {
+        // The inventory-api doc lists the exact set of valid kind discriminator
+        // values. Every one must be accepted as-is so the doc and runtime stay
+        // in lockstep.
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new {
+                json = new {
+                    version = 3,
+                    resources = new[]
+                    {
+                        new { kind, name = $"kind-probe-{kind.ToLowerInvariant()}" }
+                    }
+                }
+            });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Lowercase_Kind_Is_Rejected() {
+        // The discriminator is case-sensitive — the doc must use exact casing
+        // or users hit 400. This test pins that contract so a future refactor
+        // making the kind case-insensitive would surface here and prompt a
+        // doc update.
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new {
+                json = new {
+                    version = 3,
+                    resources = new[]
+                    {
+                        new { kind = "server", name = "lowercase-probe" }
+                    }
+                }
+            });
+
+        Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Merge_Preserves_Labels_Not_In_Incoming() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      resources:
+                        - kind: Server
+                          name: label-merge
+                          labels:
+                            env: production
+                            team: backend
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
+
+        var update = """
+                     resources:
+                       - kind: Server
+                         name: label-merge
+                         labels:
+                           env: staging
+                     """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("label-merge", result!.Updated);
+
+        var newYaml = result.NewYaml["label-merge"];
+        Assert.Contains("env: staging", newYaml);
+        Assert.Contains("team: backend", newYaml);
+        Assert.DoesNotContain("env: production", newYaml);
+    }
 }

+ 30 - 0
Tests/Api/InventoryEndpointUnconfiguredTests.cs

@@ -0,0 +1,30 @@
+using System.Net;
+using System.Net.Http.Json;
+using Xunit.Abstractions;
+
+namespace Tests.Api;
+
+public class InventoryEndpointUnconfiguredTests(ITestOutputHelper output) : ApiTestBase(output) {
+    protected override void ConfigureTestConfiguration(IDictionary<string, string?> config) =>
+        config.Remove("RPK_API_KEY");
+
+    [Fact]
+    public async Task Missing_Server_Api_Key_Returns_503() {
+        HttpClient client = CreateClient(true);
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = "resources:\n  - kind: Server\n    name: x" });
+
+        Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+    }
+
+    [Fact]
+    public async Task Missing_Server_Api_Key_Returns_503_Even_Without_Client_Header() {
+        HttpClient client = CreateClient();
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = "resources:\n  - kind: Server\n    name: x" });
+
+        Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
+    }
+}

+ 76 - 0
Tests/EndToEnd/ExporterTests/HostsExportWorkflowTests.cs

@@ -144,6 +144,82 @@ public class HostsExportWorkflowTests(
         Assert.True(aIndex < bIndex);
     }
 
+    [Fact]
+    public async Task hosts_export_uses_hostname_label_when_no_ip() {
+        // hosts-file-export.md §1: the `hostname` label is an alternative
+        // address. A resource with only `hostname` should still appear, with
+        // the hostname taking the address column.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-hostname
+                                                                             labels:
+                                                                               hostname: vm-hostname.lan
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync(
+            "hosts", "export",
+            "--no-header",
+            "--no-localhost"
+        );
+
+        Assert.Contains("vm-hostname.lan vm-hostname", output);
+    }
+
+    [Fact]
+    public async Task hosts_export_uses_ansible_host_label_when_no_ip_or_hostname() {
+        // hosts-file-export.md §1: "If you already use Ansible, `ansible_host`
+        // also works." A resource with only ansible_host should appear in
+        // the hosts file.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-ansible
+                                                                             labels:
+                                                                               ansible_host: 10.0.0.99
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync(
+            "hosts", "export",
+            "--no-header",
+            "--no-localhost"
+        );
+
+        Assert.Contains("10.0.0.99 vm-ansible", output);
+    }
+
+    [Fact]
+    public async Task hosts_export_prefers_ip_over_hostname_and_ansible_host() {
+        // hosts-file-export.md implies a precedence: ip is the canonical
+        // address, with hostname/ansible_host only as fallbacks. When all
+        // three are present the `ip` value wins.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-all
+                                                                             labels:
+                                                                               ip: 10.0.0.1
+                                                                               hostname: not-this.lan
+                                                                               ansible_host: 10.0.0.2
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync(
+            "hosts", "export",
+            "--no-header",
+            "--no-localhost"
+        );
+
+        Assert.Contains("10.0.0.1 vm-all", output);
+        Assert.DoesNotContain("not-this.lan", output);
+        Assert.DoesNotContain("10.0.0.2", output);
+    }
+
     [Fact]
     public async Task hosts_export_skips_resources_without_address() {
         await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """

+ 147 - 0
Tests/EndToEnd/ExporterTests/SshExportWorkflowTests.cs

@@ -151,6 +151,153 @@ public class SshExportWorkflowTests(
         Assert.True(aIndex < bIndex);
     }
 
+    [Fact]
+    public async Task ssh_export_uses_hostname_label_when_no_ip() {
+        // ssh-config-export.md §1 & §9: `hostname` is an accepted address
+        // fallback when `ip` is not set.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-host
+                                                                             labels:
+                                                                               hostname: vm-host.lan
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync("ssh", "export");
+
+        Assert.Contains("Host vm-host", output);
+        Assert.Contains("HostName vm-host.lan", output);
+    }
+
+    [Fact]
+    public async Task ssh_export_uses_ansible_host_label_when_no_ip_or_hostname() {
+        // ssh-config-export.md §1 & §9: `ansible_host` is the third fallback
+        // for the address.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-ansible
+                                                                             labels:
+                                                                               ansible_host: 10.0.0.99
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync("ssh", "export");
+
+        Assert.Contains("Host vm-ansible", output);
+        Assert.Contains("HostName 10.0.0.99", output);
+    }
+
+    [Fact]
+    public async Task ssh_export_falls_back_from_ssh_user_to_ansible_user() {
+        // ssh-config-export.md §9 fallback chain: ssh_user → ansible_user →
+        // CLI default. A resource with only `ansible_user` should still
+        // produce a User line.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-prefer
+                                                                             labels:
+                                                                               ip: 10.0.0.1
+                                                                               ssh_user: prefer-this
+                                                                               ansible_user: ignore-this
+
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-fallback
+                                                                             labels:
+                                                                               ip: 10.0.0.2
+                                                                               ansible_user: from-ansible
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync("ssh", "export");
+
+        Assert.Contains("""
+                        Host vm-fallback
+                          HostName 10.0.0.2
+                          User from-ansible
+                        """, output);
+        Assert.Contains("""
+                        Host vm-prefer
+                          HostName 10.0.0.1
+                          User prefer-this
+                        """, output);
+        Assert.DoesNotContain("ignore-this", output);
+    }
+
+    [Fact]
+    public async Task ssh_export_falls_back_from_ssh_port_to_ansible_port() {
+        // ssh-config-export.md §9 fallback chain: ssh_port → ansible_port →
+        // CLI default. Note: the generator omits `Port` if the resolved port
+        // equals 22, so test with a non-default value.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-prefer
+                                                                             labels:
+                                                                               ip: 10.0.0.1
+                                                                               ssh_port: "2200"
+                                                                               ansible_port: "9999"
+
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-fallback
+                                                                             labels:
+                                                                               ip: 10.0.0.2
+                                                                               ansible_port: "2222"
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync("ssh", "export");
+
+        Assert.Contains("""
+                        Host vm-fallback
+                          HostName 10.0.0.2
+                          Port 2222
+                        """, output);
+        Assert.Contains("""
+                        Host vm-prefer
+                          HostName 10.0.0.1
+                          Port 2200
+                        """, output);
+        Assert.DoesNotContain("9999", output);
+    }
+
+    [Fact]
+    public async Task ssh_export_honours_per_resource_ssh_port_and_identity_file_labels() {
+        // ssh-config-export.md §2: per-resource ssh_port and
+        // ssh_identity_file labels are emitted as Port / IdentityFile lines
+        // even when no CLI defaults are passed.
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+                                                                           version: 1
+                                                                           resources:
+                                                                           - kind: System
+                                                                             type: vm
+                                                                             name: vm-custom
+                                                                             labels:
+                                                                               ip: 10.0.0.1
+                                                                               ssh_user: ubuntu
+                                                                               ssh_port: "2222"
+                                                                               ssh_identity_file: ~/.ssh/id_custom
+                                                                           """);
+
+        (var output, var _) = await ExecuteAsync("ssh", "export");
+
+        Assert.Contains("""
+                        Host vm-custom
+                          HostName 10.0.0.1
+                          User ubuntu
+                          Port 2222
+                          IdentityFile ~/.ssh/id_custom
+                        """, output);
+    }
+
     [Fact]
     public async Task ssh_export_skips_resources_without_address() {
         await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """

+ 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();
     }
 }

+ 74 - 0
Tests/EndToEnd/Tags/TagsWorkflowTests.cs

@@ -0,0 +1,74 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.Tags;
+
+[Collection("Yaml CLI tests")]
+public class TagsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string output, string yaml)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Theory]
+    [InlineData("servers")]
+    [InlineData("switches")]
+    [InlineData("routers")]
+    [InlineData("firewalls")]
+    [InlineData("accesspoints")]
+    [InlineData("ups")]
+    [InlineData("desktops")]
+    [InlineData("laptops")]
+    [InlineData("services")]
+    [InlineData("systems")]
+    public async Task tags_cli_workflow_test(string resourceCommand) {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        (_, var yaml) = await ExecuteAsync(resourceCommand, "add", "web-01");
+        Assert.Contains("web-01", yaml);
+
+        (var output, yaml) = await ExecuteAsync(resourceCommand, "tag", "add", "web-01", "homelab");
+        Assert.Contains("Tag 'homelab' added", output);
+        Assert.Contains("homelab", yaml);
+
+        (output, _) = await ExecuteAsync("tags", "show", "homelab");
+        Assert.Contains("web-01", output);
+
+        (output, yaml) = await ExecuteAsync(resourceCommand, "tag", "remove", "web-01", "homelab");
+        Assert.Contains("Tag 'homelab' removed", output);
+        Assert.DoesNotContain("homelab", yaml);
+    }
+
+    [Fact]
+    public async Task tags_discovery_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("servers", "add", "srv-01");
+        await ExecuteAsync("services", "add", "svc-01");
+        await ExecuteAsync("servers", "add", "srv-02");
+
+        await ExecuteAsync("servers", "tag", "add", "srv-01", "homelab");
+        await ExecuteAsync("services", "tag", "add", "svc-01", "homelab");
+        await ExecuteAsync("servers", "tag", "add", "srv-02", "prod");
+
+        (var output, _) = await ExecuteAsync("tags", "list");
+        Assert.Contains("homelab", output);
+        Assert.Contains("prod", output);
+
+        (output, _) = await ExecuteAsync("tags", "show", "homelab");
+        Assert.Contains("srv-01", output);
+        Assert.Contains("svc-01", output);
+        Assert.DoesNotContain("srv-02", output);
+    }
+}

+ 78 - 0
Tests/Git/AddRemoteUseCaseTests.cs

@@ -0,0 +1,78 @@
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace Tests.Git;
+
+public sealed class AddRemoteUseCaseTests : IDisposable {
+    private readonly string _tempDir;
+    private readonly IGitRepository _repo;
+    private readonly AddRemoteUseCase _useCase;
+
+    public AddRemoteUseCaseTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-add-remote-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+
+        _repo = new LibGit2GitRepository(
+            _tempDir,
+            new TokenCredentialsProvider("test", "test-token"));
+
+        _useCase = new AddRemoteUseCase(_repo);
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, true);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    [Theory]
+    [InlineData("https://github.com/youruser/rackpeek-config.git")]
+    [InlineData("https://gitea.example.com/youruser/rackpeek-config.git")]
+    [InlineData("https://gitlab.example.com/youruser/rackpeek-config.git")]
+    public async Task Accepts_Documented_HTTPS_Examples(string url) {
+        // The doc lists these three URL shapes as supported. Use case must
+        // not reject them as malformed — a fetch failure (no real remote) is
+        // acceptable but "Only HTTPS URLs are supported" is not.
+        var error = await _useCase.ExecuteAsync(url);
+
+        Assert.DoesNotContain("Only HTTPS URLs are supported", error ?? string.Empty);
+        Assert.DoesNotContain("Git is not available", error ?? string.Empty);
+    }
+
+    [Theory]
+    [InlineData("http://github.com/u/r.git")]
+    [InlineData("ssh://git@github.com/u/r.git")]
+    [InlineData("git@github.com:u/r.git")]
+    [InlineData("file:///tmp/repo")]
+    public async Task Rejects_Non_HTTPS_URLs(string url) {
+        // Doc: "RackPeek does not use any host-specific APIs; the integration
+        // is plain git over HTTPS." Only HTTPS is supported on purpose — SSH
+        // would need a different credentials flow we don't offer.
+        var error = await _useCase.ExecuteAsync(url);
+
+        Assert.Equal("Only HTTPS URLs are supported.", error);
+    }
+
+    [Fact]
+    public async Task Rejects_Empty_URL() {
+        var error = await _useCase.ExecuteAsync("   ");
+        Assert.Equal("URL is required.", error);
+    }
+
+    [Fact]
+    public async Task Rejects_When_Remote_Already_Configured() {
+        await _useCase.ExecuteAsync("https://example.com/first.git");
+
+        var error = await _useCase.ExecuteAsync("https://example.com/second.git");
+
+        Assert.Equal("Remote already configured.", error);
+    }
+}

+ 176 - 0
Tests/Git/GitConfigurationTests.cs

@@ -0,0 +1,176 @@
+using LibGit2Sharp;
+using Microsoft.Extensions.Configuration;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain;
+using RackPeek.Domain.Git;
+
+namespace Tests.Git;
+
+[Collection("Git static state")]
+public sealed class GitConfigurationTests : IDisposable {
+    private readonly string _tempDir;
+
+    public GitConfigurationTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-git-cfg-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, true);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    private static IConfiguration BuildConfig(Dictionary<string, string?> values) =>
+        new ConfigurationBuilder()
+            .AddInMemoryCollection(values)
+            .Build();
+
+    [Fact]
+    public void Missing_Git_Token_Registers_NullGitRepository() {
+        // Doc: GIT_TOKEN is required to enable the integration. Without it the
+        // indicator never appears (RpkConstants.HasGitServices stays false) and
+        // every git call is a no-op via NullGitRepository.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?>()), _tempDir);
+
+        ServiceProvider provider = services.BuildServiceProvider();
+        IGitRepository repo = provider.GetRequiredService<IGitRepository>();
+
+        Assert.IsType<NullGitRepository>(repo);
+        Assert.False(RpkConstants.HasGitServices);
+    }
+
+    [Fact]
+    public void Empty_Git_Token_Registers_NullGitRepository() {
+        // An empty/whitespace GIT_TOKEN must be treated the same as unset.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "   "
+        }), _tempDir);
+
+        ServiceProvider provider = services.BuildServiceProvider();
+        IGitRepository repo = provider.GetRequiredService<IGitRepository>();
+
+        Assert.IsType<NullGitRepository>(repo);
+        Assert.False(RpkConstants.HasGitServices);
+    }
+
+    [Fact]
+    public void Git_Token_Set_Registers_LibGit2GitRepository_And_Flips_HasGitServices() {
+        // Doc: with GIT_TOKEN set, a Git status indicator appears in the
+        // header. The header gates the indicator on RpkConstants.HasGitServices.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_USERNAME"] = "octocat"
+        }), _tempDir);
+
+        ServiceProvider provider = services.BuildServiceProvider();
+        IGitRepository repo = provider.GetRequiredService<IGitRepository>();
+
+        Assert.IsType<LibGit2GitRepository>(repo);
+        Assert.True(RpkConstants.HasGitServices);
+    }
+
+    [Fact]
+    public void Git_Username_Defaults_To_git_When_Only_Token_Provided() {
+        // Doc table says GIT_USERNAME is "required", but in practice the code
+        // defaults it to the conventional "git" so users with personal access
+        // tokens that don't care about the username (e.g. GitHub fine-grained
+        // tokens) still authenticate correctly.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token"
+        }), _tempDir);
+
+        IGitCredentialsProvider creds = services.BuildServiceProvider()
+            .GetRequiredService<IGitCredentialsProvider>();
+
+        LibGit2Sharp.Handlers.CredentialsHandler handler = creds.GetHandler();
+        var credentials = (UsernamePasswordCredentials)handler("https://example.com", null!,
+            SupportedCredentialTypes.UsernamePassword);
+
+        Assert.Equal("git", credentials.Username);
+        Assert.Equal("ghp_test_token", credentials.Password);
+    }
+
+    [Fact]
+    public void Insecure_Tls_Flag_Defaults_To_False() {
+        // Doc: GIT_INSECURE_TLS is optional. Default behaviour must validate
+        // certificates so we don't silently accept MITM on public hosts.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.False(repo.InsecureTls);
+    }
+
+    [Fact]
+    public void Insecure_Tls_True_Is_Plumbed_Through_To_Repository() {
+        // Doc: GIT_INSECURE_TLS=true skips TLS validation. The flag must
+        // reach the repository instance that runs push/pull/fetch.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_INSECURE_TLS"] = "true"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.True(repo.InsecureTls);
+    }
+
+    [Fact]
+    public void Insecure_Tls_String_Comparison_Is_Case_Insensitive() {
+        // Common YAML/.env idiom is "True" or "TRUE"; the doc shows lowercase
+        // but users will type whatever feels natural.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_INSECURE_TLS"] = "TRUE"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.True(repo.InsecureTls);
+    }
+
+    [Fact]
+    public void Insecure_Tls_Any_Other_Value_Means_False() {
+        // Only "true" enables the bypass — strings like "yes", "1", etc.
+        // must NOT silently disable TLS.
+        var services = new ServiceCollection();
+
+        services.AddGitServices(BuildConfig(new Dictionary<string, string?> {
+            ["GIT_TOKEN"] = "ghp_test_token",
+            ["GIT_INSECURE_TLS"] = "yes"
+        }), _tempDir);
+
+        var repo = (LibGit2GitRepository)services.BuildServiceProvider()
+            .GetRequiredService<IGitRepository>();
+
+        Assert.False(repo.InsecureTls);
+    }
+}

+ 118 - 0
Tests/Git/GitIntegrationDocClaimsTests.cs

@@ -0,0 +1,118 @@
+using LibGit2Sharp;
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace Tests.Git;
+
+/// <summary>
+///     Holds tests that pin doc-only claims (UI strings, security promises,
+///     architectural statements) which don't fit cleanly inside one component
+///     test class.
+/// </summary>
+public sealed class GitIntegrationDocClaimsTests : IDisposable {
+    private readonly string _tempDir;
+
+    public GitIntegrationDocClaimsTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-doc-claims-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                Directory.Delete(_tempDir, true);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    [Fact]
+    public async Task Token_Never_Touches_The_Config_Directory() {
+        // Doc: "The token is read from the environment at container start; it
+        // is not persisted in the YAML config." Stronger guarantee: it must
+        // never appear in ANY file under the config directory, including the
+        // .git folder, so a leaked backup never leaks the token.
+        var token = $"sentinel-token-{Guid.NewGuid():N}";
+
+        var configPath = Path.Combine(_tempDir, "config.yaml");
+        await File.WriteAllTextAsync(configPath, "resources: []\n");
+
+        var repo = new LibGit2GitRepository(
+            _tempDir,
+            new TokenCredentialsProvider("octocat", token));
+
+        var addRemote = new AddRemoteUseCase(repo);
+        await addRemote.ExecuteAsync("https://example.com/user/repo.git");
+
+        var commit = new CommitAllUseCase(repo);
+        await commit.ExecuteAsync("test commit");
+
+        // Recursively scan everything under the config dir for the sentinel.
+        foreach (var file in Directory.EnumerateFiles(_tempDir, "*", SearchOption.AllDirectories)) {
+            var bytes = await File.ReadAllBytesAsync(file);
+            // tokens are ASCII; cheaper than decoding every binary file as text
+            var contents = System.Text.Encoding.ASCII.GetString(bytes);
+            Assert.DoesNotContain(token, contents);
+        }
+    }
+
+    [Fact]
+    public void Writability_Warning_String_In_UI_Matches_Documented_Wording() {
+        // Doc: 'If the indicator shows "Git configured but config directory is
+        // not writable", the container can't create the .git/ folder...'.
+        // Lock that exact wording so the doc and UI cannot drift out of sync.
+        // The tests run from Tests/bin/Debug/net10.0; walk up to the repo root.
+        var razorPath = LocateRepoFile("Shared.Rcl/Layout/GitStatusIndicator.razor");
+
+        var contents = File.ReadAllText(razorPath);
+
+        Assert.Contains(
+            "Git configured but config directory is not writable",
+            contents);
+    }
+
+    [Fact]
+    public void Integration_Uses_LibGit2_And_Not_System_Git() {
+        // Doc callout: "You do not need to install `git` in the container.
+        // RackPeek uses the bundled libgit2 library to talk to remotes
+        // directly over HTTPS." Verify the production implementation is
+        // LibGit2Sharp-backed (not a process-shelling wrapper) so the doc
+        // statement can't be invalidated by an accidental refactor.
+        IGitRepository repo = new LibGit2GitRepository(
+            _tempDir,
+            new TokenCredentialsProvider("u", "t"));
+
+        // Sanity: LibGit2Sharp must be reachable at all — if it weren't, the
+        // line above would have thrown a DllNotFoundException for the native
+        // libgit2 binary, which proves it ships with the assembly.
+        Assert.True(repo.IsAvailable);
+
+        // Pin: the production binding is the LibGit2Sharp-backed one. If
+        // someone swaps it for a CLI-shelling implementation, this fails and
+        // they're forced to revisit the "no system git needed" promise.
+        Assert.IsType<LibGit2GitRepository>(repo);
+
+        // And LibGit2Sharp itself must be loaded into the test process, which
+        // proves the assembly ships with native binaries.
+        Assert.NotNull(typeof(Repository).Assembly.Location);
+    }
+
+    private static string LocateRepoFile(string relativeFromRepoRoot) {
+        var dir = new DirectoryInfo(AppContext.BaseDirectory);
+
+        while (dir is not null) {
+            var candidate = Path.Combine(dir.FullName, relativeFromRepoRoot);
+            if (File.Exists(candidate))
+                return candidate;
+            dir = dir.Parent;
+        }
+
+        throw new FileNotFoundException(
+            $"Could not locate {relativeFromRepoRoot} walking up from {AppContext.BaseDirectory}");
+    }
+}

+ 170 - 0
Tests/Git/GitRepositoryAvailabilityTests.cs

@@ -0,0 +1,170 @@
+using LibGit2Sharp;
+using RackPeek.Domain.Git;
+using RackPeek.Domain.Git.UseCases;
+
+namespace Tests.Git;
+
+public sealed class GitRepositoryAvailabilityTests : IDisposable {
+    private readonly string _tempDir;
+    private readonly IGitCredentialsProvider _creds =
+        new TokenCredentialsProvider("test-user", "test-token");
+
+    public GitRepositoryAvailabilityTests() {
+        _tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "rackpeek-git-tests",
+            Guid.NewGuid().ToString());
+
+        Directory.CreateDirectory(_tempDir);
+        File.WriteAllText(Path.Combine(_tempDir, "config.yaml"), "");
+    }
+
+    public void Dispose() {
+        try {
+            if (Directory.Exists(_tempDir))
+                ForceDelete(_tempDir);
+        }
+        catch {
+            // ignore cleanup issues
+        }
+    }
+
+    private static void ForceDelete(string path) {
+        foreach (var file in Directory.EnumerateFiles(path, "*", SearchOption.AllDirectories)) {
+            File.SetAttributes(file, FileAttributes.Normal);
+        }
+
+        Directory.Delete(path, true);
+    }
+
+    [Fact]
+    public void Fresh_Config_Directory_Becomes_Available_After_Construction() {
+        // Reproduces the user's bug: GIT_TOKEN is set, container starts on a
+        // fresh volume with only config.yaml. Without auto-init, IsAvailable
+        // stays false and every git action returns "Git is not available."
+        Assert.False(Repository.IsValid(_tempDir),
+            "precondition: fresh dir should not be a git repo yet");
+
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+
+        Assert.True(repo.IsAvailable,
+            "after construction with GIT_TOKEN configured, the repo should be initialised " +
+            "so the user can immediately add a remote without an explicit Enable Git click");
+    }
+
+    [Fact]
+    public async Task Add_Remote_Succeeds_On_Fresh_Config_Directory() {
+        // End-to-end: simulates the user clicking Add Remote on a brand-new
+        // install. Before the fix this returns "Git is not available." even
+        // though GIT_TOKEN was set on the container.
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+        var useCase = new AddRemoteUseCase(repo);
+
+        var error = await useCase.ExecuteAsync("https://example.com/user/repo.git");
+
+        // The AddRemote fetch will fail (no real remote) but the failure
+        // should be a network error, not "Git is not available."
+        Assert.DoesNotContain("Git is not available", error ?? string.Empty);
+    }
+
+    [Fact]
+    public void Existing_Repo_Is_Not_Reinitialised() {
+        // Auto-init must be idempotent: pre-existing repos must keep their
+        // history. Initialising twice would discard the user's commits.
+        Repository.Init(_tempDir);
+
+        using (var seed = new Repository(_tempDir)) {
+            File.WriteAllText(Path.Combine(_tempDir, "seed.txt"), "seed");
+            Commands.Stage(seed, "seed.txt");
+            var sig = new Signature("seed", "seed@test", DateTimeOffset.UtcNow);
+            seed.Commit("seed commit", sig, sig);
+        }
+
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+
+        Assert.True(repo.IsAvailable);
+
+        using var verify = new Repository(_tempDir);
+        Assert.NotNull(verify.Head.Tip);
+        Assert.Equal("seed commit", verify.Head.Tip.MessageShort);
+    }
+
+    [Fact]
+    public void Missing_Directory_Stays_Unavailable() {
+        // Edge case: directory doesn't exist (e.g. misconfigured volume).
+        // We must not throw out of the constructor and we must not pretend
+        // git is available.
+        var missing = Path.Combine(_tempDir, "definitely-not-there");
+
+        var repo = new LibGit2GitRepository(missing, _creds);
+
+        Assert.False(repo.IsAvailable);
+    }
+
+    [Fact]
+    public void Readonly_Config_Directory_Does_Not_Throw_From_Constructor() {
+        // Doc promises the UI will surface "Git configured but config directory
+        // is not writable" when init can't run — that requires the constructor
+        // to swallow the init failure and set IsAvailable=false rather than
+        // crashing the Blazor render that resolves the singleton.
+        if (OperatingSystem.IsWindows())
+            return; // chmod-style read-only doesn't translate cleanly
+
+        var readOnly = Path.Combine(_tempDir, "readonly");
+        Directory.CreateDirectory(readOnly);
+        File.WriteAllText(Path.Combine(readOnly, "config.yaml"), "");
+
+        // Strip write permission for owner + group + other (0555).
+        File.SetUnixFileMode(readOnly,
+            UnixFileMode.UserRead | UnixFileMode.UserExecute |
+            UnixFileMode.GroupRead | UnixFileMode.GroupExecute |
+            UnixFileMode.OtherRead | UnixFileMode.OtherExecute);
+
+        try {
+            var repo = new LibGit2GitRepository(readOnly, _creds);
+
+            Assert.False(repo.IsAvailable,
+                "constructor must report unavailable, not throw, so the UI can " +
+                "show the documented writability warning");
+        }
+        finally {
+            // Restore write perm so cleanup works.
+            File.SetUnixFileMode(readOnly,
+                UnixFileMode.UserRead | UnixFileMode.UserWrite | UnixFileMode.UserExecute);
+        }
+    }
+
+    [Fact]
+    public async Task Add_Remote_Persists_The_Configured_Origin() {
+        // The full happy path that was broken before the fix: construct a repo
+        // against a fresh dir, add a remote, confirm it lives on disk so a
+        // subsequent push/pull would find it.
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+        var useCase = new AddRemoteUseCase(repo);
+
+        var url = "https://example.com/user/repo.git";
+        await useCase.ExecuteAsync(url);
+
+        using var verify = new Repository(_tempDir);
+        Remote? origin = verify.Network.Remotes["origin"];
+
+        Assert.NotNull(origin);
+        Assert.Equal(url, origin!.Url);
+    }
+
+    [Fact]
+    public void Existing_Repo_With_Remote_Is_Detected_As_Available() {
+        // After a container restart we should pick up the previously
+        // initialised repo (including its remote) without re-initialising
+        // and without losing state.
+        Repository.Init(_tempDir);
+        using (var seed = new Repository(_tempDir)) {
+            seed.Network.Remotes.Add("origin", "https://example.com/user/repo.git");
+        }
+
+        var repo = new LibGit2GitRepository(_tempDir, _creds);
+
+        Assert.True(repo.IsAvailable);
+        Assert.True(repo.HasRemote());
+    }
+}

+ 30 - 0
Tests/Git/TokenCredentialsProviderTests.cs

@@ -0,0 +1,30 @@
+using LibGit2Sharp;
+using RackPeek.Domain.Git;
+
+namespace Tests.Git;
+
+public sealed class TokenCredentialsProviderTests {
+    [Fact]
+    public void Produces_HTTP_Basic_Credentials_With_Configured_Username_And_Token() {
+        // Doc: GIT_TOKEN is "used as the password for HTTP Basic auth";
+        // GIT_USERNAME is the username the token belongs to. The handler must
+        // wire those two into UsernamePasswordCredentials, which libgit2 sends
+        // as HTTP Basic over HTTPS.
+        var provider = new TokenCredentialsProvider("octocat", "ghp_secret");
+
+        LibGit2Sharp.Handlers.CredentialsHandler handler = provider.GetHandler();
+        var credentials = (UsernamePasswordCredentials)handler(
+            "https://github.com/foo/bar.git", null!, SupportedCredentialTypes.UsernamePassword);
+
+        Assert.Equal("octocat", credentials.Username);
+        Assert.Equal("ghp_secret", credentials.Password);
+    }
+
+    [Fact]
+    public void Rejects_Null_Username() =>
+        Assert.Throws<ArgumentNullException>(() => new TokenCredentialsProvider(null!, "token"));
+
+    [Fact]
+    public void Rejects_Null_Token() =>
+        Assert.Throws<ArgumentNullException>(() => new TokenCredentialsProvider("user", null!));
+}

+ 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);
+    }
+
+}

+ 7 - 4
Tests/Tests.csproj

@@ -8,14 +8,17 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="coverlet.collector" Version="10.0.0"/>
+        <PackageReference Include="coverlet.collector" Version="10.0.1">
+          <PrivateAssets>all</PrivateAssets>
+          <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
+        </PackageReference>
         <PackageReference Include="JsonSchema.Net" Version="9.2.1"/>
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.5.1"/>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
         <PackageReference Include="NSubstitute" Version="5.3.0"/>
         <PackageReference Include="xunit" Version="2.9.3"/>
         <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.55.2"/>
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.8"/>
+        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
         <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8"/>
         <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8"/>
     </ItemGroup>

+ 227 - 141
docs/CommandIndex.md

@@ -1,142 +1,228 @@
 
-- [rpk](Commands.md#rpk)
-  - [summary](Commands.md#rpk-summary) - Show a summarized report of all resources in the system
-  - [servers](Commands.md#rpk-servers) - Manage servers and their components
-    - [summary](Commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers
-    - [add](Commands.md#rpk-servers-add) - Add a new server to the inventory
-    - [get](Commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name
-    - [describe](Commands.md#rpk-servers-describe) - Display detailed information about a specific server
-    - [set](Commands.md#rpk-servers-set) - Update properties of an existing server
-    - [del](Commands.md#rpk-servers-del) - Delete a server from the inventory
-    - [tree](Commands.md#rpk-servers-tree) - Display the dependency tree of a server
-    - [cpu](Commands.md#rpk-servers-cpu) - Manage CPUs attached to a server
-      - [add](Commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server
-      - [set](Commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU
-      - [del](Commands.md#rpk-servers-cpu-del) - Remove a CPU from a server
-    - [drive](Commands.md#rpk-servers-drive) - Manage drives attached to a server
-      - [add](Commands.md#rpk-servers-drive-add) - Add a storage drive to a server
-      - [set](Commands.md#rpk-servers-drive-set) - Update properties of a server drive
-      - [del](Commands.md#rpk-servers-drive-del) - Remove a drive from a server
-    - [gpu](Commands.md#rpk-servers-gpu) - Manage GPUs attached to a server
-      - [add](Commands.md#rpk-servers-gpu-add) - Add a GPU to a server
-      - [set](Commands.md#rpk-servers-gpu-set) - Update properties of a server GPU
-      - [del](Commands.md#rpk-servers-gpu-del) - Remove a GPU from a server
-    - [nic](Commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server
-      - [add](Commands.md#rpk-servers-nic-add) - Add a NIC to a server
-      - [set](Commands.md#rpk-servers-nic-set) - Update properties of a server NIC
-      - [del](Commands.md#rpk-servers-nic-del) - Remove a NIC from a server
-  - [switches](Commands.md#rpk-switches) - Manage network switches
-    - [summary](Commands.md#rpk-switches-summary) - Show a hardware report for all switches
-    - [add](Commands.md#rpk-switches-add) - Add a new network switch to the inventory
-    - [list](Commands.md#rpk-switches-list) - List all switches in the system
-    - [get](Commands.md#rpk-switches-get) - Retrieve details of a specific switch by name
-    - [describe](Commands.md#rpk-switches-describe) - Show detailed information about a switch
-    - [set](Commands.md#rpk-switches-set) - Update properties of a switch
-    - [del](Commands.md#rpk-switches-del) - Delete a switch from the inventory
-    - [port](Commands.md#rpk-switches-port) - Manage ports on a network switch
-      - [add](Commands.md#rpk-switches-port-add) - Add a port to a switch
-      - [set](Commands.md#rpk-switches-port-set) - Update a switch port
-      - [del](Commands.md#rpk-switches-port-del) - Remove a port from a switch
-  - [routers](Commands.md#rpk-routers) - Manage network routers
-    - [summary](Commands.md#rpk-routers-summary) - Show a hardware report for all routers
-    - [add](Commands.md#rpk-routers-add) - Add a new network router to the inventory
-    - [list](Commands.md#rpk-routers-list) - List all routers in the system
-    - [get](Commands.md#rpk-routers-get) - Retrieve details of a specific router by name
-    - [describe](Commands.md#rpk-routers-describe) - Show detailed information about a router
-    - [set](Commands.md#rpk-routers-set) - Update properties of a router
-    - [del](Commands.md#rpk-routers-del) - Delete a router from the inventory
-    - [port](Commands.md#rpk-routers-port) - Manage ports on a router
-      - [add](Commands.md#rpk-routers-port-add) - Add a port to a router
-      - [set](Commands.md#rpk-routers-port-set) - Update a router port
-      - [del](Commands.md#rpk-routers-port-del) - Remove a port from a router
-  - [firewalls](Commands.md#rpk-firewalls) - Manage firewalls
-    - [summary](Commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls
-    - [add](Commands.md#rpk-firewalls-add) - Add a new firewall to the inventory
-    - [list](Commands.md#rpk-firewalls-list) - List all firewalls in the system
-    - [get](Commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name
-    - [describe](Commands.md#rpk-firewalls-describe) - Show detailed information about a firewall
-    - [set](Commands.md#rpk-firewalls-set) - Update properties of a firewall
-    - [del](Commands.md#rpk-firewalls-del) - Delete a firewall from the inventory
-    - [port](Commands.md#rpk-firewalls-port) - Manage ports on a firewall
-      - [add](Commands.md#rpk-firewalls-port-add) - Add a port to a firewall
-      - [set](Commands.md#rpk-firewalls-port-set) - Update a firewall port
-      - [del](Commands.md#rpk-firewalls-port-del) - Remove a port from a firewall
-  - [systems](Commands.md#rpk-systems) - Manage systems and their dependencies
-    - [summary](Commands.md#rpk-systems-summary) - Show a summary report for all systems
-    - [add](Commands.md#rpk-systems-add) - Add a new system to the inventory
-    - [list](Commands.md#rpk-systems-list) - List all systems
-    - [get](Commands.md#rpk-systems-get) - Retrieve a system by name
-    - [describe](Commands.md#rpk-systems-describe) - Display detailed information about a system
-    - [set](Commands.md#rpk-systems-set) - Update properties of a system
-    - [del](Commands.md#rpk-systems-del) - Delete a system from the inventory
-    - [tree](Commands.md#rpk-systems-tree) - Display the dependency tree for a system
-  - [accesspoints](Commands.md#rpk-accesspoints) - Manage access points
-    - [summary](Commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points
-    - [add](Commands.md#rpk-accesspoints-add) - Add a new access point
-    - [list](Commands.md#rpk-accesspoints-list) - List all access points
-    - [get](Commands.md#rpk-accesspoints-get) - Retrieve an access point by name
-    - [describe](Commands.md#rpk-accesspoints-describe) - Show detailed information about an access point
-    - [set](Commands.md#rpk-accesspoints-set) - Update properties of an access point
-    - [del](Commands.md#rpk-accesspoints-del) - Delete an access point
-  - [ups](Commands.md#rpk-ups) - Manage UPS units
-    - [summary](Commands.md#rpk-ups-summary) - Show a hardware report for all UPS units
-    - [add](Commands.md#rpk-ups-add) - Add a new UPS unit
-    - [list](Commands.md#rpk-ups-list) - List all UPS units
-    - [get](Commands.md#rpk-ups-get) - Retrieve a UPS unit by name
-    - [describe](Commands.md#rpk-ups-describe) - Show detailed information about a UPS unit
-    - [set](Commands.md#rpk-ups-set) - Update properties of a UPS unit
-    - [del](Commands.md#rpk-ups-del) - Delete a UPS unit
-  - [desktops](Commands.md#rpk-desktops) - Manage desktop computers and their components
-    - [add](Commands.md#rpk-desktops-add) - Add a new desktop
-    - [list](Commands.md#rpk-desktops-list) - List all desktops
-    - [get](Commands.md#rpk-desktops-get) - Retrieve a desktop by name
-    - [describe](Commands.md#rpk-desktops-describe) - Show detailed information about a desktop
-    - [set](Commands.md#rpk-desktops-set) - Update properties of a desktop
-    - [del](Commands.md#rpk-desktops-del) - Delete a desktop from the inventory
-    - [summary](Commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops
-    - [tree](Commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop
-    - [cpu](Commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops
-      - [add](Commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop
-      - [set](Commands.md#rpk-desktops-cpu-set) - Update a desktop CPU
-      - [del](Commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop
-    - [drive](Commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops
-      - [add](Commands.md#rpk-desktops-drive-add) - Add a drive to a desktop
-      - [set](Commands.md#rpk-desktops-drive-set) - Update a desktop drive
-      - [del](Commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop
-    - [gpu](Commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops
-      - [add](Commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop
-      - [set](Commands.md#rpk-desktops-gpu-set) - Update a desktop GPU
-      - [del](Commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop
-    - [nic](Commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops
-      - [add](Commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop
-      - [set](Commands.md#rpk-desktops-nic-set) - Update a desktop NIC
-      - [del](Commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop
-  - [laptops](Commands.md#rpk-laptops) - Manage Laptop computers and their components
-    - [add](Commands.md#rpk-laptops-add) - Add a new Laptop
-    - [list](Commands.md#rpk-laptops-list) - List all Laptops
-    - [get](Commands.md#rpk-laptops-get) - Retrieve a Laptop by name
-    - [describe](Commands.md#rpk-laptops-describe) - Show detailed information about a Laptop
-    - [del](Commands.md#rpk-laptops-del) - Delete a Laptop from the inventory
-    - [summary](Commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops
-    - [tree](Commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop
-    - [cpu](Commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops
-      - [add](Commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop
-      - [set](Commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU
-      - [del](Commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop
-    - [drive](Commands.md#rpk-laptops-drive) - Manage storage drives attached to Laptops
-      - [add](Commands.md#rpk-laptops-drive-add) - Add a drive to a Laptop
-      - [set](Commands.md#rpk-laptops-drive-set) - Update a Laptop drive
-      - [del](Commands.md#rpk-laptops-drive-del) - Remove a drive from a Laptop
-    - [gpu](Commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops
-      - [add](Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop
-      - [set](Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU
-      - [del](Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop
-  - [services](Commands.md#rpk-services) - Manage services and their configurations
-    - [summary](Commands.md#rpk-services-summary) - Show a summary report for all services
-    - [add](Commands.md#rpk-services-add) - Add a new service
-    - [list](Commands.md#rpk-services-list) - List all services
-    - [get](Commands.md#rpk-services-get) - Retrieve a service by name
-    - [describe](Commands.md#rpk-services-describe) - Show detailed information about a service
-    - [set](Commands.md#rpk-services-set) - Update properties of a service
-    - [del](Commands.md#rpk-services-del) - Delete a service
-    - [subnets](Commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered by CIDR
+- [rpk](docs/Commands.md#rpk)
+  - [summary](docs/Commands.md#rpk-summary) - Show a summarized report of all resources in the system
+  - [servers](docs/Commands.md#rpk-servers) - Manage servers and their components
+    - [summary](docs/Commands.md#rpk-servers-summary) - Show a summarized hardware report for all servers
+    - [add](docs/Commands.md#rpk-servers-add) - Add a new server to the inventory
+    - [get](docs/Commands.md#rpk-servers-get) - List all servers or retrieve a specific server by name
+    - [describe](docs/Commands.md#rpk-servers-describe) - Display detailed information about a specific server
+    - [set](docs/Commands.md#rpk-servers-set) - Update properties of an existing server
+    - [del](docs/Commands.md#rpk-servers-del) - Delete a server from the inventory
+    - [rename](docs/Commands.md#rpk-servers-rename) - Rename a server to a new name
+    - [tree](docs/Commands.md#rpk-servers-tree) - Display the dependency tree of a server
+    - [cpu](docs/Commands.md#rpk-servers-cpu) - Manage CPUs attached to a server
+      - [add](docs/Commands.md#rpk-servers-cpu-add) - Add a CPU to a specific server
+      - [set](docs/Commands.md#rpk-servers-cpu-set) - Update configuration of a server CPU
+      - [del](docs/Commands.md#rpk-servers-cpu-del) - Remove a CPU from a server
+    - [drive](docs/Commands.md#rpk-servers-drive) - Manage drives attached to a server
+      - [add](docs/Commands.md#rpk-servers-drive-add) - Add a storage drive to a server
+      - [set](docs/Commands.md#rpk-servers-drive-set) - Update properties of a server drive
+      - [del](docs/Commands.md#rpk-servers-drive-del) - Remove a drive from a server
+    - [gpu](docs/Commands.md#rpk-servers-gpu) - Manage GPUs attached to a server
+      - [add](docs/Commands.md#rpk-servers-gpu-add) - Add a GPU to a server
+      - [set](docs/Commands.md#rpk-servers-gpu-set) - Update properties of a server GPU
+      - [del](docs/Commands.md#rpk-servers-gpu-del) - Remove a GPU from a server
+    - [nic](docs/Commands.md#rpk-servers-nic) - Manage network interface cards (NICs) for a server
+      - [add](docs/Commands.md#rpk-servers-nic-add) - Add a NIC to a server
+      - [set](docs/Commands.md#rpk-servers-nic-set) - Update properties of a server NIC
+      - [del](docs/Commands.md#rpk-servers-nic-del) - Remove a NIC from a server
+    - [label](docs/Commands.md#rpk-servers-label) - Manage labels on a server
+      - [add](docs/Commands.md#rpk-servers-label-add) - Add a label to a server
+      - [remove](docs/Commands.md#rpk-servers-label-remove) - Remove a label from a server
+    - [tag](docs/Commands.md#rpk-servers-tag) - Manage tags on a server
+      - [add](docs/Commands.md#rpk-servers-tag-add) - Add a tag to a server
+      - [remove](docs/Commands.md#rpk-servers-tag-remove) - Remove a tag from a server
+  - [switches](docs/Commands.md#rpk-switches) - Manage network switches
+    - [summary](docs/Commands.md#rpk-switches-summary) - Show a hardware report for all switches
+    - [add](docs/Commands.md#rpk-switches-add) - Add a new network switch to the inventory
+    - [list](docs/Commands.md#rpk-switches-list) - List all switches in the system
+    - [get](docs/Commands.md#rpk-switches-get) - Retrieve details of a specific switch by name
+    - [describe](docs/Commands.md#rpk-switches-describe) - Show detailed information about a switch
+    - [set](docs/Commands.md#rpk-switches-set) - Update properties of a switch
+    - [del](docs/Commands.md#rpk-switches-del) - Delete a switch from the inventory
+    - [rename](docs/Commands.md#rpk-switches-rename) - Rename a switch to a new name
+    - [port](docs/Commands.md#rpk-switches-port) - Manage ports on a network switch
+      - [add](docs/Commands.md#rpk-switches-port-add) - Add a port to a switch
+      - [set](docs/Commands.md#rpk-switches-port-set) - Update a switch port
+      - [del](docs/Commands.md#rpk-switches-port-del) - Remove a port from a switch
+    - [label](docs/Commands.md#rpk-switches-label) - Manage labels on a switch
+      - [add](docs/Commands.md#rpk-switches-label-add) - Add a label to a switch
+      - [remove](docs/Commands.md#rpk-switches-label-remove) - Remove a label from a switch
+    - [tag](docs/Commands.md#rpk-switches-tag) - Manage tags on a switch
+      - [add](docs/Commands.md#rpk-switches-tag-add) - Add a tag to a switch
+      - [remove](docs/Commands.md#rpk-switches-tag-remove) - Remove a tag from a switch
+  - [routers](docs/Commands.md#rpk-routers) - Manage network routers
+    - [summary](docs/Commands.md#rpk-routers-summary) - Show a hardware report for all routers
+    - [add](docs/Commands.md#rpk-routers-add) - Add a new network router to the inventory
+    - [list](docs/Commands.md#rpk-routers-list) - List all routers in the system
+    - [get](docs/Commands.md#rpk-routers-get) - Retrieve details of a specific router by name
+    - [describe](docs/Commands.md#rpk-routers-describe) - Show detailed information about a router
+    - [set](docs/Commands.md#rpk-routers-set) - Update properties of a router
+    - [del](docs/Commands.md#rpk-routers-del) - Delete a router from the inventory
+    - [rename](docs/Commands.md#rpk-routers-rename) - Rename a router to a new name
+    - [port](docs/Commands.md#rpk-routers-port) - Manage ports on a router
+      - [add](docs/Commands.md#rpk-routers-port-add) - Add a port to a router
+      - [set](docs/Commands.md#rpk-routers-port-set) - Update a router port
+      - [del](docs/Commands.md#rpk-routers-port-del) - Remove a port from a router
+    - [label](docs/Commands.md#rpk-routers-label) - Manage labels on a router
+      - [add](docs/Commands.md#rpk-routers-label-add) - Add a label to a router
+      - [remove](docs/Commands.md#rpk-routers-label-remove) - Remove a label from a router
+    - [tag](docs/Commands.md#rpk-routers-tag) - Manage tags on a router
+      - [add](docs/Commands.md#rpk-routers-tag-add) - Add a tag to a router
+      - [remove](docs/Commands.md#rpk-routers-tag-remove) - Remove a tag from a router
+  - [firewalls](docs/Commands.md#rpk-firewalls) - Manage firewalls
+    - [summary](docs/Commands.md#rpk-firewalls-summary) - Show a hardware report for all firewalls
+    - [add](docs/Commands.md#rpk-firewalls-add) - Add a new firewall to the inventory
+    - [list](docs/Commands.md#rpk-firewalls-list) - List all firewalls in the system
+    - [get](docs/Commands.md#rpk-firewalls-get) - Retrieve details of a specific firewall by name
+    - [describe](docs/Commands.md#rpk-firewalls-describe) - Show detailed information about a firewall
+    - [set](docs/Commands.md#rpk-firewalls-set) - Update properties of a firewall
+    - [del](docs/Commands.md#rpk-firewalls-del) - Delete a firewall from the inventory
+    - [rename](docs/Commands.md#rpk-firewalls-rename) - Rename a firewall to a new name
+    - [port](docs/Commands.md#rpk-firewalls-port) - Manage ports on a firewall
+      - [add](docs/Commands.md#rpk-firewalls-port-add) - Add a port to a firewall
+      - [set](docs/Commands.md#rpk-firewalls-port-set) - Update a firewall port
+      - [del](docs/Commands.md#rpk-firewalls-port-del) - Remove a port from a firewall
+    - [label](docs/Commands.md#rpk-firewalls-label) - Manage labels on a firewall
+      - [add](docs/Commands.md#rpk-firewalls-label-add) - Add a label to a firewall
+      - [remove](docs/Commands.md#rpk-firewalls-label-remove) - Remove a label from a firewall
+    - [tag](docs/Commands.md#rpk-firewalls-tag) - Manage tags on a firewall
+      - [add](docs/Commands.md#rpk-firewalls-tag-add) - Add a tag to a firewall
+      - [remove](docs/Commands.md#rpk-firewalls-tag-remove) - Remove a tag from a firewall
+  - [systems](docs/Commands.md#rpk-systems) - Manage systems and their dependencies
+    - [summary](docs/Commands.md#rpk-systems-summary) - Show a summary report for all systems
+    - [add](docs/Commands.md#rpk-systems-add) - Add a new system to the inventory
+    - [list](docs/Commands.md#rpk-systems-list) - List all systems
+    - [get](docs/Commands.md#rpk-systems-get) - Retrieve a system by name
+    - [describe](docs/Commands.md#rpk-systems-describe) - Display detailed information about a system
+    - [set](docs/Commands.md#rpk-systems-set) - Update properties of a system
+    - [del](docs/Commands.md#rpk-systems-del) - Delete a system from the inventory
+    - [rename](docs/Commands.md#rpk-systems-rename) - Rename a system to a new name
+    - [tree](docs/Commands.md#rpk-systems-tree) - Display the dependency tree for a system
+    - [label](docs/Commands.md#rpk-systems-label) - Manage labels on a system
+      - [add](docs/Commands.md#rpk-systems-label-add) - Add a label to a system
+      - [remove](docs/Commands.md#rpk-systems-label-remove) - Remove a label from a system
+    - [tag](docs/Commands.md#rpk-systems-tag) - Manage tags on a system
+      - [add](docs/Commands.md#rpk-systems-tag-add) - Add a tag to a system
+      - [remove](docs/Commands.md#rpk-systems-tag-remove) - Remove a tag from a system
+  - [accesspoints](docs/Commands.md#rpk-accesspoints) - Manage access points
+    - [summary](docs/Commands.md#rpk-accesspoints-summary) - Show a hardware report for all access points
+    - [add](docs/Commands.md#rpk-accesspoints-add) - Add a new access point
+    - [list](docs/Commands.md#rpk-accesspoints-list) - List all access points
+    - [get](docs/Commands.md#rpk-accesspoints-get) - Retrieve an access point by name
+    - [describe](docs/Commands.md#rpk-accesspoints-describe) - Show detailed information about an access point
+    - [set](docs/Commands.md#rpk-accesspoints-set) - Update properties of an access point
+    - [del](docs/Commands.md#rpk-accesspoints-del) - Delete an access point
+    - [rename](docs/Commands.md#rpk-accesspoints-rename) - Rename an access point to a new name
+    - [label](docs/Commands.md#rpk-accesspoints-label) - Manage labels on an access point
+      - [add](docs/Commands.md#rpk-accesspoints-label-add) - Add a label to an access point
+      - [remove](docs/Commands.md#rpk-accesspoints-label-remove) - Remove a label from an access point
+    - [tag](docs/Commands.md#rpk-accesspoints-tag) - Manage tags on an access point
+      - [add](docs/Commands.md#rpk-accesspoints-tag-add) - Add a tag to an access point
+      - [remove](docs/Commands.md#rpk-accesspoints-tag-remove) - Remove a tag from an access point
+  - [ups](docs/Commands.md#rpk-ups) - Manage UPS units
+    - [summary](docs/Commands.md#rpk-ups-summary) - Show a hardware report for all UPS units
+    - [add](docs/Commands.md#rpk-ups-add) - Add a new UPS unit
+    - [list](docs/Commands.md#rpk-ups-list) - List all UPS units
+    - [get](docs/Commands.md#rpk-ups-get) - Retrieve a UPS unit by name
+    - [describe](docs/Commands.md#rpk-ups-describe) - Show detailed information about a UPS unit
+    - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit
+    - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit
+    - [rename](docs/Commands.md#rpk-ups-rename) - Rename a UPS unit to a new name
+    - [label](docs/Commands.md#rpk-ups-label) - Manage labels on a UPS unit
+      - [add](docs/Commands.md#rpk-ups-label-add) - Add a label to a UPS unit
+      - [remove](docs/Commands.md#rpk-ups-label-remove) - Remove a label from a UPS unit
+    - [tag](docs/Commands.md#rpk-ups-tag) - Manage tags on a UPS unit
+      - [add](docs/Commands.md#rpk-ups-tag-add) - Add a tag to a UPS unit
+      - [remove](docs/Commands.md#rpk-ups-tag-remove) - Remove a tag from a UPS unit
+  - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
+    - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
+    - [list](docs/Commands.md#rpk-desktops-list) - List all desktops
+    - [get](docs/Commands.md#rpk-desktops-get) - Retrieve a desktop by name
+    - [describe](docs/Commands.md#rpk-desktops-describe) - Show detailed information about a desktop
+    - [set](docs/Commands.md#rpk-desktops-set) - Update properties of a desktop
+    - [del](docs/Commands.md#rpk-desktops-del) - Delete a desktop from the inventory
+    - [rename](docs/Commands.md#rpk-desktops-rename) - Rename a desktop to a new name
+    - [summary](docs/Commands.md#rpk-desktops-summary) - Show a summarized hardware report for all desktops
+    - [tree](docs/Commands.md#rpk-desktops-tree) - Display the dependency tree for a desktop
+    - [cpu](docs/Commands.md#rpk-desktops-cpu) - Manage CPUs attached to desktops
+      - [add](docs/Commands.md#rpk-desktops-cpu-add) - Add a CPU to a desktop
+      - [set](docs/Commands.md#rpk-desktops-cpu-set) - Update a desktop CPU
+      - [del](docs/Commands.md#rpk-desktops-cpu-del) - Remove a CPU from a desktop
+    - [drive](docs/Commands.md#rpk-desktops-drive) - Manage storage drives attached to desktops
+      - [add](docs/Commands.md#rpk-desktops-drive-add) - Add a drive to a desktop
+      - [set](docs/Commands.md#rpk-desktops-drive-set) - Update a desktop drive
+      - [del](docs/Commands.md#rpk-desktops-drive-del) - Remove a drive from a desktop
+    - [gpu](docs/Commands.md#rpk-desktops-gpu) - Manage GPUs attached to desktops
+      - [add](docs/Commands.md#rpk-desktops-gpu-add) - Add a GPU to a desktop
+      - [set](docs/Commands.md#rpk-desktops-gpu-set) - Update a desktop GPU
+      - [del](docs/Commands.md#rpk-desktops-gpu-del) - Remove a GPU from a desktop
+    - [nic](docs/Commands.md#rpk-desktops-nic) - Manage network interface cards (NICs) for desktops
+      - [add](docs/Commands.md#rpk-desktops-nic-add) - Add a NIC to a desktop
+      - [set](docs/Commands.md#rpk-desktops-nic-set) - Update a desktop NIC
+      - [del](docs/Commands.md#rpk-desktops-nic-del) - Remove a NIC from a desktop
+    - [label](docs/Commands.md#rpk-desktops-label) - Manage labels on a desktop
+      - [add](docs/Commands.md#rpk-desktops-label-add) - Add a label to a desktop
+      - [remove](docs/Commands.md#rpk-desktops-label-remove) - Remove a label from a desktop
+    - [tag](docs/Commands.md#rpk-desktops-tag) - Manage tags on a desktop
+      - [add](docs/Commands.md#rpk-desktops-tag-add) - Add a tag to a desktop
+      - [remove](docs/Commands.md#rpk-desktops-tag-remove) - Remove a tag from a desktop
+  - [laptops](docs/Commands.md#rpk-laptops) - Manage Laptop computers and their components
+    - [add](docs/Commands.md#rpk-laptops-add) - Add a new Laptop
+    - [list](docs/Commands.md#rpk-laptops-list) - List all Laptops
+    - [get](docs/Commands.md#rpk-laptops-get) - Retrieve a Laptop by name
+    - [describe](docs/Commands.md#rpk-laptops-describe) - Show detailed information about a Laptop
+    - [set](docs/Commands.md#rpk-laptops-set) - Update properties of a laptop
+    - [del](docs/Commands.md#rpk-laptops-del) - Delete a Laptop from the inventory
+    - [rename](docs/Commands.md#rpk-laptops-rename) - Rename a Laptop to a new name
+    - [summary](docs/Commands.md#rpk-laptops-summary) - Show a summarized hardware report for all Laptops
+    - [tree](docs/Commands.md#rpk-laptops-tree) - Display the dependency tree for a Laptop
+    - [cpu](docs/Commands.md#rpk-laptops-cpu) - Manage CPUs attached to Laptops
+      - [add](docs/Commands.md#rpk-laptops-cpu-add) - Add a CPU to a Laptop
+      - [set](docs/Commands.md#rpk-laptops-cpu-set) - Update a Laptop CPU
+      - [del](docs/Commands.md#rpk-laptops-cpu-del) - Remove a CPU from a Laptop
+    - [drive](docs/Commands.md#rpk-laptops-drive) - Manage storage drives attached to Laptops
+      - [add](docs/Commands.md#rpk-laptops-drive-add) - Add a drive to a Laptop
+      - [set](docs/Commands.md#rpk-laptops-drive-set) - Update a Laptop drive
+      - [del](docs/Commands.md#rpk-laptops-drive-del) - Remove a drive from a Laptop
+    - [gpu](docs/Commands.md#rpk-laptops-gpu) - Manage GPUs attached to Laptops
+      - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop
+      - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU
+      - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop
+    - [label](docs/Commands.md#rpk-laptops-label) - Manage labels on a laptop
+      - [add](docs/Commands.md#rpk-laptops-label-add) - Add a label to a laptop
+      - [remove](docs/Commands.md#rpk-laptops-label-remove) - Remove a label from a laptop
+    - [tag](docs/Commands.md#rpk-laptops-tag) - Manage tags on a laptop
+      - [add](docs/Commands.md#rpk-laptops-tag-add) - Add a tag to a laptop
+      - [remove](docs/Commands.md#rpk-laptops-tag-remove) - Remove a tag from a laptop
+  - [services](docs/Commands.md#rpk-services) - Manage services and their configurations
+    - [summary](docs/Commands.md#rpk-services-summary) - Show a summary report for all services
+    - [add](docs/Commands.md#rpk-services-add) - Add a new service
+    - [list](docs/Commands.md#rpk-services-list) - List all services
+    - [get](docs/Commands.md#rpk-services-get) - Retrieve a service by name
+    - [describe](docs/Commands.md#rpk-services-describe) - Show detailed information about a service
+    - [set](docs/Commands.md#rpk-services-set) - Update properties of a service
+    - [del](docs/Commands.md#rpk-services-del) - Delete a service
+    - [rename](docs/Commands.md#rpk-services-rename) - Rename a service to a new name
+    - [subnets](docs/Commands.md#rpk-services-subnets) - List subnets associated with a service, optionally filtered by CIDR
+    - [label](docs/Commands.md#rpk-services-label) - Manage labels on a service
+      - [add](docs/Commands.md#rpk-services-label-add) - Add a label to a service
+      - [remove](docs/Commands.md#rpk-services-label-remove) - Remove a label from a service
+    - [tag](docs/Commands.md#rpk-services-tag) - Manage tags on a service
+      - [add](docs/Commands.md#rpk-services-tag-add) - Add a tag to a service
+      - [remove](docs/Commands.md#rpk-services-tag-remove) - Remove a tag from a service
+  - [ansible](docs/Commands.md#rpk-ansible) - Generate and manage Ansible inventory
+    - [inventory](docs/Commands.md#rpk-ansible-inventory) - Generate an Ansible inventory
+  - [ssh](docs/Commands.md#rpk-ssh) - Generate SSH configuration from infrastructure
+    - [export](docs/Commands.md#rpk-ssh-export) - Generate an SSH config file
+  - [hosts](docs/Commands.md#rpk-hosts) - Generate a hosts file from infrastructure
+    - [export](docs/Commands.md#rpk-hosts-export) - Generate a /etc/hosts compatible file
+  - [graph](docs/Commands.md#rpk-graph) - Render inventory as graph diagrams
+    - [topology](docs/Commands.md#rpk-graph-topology) - Emit a Mermaid flowchart of the physical topology (hardware + connections)
+    - [logical](docs/Commands.md#rpk-graph-logical) - Emit a Mermaid flowchart of services & systems grouped by subnet and host
+  - [tags](docs/Commands.md#rpk-tags) - Discover tags across resources
+    - [list](docs/Commands.md#rpk-tags-list) - List all tags in use with usage counts
+    - [show](docs/Commands.md#rpk-tags-show) - List resources carrying a specific tag
+  - [connections](docs/Commands.md#rpk-connections) - Manage physical or logical port connections
+    - [add](docs/Commands.md#rpk-connections-add) - Create a connection between two ports
+    - [remove](docs/Commands.md#rpk-connections-remove) - Remove the connection from a specific port

文件差異過大導致無法顯示
+ 721 - 126
docs/Commands.md


+ 34 - 3
vhs/capture.js

@@ -3,6 +3,8 @@ const fs = require('fs');
 
 const URLS = [
   "http://localhost:5287",
+  "http://localhost:5287/visualise/topology",
+  "http://localhost:5287/visualise/logical",
   "http://localhost:5287/cli",
   "http://localhost:5287/yaml",
   "http://localhost:5287/hardware/tree",
@@ -25,17 +27,46 @@ const URLS = [
     const filename = url.replace(/^https?:\/\//, '').replace(/\//g, '_') + ".png";
     console.log("Capturing", url);
 
+    // Diagram routes are taller than the standard viewport; stretch it so
+    // the GraphView's `h-full` container shows the whole diagram in-frame.
+    const isVisualise = url.includes("/visualise/");
+    if (isVisualise) {
+      await page.setViewportSize({ width: 2400, height: 5000 });
+    } else {
+      await page.setViewportSize({ width: 1366, height: 768 });
+    }
+
     await page.goto(url, {
       waitUntil: "networkidle",
       timeout: 30000
     });
 
-    // extra settle time for SPA hydration
-    await page.waitForTimeout(2000);
+    // extra settle time for SPA hydration; Mermaid renders async so the
+    // visualise routes need a longer window before screenshotting.
+    await page.waitForTimeout(isVisualise ? 5000 : 2000);
+
+    // The visualise route wraps the diagram in an overflow:auto container
+    // bounded to 75vh — fine for the live UI, but it clips taller diagrams
+    // in screenshots. For the capture only, expand any ancestor of the SVG
+    // to its natural height and disable overflow clipping.
+    if (isVisualise) {
+      await page.evaluate(() => {
+        const svg = document.querySelector("[id^='visualise-graph-host'] svg, #visualise-graph-host svg");
+        if (!svg) return;
+        let el = svg.parentElement;
+        while (el && el !== document.body) {
+          el.style.height = "auto";
+          el.style.maxHeight = "none";
+          el.style.overflow = "visible";
+          el = el.parentElement;
+        }
+      });
+      await page.waitForTimeout(500);
+    }
 
     await page.screenshot({
       path: `webui_screenshots/${filename}`,
-      fullPage: false
+      fullPage: isVisualise
     });
   }
 

二進制
vhs/rpk-demo.gif


+ 24 - 0
vhs/rpk.tape

@@ -53,3 +53,27 @@ Sleep 0.5
 Type "rpk services subnets"
 Enter
 Sleep 2
+
+Type "clear"
+Enter
+Sleep 0.5
+
+# New in v2.0.0 — tags
+Type "rpk servers tag add proxmox-node01 prod"
+Enter
+Sleep 1
+Type "rpk servers tag add proxmox-node02 prod"
+Enter
+Sleep 1
+Type "rpk tags show prod"
+Enter
+Sleep 3
+
+Type "clear"
+Enter
+Sleep 0.5
+
+# New in v2.0.0 — graph diagrams (preview the Mermaid source)
+Type "rpk graph topology | head -15"
+Enter
+Sleep 3

+ 1004 - 509
vhs/sample_config/config.yaml

@@ -1,513 +1,1008 @@
+version: 3
 resources:
-  # ------------------------
-  # Servers
-  # ------------------------
-  - kind: Server
-    name: proxmox-node01
-    cpus:
-      - model: AMD EPYC 7302P
-        cores: 16
-        threads: 32
-    ram:
-      size: 128gb
-      mts: 3200
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: ssd
-        size: 1tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: proxmox-node02
-    cpus:
-      - model: Intel Xeon Silver 4210
-        cores: 10
-        threads: 20
-    ram:
-      size: 96gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: hdd
-        size: 4tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  - kind: Server
-    name: truenas-storage
-    cpus:
-      - model: Intel Xeon E-2236
-        cores: 6
-        threads: 12
-    ram:
-      size: 64gb
-      mts: 2666
-    drives:
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-      - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  # ------------------------
-  # Network
-  # ------------------------
-  - kind: Firewall
-    name: pfsense-fw
-    model: Netgate-6100
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 4
-      - type: sfp+
-        speed: 10gb
-        count: 2
-    managed: true
-    poe: false
-
-  - kind: Router
-    name: core-router
-    model: Ubiquiti-ER-4
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 4
-      - type: sfp
-        speed: 10gb
-        count: 1
-    managed: true
-    poe: false
-
-  - kind: Switch
-    name: core-switch
-    model: UniFi-USW-Enterprise-24
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 12
-      - type: rj45
-        speed: 2.5gb
-        count: 8
-      - type: sfp+
-        speed: 10gb
-        count: 4
-    managed: true
-    poe: true
-
-  - kind: Switch
-    name: access-switch
-    model: UniFi-USW-16-PoE
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 16
-      - type: sfp
-        speed: 1gb
-        count: 2
-    managed: true
-    poe: true
-
-  - kind: AccessPoint
-    name: lounge-ap
-    model: UniFi-U6-Pro
-    speed: 2.5gb
-
-  # ------------------------
-  # Power
-  # ------------------------
-  - kind: Ups
-    name: rack-ups
-    model: APC-SmartUPS-2200
-    va: 2200
-
-  # ------------------------
-  # Desktops
-  # ------------------------
-  - kind: Desktop
-    name: workstation-linux
-    cpus:
-      - model: AMD Ryzen 9 5900X
-        cores: 12
-        threads: 24
-    ram:
-      size: 64gb
-      mts: 3600
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: ssd
-        size: 2tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: NVIDIA RTX 3080
-        vram: 10gb
-
-  - kind: Desktop
-    name: gaming-pc
-    cpus:
-      - model: Intel Core i7-12700K
-        cores: 12
-        threads: 20
-    ram:
-      size: 32gb
-      mts: 3200
-    drives:
-      - type: ssd
-        size: 1tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: NVIDIA RTX 3070
-        vram: 8gb
-
-  # ------------------------
-  # Laptop
-  # ------------------------
-  - kind: Laptop
-    name: dev-laptop
-    cpus:
-      - model: Intel Core i7-1260P
-        cores: 12
-        threads: 16
-    ram:
-      size: 32gb
-      mts: 5200
-    drives:
-      - type: ssd
-        size: 1tb
-  # --------------------------------------------------
-  # Smart Home
-  # --------------------------------------------------
-  - kind: Service
-    name: home-assistant
-    network:
-      ip: 192.168.0.10
-      port: 8123
-      protocol: TCP
-      url: http://homeassistant.lan:8123
-    runsOn: vm-home-assistant
-
-  # --------------------------------------------------
-  # Media & Photos
-  # --------------------------------------------------
-  - kind: Service
-    name: plex
-    network:
-      ip: 192.168.0.20
-      port: 32400
-      protocol: TCP
-      url: http://plex.lan:32400
-    runsOn: vm-media-server
-
-  - kind: Service
-    name: jellyfin
-    network:
-      ip: 192.168.0.21
-      port: 8096
-      protocol: TCP
-      url: http://jellyfin.lan:8096
-    runsOn: vm-media-server
-
-  - kind: Service
-    name: immich
-    network:
-      ip: 192.168.0.22
-      port: 8080
-      protocol: TCP
-      url: http://immich.lan:8080
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Storage & Backup
-  # --------------------------------------------------
-  - kind: Service
-    name: truenas-webui
-    network:
-      ip: 192.168.0.30
-      port: 443
-      protocol: TCP
-      url: https://truenas.lan
-    runsOn: truenas-core-os
-
-  - kind: Service
-    name: minio
-    network:
-      ip: 192.168.0.31
-      port: 9000
-      protocol: TCP
-      url: http://minio.lan:9000
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Monitoring & Ops
-  # --------------------------------------------------
-  - kind: Service
-    name: prometheus
-    network:
-      ip: 192.168.0.40
-      port: 9090
-      protocol: TCP
-      url: http://prometheus.lan:9090
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: grafana
-    network:
-      ip: 192.168.0.41
-      port: 3000
-      protocol: TCP
-      url: http://grafana.lan:3000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: alertmanager
-    network:
-      ip: 192.168.0.42
-      port: 9093
-      protocol: TCP
-      url: http://alertmanager.lan:9093
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Dev & Internal Tools
-  # --------------------------------------------------
-  - kind: Service
-    name: gitea
-    network:
-      ip: 192.168.0.50
-      port: 3001
-      protocol: TCP
-      url: http://git.lan:3001
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: docker-registry
-    network:
-      ip: 192.168.0.51
-      port: 5000
-      protocol: TCP
-      url: http://registry.lan:5000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: portainer
-    network:
-      ip: 192.168.0.52
-      port: 9000
-      protocol: TCP
-      url: http://portainer.lan:9000
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Network Services
-  # --------------------------------------------------
-  - kind: Service
-    name: pihole
-    network:
-      ip: 192.168.0.53
-      port: 80
-      protocol: TCP
-      url: http://pihole.lan
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: firewall-webui
-    network:
-      ip: 192.168.0.1
-      port: 443
-      protocol: TCP
-      url: https://firewall.lan
-    runsOn: firewall-os
-
-  - kind: Service
-    name: router-webui
-    network:
-      ip: 192.168.0.254
-      port: 443
-      protocol: TCP
-      url: https://router.lan
-    runsOn: router-os
-  # --------------------------------------------------
-  # Hypervisors (Bare Metal)
-  # --------------------------------------------------
-  - kind: System
-    type: Hypervisor
-    name: proxmox-cluster-node01
-    os: proxmox
+# ============================================================================
+# Hardware
+# ============================================================================
+- kind: Server
+  ram:
+    size: 128
+    mts: 3200
+  ipmi: true
+  cpus:
+  - model: AMD EPYC 7302P
     cores: 16
-    ram: 128gb
-    drives:
-      - size: 1tb
-      - size: 1tb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: Hypervisor
-    name: proxmox-cluster-node02
-    os: proxmox
+    threads: 32
+  drives:
+  - type: ssd
+    size: 1024
+  - type: ssd
+    size: 1024
+  ports:
+  - type: rj45
+    speed: 1
+    count: 2
+  - type: sfp+
+    speed: 10
+    count: 2
+  name: proxmox-node01
+- kind: Server
+  ram:
+    size: 96
+    mts: 2666
+  ipmi: true
+  cpus:
+  - model: Intel Xeon Silver 4210
     cores: 10
-    ram: 96gb
-    drives:
-      - size: 1tb
-      - size: 4tb
-    runsOn: proxmox-node02
-
-  # --------------------------------------------------
-  # Storage OS (Bare Metal)
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: truenas-core-os
-    os: truenas
+    threads: 20
+  drives:
+  - type: ssd
+    size: 1024
+  - type: hdd
+    size: 4096
+  ports:
+  - type: rj45
+    speed: 1
+    count: 2
+  - type: sfp+
+    speed: 10
+    count: 1
+  name: proxmox-node02
+- kind: Server
+  ram:
+    size: 64
+    mts: 2666
+  ipmi: true
+  cpus:
+  - model: Intel Xeon E-2236
     cores: 6
-    ram: 64gb
-    drives:
-      - size: 8tb
-      - size: 8tb
-      - size: 8tb
-      - size: 8tb
-    runsOn: truenas-storage
-
-  # --------------------------------------------------
-  # IPMI / BMC Management
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: ipmi-proxmox-node01
-    os: idrac
-    cores: 1
-    ram: 1gb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: Baremetal
-    name: ipmi-proxmox-node02
-    os: ipmi
-    cores: 1
-    ram: 1gb
-    runsOn: proxmox-node02
-
-  - kind: System
-    type: Baremetal
-    name: ipmi-truenas-storage
-    os: ipmi
-    cores: 1
-    ram: 1gb
-    runsOn: truenas-storage
-
-  # --------------------------------------------------
-  # Core Network Systems
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: firewall-os
-    os: pfsense
-    cores: 4
-    ram: 8gb
-    drives:
-      - size: 32gb
-    runsOn: pfsense-fw
-
-  - kind: System
-    type: Baremetal
-    name: router-os
-    os: edgeos
-    cores: 4
-    ram: 4gb
-    drives:
-      - size: 4gb
-    runsOn: core-router
-
-  - kind: System
-    type: Baremetal
-    name: unifi-core-switch-os
-    os: unifi-os
-    cores: 2
-    ram: 2gb
-    drives:
-      - size: 8gb
-    runsOn: core-switch
-
-  - kind: System
-    type: Baremetal
-    name: unifi-access-switch-os
-    os: unifi-os
-    cores: 2
-    ram: 2gb
-    drives:
-      - size: 8gb
-    runsOn: access-switch
-
-  - kind: System
-    type: Baremetal
-    name: unifi-lounge-ap-os
-    os: unifi-firmware
-    cores: 2
-    ram: 1gb
-    drives:
-      - size: 4gb
-    runsOn: lounge-ap
-
-  # --------------------------------------------------
-  # Virtual Machines
-  # --------------------------------------------------
-  - kind: System
-    type: VM
-    name: vm-home-assistant
-    os: hassos
-    cores: 2
-    ram: 4gb
-    drives:
-      - size: 64gb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: VM
-    name: vm-media-server
-    os: ubuntu-22.04
-    cores: 4
-    ram: 8gb
-    drives:
-      - size: 500gb
-    runsOn: proxmox-node02
-
-  - kind: System
-    type: VM
-    name: vm-monitoring
-    os: debian-12
-    cores: 2
-    ram: 4gb
-    drives:
-      - size: 64gb
-    runsOn: proxmox-node01
+    threads: 12
+  drives:
+  - type: hdd
+    size: 8192
+  - type: hdd
+    size: 8192
+  - type: hdd
+    size: 8192
+  - type: hdd
+    size: 8192
+  ports:
+  - type: rj45
+    speed: 1
+    count: 1
+  - type: sfp+
+    speed: 10
+    count: 1
+  name: truenas-storage
+- kind: Server
+  ram:
+    size: 32
+    mts: 3200
+  ipmi: false
+  cpus:
+  - model: Intel Core i5-1240P
+    cores: 12
+    threads: 16
+  drives:
+  - type: ssd
+    size: 1024
+  ports:
+  - type: rj45
+    speed: 2.5
+    count: 1
+  name: docker-host
+- kind: Firewall
+  model: Netgate-6100
+  managed: true
+  poe: false
+  ports:
+  - type: rj45
+    speed: 1
+    count: 4
+  - type: sfp+
+    speed: 10
+    count: 2
+  name: pfsense-fw
+- kind: Router
+  model: Ubiquiti-ER-4
+  managed: true
+  poe: false
+  ports:
+  - type: rj45
+    speed: 1
+    count: 4
+  - type: sfp+
+    speed: 10
+    count: 1
+  name: core-router
+- kind: Switch
+  model: UniFi-USW-Enterprise-24
+  managed: true
+  poe: true
+  ports:
+  - type: rj45
+    speed: 1
+    count: 12
+  - type: rj45
+    speed: 2.5
+    count: 8
+  - type: sfp+
+    speed: 10
+    count: 4
+  name: core-switch
+- kind: Switch
+  model: UniFi-USW-16-PoE
+  managed: true
+  poe: true
+  ports:
+  - type: rj45
+    speed: 1
+    count: 16
+  - type: sfp
+    speed: 1
+    count: 2
+  name: access-switch
+- kind: AccessPoint
+  model: UniFi-U6-Pro
+  speed: 2.5
+  name: lounge-ap
+- kind: AccessPoint
+  model: UniFi-U6-Lite
+  speed: 1
+  name: basement-ap
+- kind: Ups
+  model: APC-SmartUPS-2200
+  va: 2200
+  name: rack-ups
+- kind: Desktop
+  ram:
+    size: 64
+    mts: 3600
+  cpus:
+  - model: AMD Ryzen 9 5900X
+    cores: 12
+    threads: 24
+  drives:
+  - type: ssd
+    size: 1024
+  - type: ssd
+    size: 2048
+  gpus:
+  - model: NVIDIA RTX 3080
+    vram: 10
+  ports:
+  - type: rj45
+    speed: 1
+    count: 1
+  name: workstation-linux
+- kind: Desktop
+  ram:
+    size: 32
+    mts: 3200
+  cpus:
+  - model: Intel Core i7-12700K
+    cores: 12
+    threads: 20
+  drives:
+  - type: ssd
+    size: 1024
+  gpus:
+  - model: NVIDIA RTX 3070
+    vram: 8
+  ports:
+  - type: rj45
+    speed: 1
+    count: 1
+  name: gaming-pc
+- kind: Laptop
+  ram:
+    size: 32
+    mts: 5200
+  cpus:
+  - model: Intel Core i7-1260P
+    cores: 12
+    threads: 16
+  drives:
+  - type: ssd
+    size: 1024
+  name: dev-laptop
+
+# ============================================================================
+# Systems
+# ============================================================================
+# Hypervisors / baremetal hosts
+- kind: System
+  type: Hypervisor
+  os: proxmox
+  cores: 16
+  ram: 128
+  drives:
+  - size: 1024
+  - size: 1024
+  ip: 192.168.1.10
+  name: proxmox-cluster-node01
+  runsOn:
+  - proxmox-node01
+- kind: System
+  type: Hypervisor
+  os: proxmox
+  cores: 10
+  ram: 96
+  drives:
+  - size: 1024
+  - size: 4096
+  ip: 192.168.1.11
+  name: proxmox-cluster-node02
+  runsOn:
+  - proxmox-node02
+- kind: System
+  type: Baremetal
+  os: truenas-scale
+  cores: 6
+  ram: 64
+  drives:
+  - size: 8192
+  - size: 8192
+  - size: 8192
+  - size: 8192
+  ip: 192.168.1.20
+  name: truenas-core-os
+  runsOn:
+  - truenas-storage
+- kind: System
+  type: Baremetal
+  os: debian-12
+  cores: 12
+  ram: 32
+  drives:
+  - size: 1024
+  ip: 192.168.1.30
+  name: docker-host-os
+  runsOn:
+  - docker-host
+
+# IPMI / BMC management
+- kind: System
+  type: Baremetal
+  os: idrac
+  cores: 1
+  ram: 1
+  ip: 10.0.0.30
+  name: ipmi-proxmox-node01
+  runsOn:
+  - proxmox-node01
+- kind: System
+  type: Baremetal
+  os: ipmi
+  cores: 1
+  ram: 1
+  ip: 10.0.0.31
+  name: ipmi-proxmox-node02
+  runsOn:
+  - proxmox-node02
+- kind: System
+  type: Baremetal
+  os: ipmi
+  cores: 1
+  ram: 1
+  ip: 10.0.0.32
+  name: ipmi-truenas-storage
+  runsOn:
+  - truenas-storage
+
+# Network appliances
+- kind: System
+  type: Baremetal
+  os: pfsense
+  cores: 4
+  ram: 8
+  drives:
+  - size: 32
+  ip: 10.0.0.1
+  name: firewall-os
+  runsOn:
+  - pfsense-fw
+- kind: System
+  type: Baremetal
+  os: edgeos
+  cores: 4
+  ram: 4
+  drives:
+  - size: 4
+  ip: 10.0.0.2
+  name: router-os
+  runsOn:
+  - core-router
+- kind: System
+  type: Baremetal
+  os: unifi-os
+  cores: 2
+  ram: 2
+  drives:
+  - size: 8
+  ip: 10.0.0.10
+  name: unifi-core-switch-os
+  runsOn:
+  - core-switch
+- kind: System
+  type: Baremetal
+  os: unifi-os
+  cores: 2
+  ram: 2
+  drives:
+  - size: 8
+  ip: 10.0.0.11
+  name: unifi-access-switch-os
+  runsOn:
+  - access-switch
+- kind: System
+  type: Baremetal
+  os: unifi-firmware
+  cores: 2
+  ram: 1
+  drives:
+  - size: 4
+  ip: 10.0.0.20
+  name: unifi-lounge-ap-os
+  runsOn:
+  - lounge-ap
+- kind: System
+  type: Baremetal
+  os: unifi-firmware
+  cores: 2
+  ram: 1
+  drives:
+  - size: 4
+  ip: 10.0.0.21
+  name: unifi-basement-ap-os
+  runsOn:
+  - basement-ap
+
+# Virtual machines
+- kind: System
+  type: VM
+  os: hassos
+  cores: 2
+  ram: 4
+  drives:
+  - size: 64
+  ip: 192.168.20.10
+  name: vm-home-assistant
+  runsOn:
+  - proxmox-node01
+- kind: System
+  type: VM
+  os: ubuntu-22.04
+  cores: 4
+  ram: 8
+  drives:
+  - size: 500
+  ip: 192.168.10.20
+  name: vm-media-server
+  runsOn:
+  - proxmox-node02
+- kind: System
+  type: VM
+  os: debian-12
+  cores: 2
+  ram: 4
+  drives:
+  - size: 64
+  ip: 192.168.10.30
+  name: vm-monitoring
+  runsOn:
+  - proxmox-node01
+- kind: System
+  type: VM
+  os: debian-12
+  cores: 4
+  ram: 8
+  drives:
+  - size: 128
+  ip: 192.168.10.40
+  name: vm-k3s-master
+  runsOn:
+  - proxmox-node01
+- kind: System
+  type: VM
+  os: debian-12
+  cores: 4
+  ram: 8
+  drives:
+  - size: 128
+  ip: 192.168.10.41
+  name: vm-k3s-worker-01
+  runsOn:
+  - proxmox-node02
+- kind: System
+  type: VM
+  os: debian-12
+  cores: 4
+  ram: 8
+  drives:
+  - size: 128
+  ip: 192.168.10.42
+  name: vm-k3s-worker-02
+  runsOn:
+  - proxmox-node02
+
+# LXC containers (lightweight service hosts)
+- kind: System
+  type: LXC
+  os: debian-12
+  cores: 2
+  ram: 4
+  drives:
+  - size: 32
+  ip: 192.168.10.50
+  name: lxc-arr
+  runsOn:
+  - proxmox-node02
+- kind: System
+  type: LXC
+  os: debian-12
+  cores: 2
+  ram: 4
+  drives:
+  - size: 32
+  ip: 192.168.10.51
+  name: lxc-downloads
+  runsOn:
+  - proxmox-node02
+- kind: System
+  type: LXC
+  os: debian-12
+  cores: 2
+  ram: 2
+  drives:
+  - size: 16
+  ip: 192.168.10.60
+  name: lxc-network
+  runsOn:
+  - proxmox-node01
+- kind: System
+  type: LXC
+  os: debian-12
+  cores: 2
+  ram: 2
+  drives:
+  - size: 16
+  ip: 192.168.10.61
+  name: lxc-auth
+  runsOn:
+  - proxmox-node01
+- kind: System
+  type: LXC
+  os: debian-12
+  cores: 2
+  ram: 4
+  drives:
+  - size: 64
+  ip: 192.168.10.62
+  name: lxc-files
+  runsOn:
+  - proxmox-node02
+- kind: System
+  type: LXC
+  os: debian-12
+  cores: 1
+  ram: 2
+  drives:
+  - size: 16
+  ip: 192.168.10.63
+  name: lxc-dashboard
+  runsOn:
+  - proxmox-node01
+
+# ============================================================================
+# Services
+# ============================================================================
+# --- Smart home (IoT VLAN) ---
+- kind: Service
+  network:
+    ip: 192.168.20.10
+    port: 8123
+    protocol: TCP
+    url: http://homeassistant.lan:8123
+  name: home-assistant
+  runsOn:
+  - vm-home-assistant
+- kind: Service
+  network:
+    ip: 192.168.20.10
+    port: 1883
+    protocol: TCP
+    url: mqtt://homeassistant.lan:1883
+  name: mosquitto
+  runsOn:
+  - vm-home-assistant
+- kind: Service
+  network:
+    ip: 192.168.20.10
+    port: 8080
+    protocol: TCP
+    url: http://homeassistant.lan:8080
+  name: zigbee2mqtt
+  runsOn:
+  - vm-home-assistant
+
+# --- Media stack ---
+- kind: Service
+  network:
+    ip: 192.168.10.20
+    port: 32400
+    protocol: TCP
+    url: http://plex.lan:32400
+  name: plex
+  runsOn:
+  - vm-media-server
+- kind: Service
+  network:
+    ip: 192.168.10.20
+    port: 8096
+    protocol: TCP
+    url: http://jellyfin.lan:8096
+  name: jellyfin
+  runsOn:
+  - vm-media-server
+- kind: Service
+  network:
+    ip: 192.168.10.20
+    port: 8083
+    protocol: TCP
+    url: http://audiobookshelf.lan:8083
+  name: audiobookshelf
+  runsOn:
+  - vm-media-server
+- kind: Service
+  network:
+    ip: 192.168.10.20
+    port: 4533
+    protocol: TCP
+    url: http://navidrome.lan:4533
+  name: navidrome
+  runsOn:
+  - vm-media-server
+- kind: Service
+  network:
+    ip: 192.168.10.20
+    port: 2283
+    protocol: TCP
+    url: https://immich.lan
+  name: immich
+  runsOn:
+  - vm-media-server
+- kind: Service
+  network:
+    ip: 192.168.10.20
+    port: 2342
+    protocol: TCP
+    url: http://photoprism.lan:2342
+  name: photoprism
+  runsOn:
+  - vm-media-server
+
+# --- *arr stack & downloads ---
+- kind: Service
+  network:
+    ip: 192.168.10.50
+    port: 8989
+    protocol: TCP
+    url: http://sonarr.lan:8989
+  name: sonarr
+  runsOn:
+  - lxc-arr
+- kind: Service
+  network:
+    ip: 192.168.10.50
+    port: 7878
+    protocol: TCP
+    url: http://radarr.lan:7878
+  name: radarr
+  runsOn:
+  - lxc-arr
+- kind: Service
+  network:
+    ip: 192.168.10.50
+    port: 8686
+    protocol: TCP
+    url: http://lidarr.lan:8686
+  name: lidarr
+  runsOn:
+  - lxc-arr
+- kind: Service
+  network:
+    ip: 192.168.10.50
+    port: 6767
+    protocol: TCP
+    url: http://bazarr.lan:6767
+  name: bazarr
+  runsOn:
+  - lxc-arr
+- kind: Service
+  network:
+    ip: 192.168.10.50
+    port: 9696
+    protocol: TCP
+    url: http://prowlarr.lan:9696
+  name: prowlarr
+  runsOn:
+  - lxc-arr
+- kind: Service
+  network:
+    ip: 192.168.10.50
+    port: 5055
+    protocol: TCP
+    url: http://overseerr.lan:5055
+  name: overseerr
+  runsOn:
+  - lxc-arr
+- kind: Service
+  network:
+    ip: 192.168.10.51
+    port: 8080
+    protocol: TCP
+    url: http://qbittorrent.lan:8080
+  name: qbittorrent
+  runsOn:
+  - lxc-downloads
+- kind: Service
+  network:
+    ip: 192.168.10.51
+    port: 8081
+    protocol: TCP
+    url: http://sabnzbd.lan:8081
+  name: sabnzbd
+  runsOn:
+  - lxc-downloads
+
+# --- Network services ---
+- kind: Service
+  network:
+    ip: 192.168.10.60
+    port: 80
+    protocol: TCP
+    url: http://adguard.lan
+  name: adguard-home
+  runsOn:
+  - lxc-network
+- kind: Service
+  network:
+    ip: 192.168.10.60
+    port: 5335
+    protocol: TCP
+    url: udp://unbound.lan:5335
+  name: unbound
+  runsOn:
+  - lxc-network
+- kind: Service
+  network:
+    ip: 192.168.10.60
+    port: 81
+    protocol: TCP
+    url: http://npm.lan:81
+  name: nginx-proxy-manager
+  runsOn:
+  - lxc-network
+- kind: Service
+  network:
+    ip: 192.168.10.60
+    port: 51820
+    protocol: UDP
+    url: wg://wireguard.lan:51820
+  name: wireguard
+  runsOn:
+  - lxc-network
+
+# --- Auth & secrets ---
+- kind: Service
+  network:
+    ip: 192.168.10.61
+    port: 9000
+    protocol: TCP
+    url: https://auth.lan
+  name: authentik
+  runsOn:
+  - lxc-auth
+- kind: Service
+  network:
+    ip: 192.168.10.61
+    port: 8000
+    protocol: TCP
+    url: https://vault.lan
+  name: vaultwarden
+  runsOn:
+  - lxc-auth
+
+# --- Files & productivity ---
+- kind: Service
+  network:
+    ip: 192.168.10.62
+    port: 443
+    protocol: TCP
+    url: https://nextcloud.lan
+  name: nextcloud
+  runsOn:
+  - lxc-files
+- kind: Service
+  network:
+    ip: 192.168.10.62
+    port: 8384
+    protocol: TCP
+    url: http://syncthing.lan:8384
+  name: syncthing
+  runsOn:
+  - lxc-files
+- kind: Service
+  network:
+    ip: 192.168.10.62
+    port: 8000
+    protocol: TCP
+    url: http://paperless.lan:8000
+  name: paperless-ngx
+  runsOn:
+  - lxc-files
+
+# --- Monitoring & ops ---
+- kind: Service
+  network:
+    ip: 192.168.10.30
+    port: 9090
+    protocol: TCP
+    url: http://prometheus.lan:9090
+  name: prometheus
+  runsOn:
+  - vm-monitoring
+- kind: Service
+  network:
+    ip: 192.168.10.30
+    port: 3000
+    protocol: TCP
+    url: http://grafana.lan:3000
+  name: grafana
+  runsOn:
+  - vm-monitoring
+- kind: Service
+  network:
+    ip: 192.168.10.30
+    port: 9093
+    protocol: TCP
+    url: http://alertmanager.lan:9093
+  name: alertmanager
+  runsOn:
+  - vm-monitoring
+- kind: Service
+  network:
+    ip: 192.168.10.30
+    port: 3100
+    protocol: TCP
+    url: http://loki.lan:3100
+  name: loki
+  runsOn:
+  - vm-monitoring
+- kind: Service
+  network:
+    ip: 192.168.10.63
+    port: 3001
+    protocol: TCP
+    url: http://uptime.lan:3001
+  name: uptime-kuma
+  runsOn:
+  - lxc-dashboard
+- kind: Service
+  network:
+    ip: 192.168.10.63
+    port: 7575
+    protocol: TCP
+    url: http://homarr.lan:7575
+  name: homarr
+  runsOn:
+  - lxc-dashboard
+- kind: Service
+  network:
+    ip: 192.168.10.63
+    port: 8080
+    protocol: TCP
+    url: http://dozzle.lan:8080
+  name: dozzle
+  runsOn:
+  - lxc-dashboard
+
+# --- Dev / source / CI ---
+- kind: Service
+  network:
+    ip: 192.168.10.40
+    port: 3000
+    protocol: TCP
+    url: http://gitea.lan:3000
+  name: gitea
+  runsOn:
+  - vm-k3s-master
+- kind: Service
+  network:
+    ip: 192.168.10.40
+    port: 80
+    protocol: TCP
+    url: http://drone.lan
+  name: drone-ci
+  runsOn:
+  - vm-k3s-master
+- kind: Service
+  network:
+    ip: 192.168.1.30
+    port: 9000
+    protocol: TCP
+    url: http://portainer.lan:9000
+  name: portainer
+  runsOn:
+  - docker-host-os
+- kind: Service
+  network:
+    ip: 192.168.1.30
+    port: 5000
+    protocol: TCP
+    url: http://registry.lan:5000
+  name: docker-registry
+  runsOn:
+  - docker-host-os
+- kind: Service
+  network:
+    ip: 192.168.1.30
+    port: 9001
+    protocol: TCP
+    url: http://minio.lan:9001
+  name: minio
+  runsOn:
+  - docker-host-os
+
+# --- Storage UI ---
+- kind: Service
+  network:
+    ip: 192.168.1.20
+    port: 443
+    protocol: TCP
+    url: https://truenas.lan
+  name: truenas-webui
+  runsOn:
+  - truenas-core-os
+
+# --- Network admin UIs ---
+- kind: Service
+  network:
+    ip: 10.0.0.1
+    port: 443
+    protocol: TCP
+    url: https://firewall.lan
+  name: firewall-webui
+  runsOn:
+  - firewall-os
+- kind: Service
+  network:
+    ip: 10.0.0.2
+    port: 443
+    protocol: TCP
+    url: https://router.lan
+  name: router-webui
+  runsOn:
+  - router-os
+- kind: Service
+  network:
+    ip: 10.0.0.10
+    port: 443
+    protocol: TCP
+    url: https://unifi.lan
+  name: unifi-controller
+  runsOn:
+  - unifi-core-switch-os
+
+# ============================================================================
+# Connections (physical port wiring)
+# ============================================================================
+connections:
+# WAN ─ firewall ─ router (uplink chain)
+- a:
+    resource: pfsense-fw
+    portGroup: 1
+    portIndex: 0
+  b:
+    resource: core-router
+    portGroup: 1
+    portIndex: 0
+  label: WAN uplink
+# router ─ core-switch (10G trunk)
+- a:
+    resource: core-router
+    portGroup: 1
+    portIndex: 0
+  b:
+    resource: core-switch
+    portGroup: 2
+    portIndex: 0
+  label: core trunk
+# core-switch ─ access-switch (1G uplink)
+- a:
+    resource: core-switch
+    portGroup: 0
+    portIndex: 0
+  b:
+    resource: access-switch
+    portGroup: 1
+    portIndex: 0
+  label: uplink
+# core-switch ─ proxmox-node01 (10G)
+- a:
+    resource: core-switch
+    portGroup: 2
+    portIndex: 1
+  b:
+    resource: proxmox-node01
+    portGroup: 1
+    portIndex: 0
+  label: 10G
+# core-switch ─ proxmox-node02 (10G)
+- a:
+    resource: core-switch
+    portGroup: 2
+    portIndex: 2
+  b:
+    resource: proxmox-node02
+    portGroup: 1
+    portIndex: 0
+  label: 10G
+# core-switch ─ truenas-storage (10G)
+- a:
+    resource: core-switch
+    portGroup: 2
+    portIndex: 3
+  b:
+    resource: truenas-storage
+    portGroup: 1
+    portIndex: 0
+  label: 10G
+# core-switch ─ docker-host (2.5G)
+- a:
+    resource: core-switch
+    portGroup: 1
+    portIndex: 0
+  b:
+    resource: docker-host
+    portGroup: 0
+    portIndex: 0
+  label: 2.5G
+# access-switch ─ lounge-ap (PoE)
+- a:
+    resource: access-switch
+    portGroup: 0
+    portIndex: 0
+  b:
+    resource: lounge-ap
+    portGroup: 0
+    portIndex: 0
+  label: PoE
+# access-switch ─ basement-ap (PoE)
+- a:
+    resource: access-switch
+    portGroup: 0
+    portIndex: 1
+  b:
+    resource: basement-ap
+    portGroup: 0
+    portIndex: 0
+  label: PoE
+# access-switch ─ workstation-linux
+- a:
+    resource: access-switch
+    portGroup: 0
+    portIndex: 2
+  b:
+    resource: workstation-linux
+    portGroup: 0
+    portIndex: 0
+# access-switch ─ gaming-pc
+- a:
+    resource: access-switch
+    portGroup: 0
+    portIndex: 3
+  b:
+    resource: gaming-pc
+    portGroup: 0
+    portIndex: 0

+ 0 - 208
vhs/sample_config/hardware.yaml

@@ -1,208 +0,0 @@
-resources:
-  # ------------------------
-  # Servers
-  # ------------------------
-  - kind: Server
-    name: proxmox-node01
-    cpus:
-      - model: AMD EPYC 7302P
-        cores: 16
-        threads: 32
-    ram:
-      size: 128gb
-      mts: 3200
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: ssd
-        size: 1tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
-  - kind: Server
-    name: proxmox-node02
-    cpus:
-      - model: Intel Xeon Silver 4210
-        cores: 10
-        threads: 20
-    ram:
-      size: 96gb
-      mts: 2666
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: hdd
-        size: 4tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 2
-      - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  - kind: Server
-    name: truenas-storage
-    cpus:
-      - model: Intel Xeon E-2236
-        cores: 6
-        threads: 12
-    ram:
-      size: 64gb
-      mts: 2666
-    drives:
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-      - type: hdd
-        size: 8tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-      - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  # ------------------------
-  # Network
-  # ------------------------
-  - kind: Firewall
-    name: pfsense-fw
-    model: Netgate-6100
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 4
-      - type: sfp+
-        speed: 10gb
-        count: 2
-    managed: true
-    poe: false
-
-  - kind: Router
-    name: core-router
-    model: Ubiquiti-ER-4
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 4
-      - type: sfp
-        speed: 10gb
-        count: 1
-    managed: true
-    poe: false
-
-  - kind: Switch
-    name: core-switch
-    model: UniFi-USW-Enterprise-24
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 12
-      - type: rj45
-        speed: 2.5gb
-        count: 8
-      - type: sfp+
-        speed: 10gb
-        count: 4
-    managed: true
-    poe: true
-
-  - kind: Switch
-    name: access-switch
-    model: UniFi-USW-16-PoE
-    ports:
-      - type: rj45
-        speed: 1gb
-        count: 16
-      - type: sfp
-        speed: 1gb
-        count: 2
-    managed: true
-    poe: true
-
-  - kind: AccessPoint
-    name: lounge-ap
-    model: UniFi-U6-Pro
-    speed: 2.5gb
-
-  # ------------------------
-  # Power
-  # ------------------------
-  - kind: Ups
-    name: rack-ups
-    model: APC-SmartUPS-2200
-    va: 2200
-
-  # ------------------------
-  # Desktops
-  # ------------------------
-  - kind: Desktop
-    name: workstation-linux
-    cpus:
-      - model: AMD Ryzen 9 5900X
-        cores: 12
-        threads: 24
-    ram:
-      size: 64gb
-      mts: 3600
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: ssd
-        size: 2tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: NVIDIA RTX 3080
-        vram: 10gb
-
-  - kind: Desktop
-    name: gaming-pc
-    cpus:
-      - model: Intel Core i7-12700K
-        cores: 12
-        threads: 20
-    ram:
-      size: 32gb
-      mts: 3200
-    drives:
-      - type: ssd
-        size: 1tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: NVIDIA RTX 3070
-        vram: 8gb
-
-  # ------------------------
-  # Laptop
-  # ------------------------
-  - kind: Laptop
-    name: dev-laptop
-    cpus:
-      - model: Intel Core i7-1260P
-        cores: 12
-        threads: 16
-    ram:
-      size: 32gb
-      mts: 5200
-    drives:
-      - type: ssd
-        size: 1tb

+ 0 - 153
vhs/sample_config/services.yaml

@@ -1,153 +0,0 @@
-resources:
-  # --------------------------------------------------
-  # Smart Home
-  # --------------------------------------------------
-  - kind: Service
-    name: home-assistant
-    network:
-      ip: 192.168.0.10
-      port: 8123
-      protocol: TCP
-      url: http://homeassistant.lan:8123
-    runsOn: vm-home-assistant
-
-  # --------------------------------------------------
-  # Media & Photos
-  # --------------------------------------------------
-  - kind: Service
-    name: plex
-    network:
-      ip: 192.168.0.20
-      port: 32400
-      protocol: TCP
-      url: http://plex.lan:32400
-    runsOn: vm-media-server
-
-  - kind: Service
-    name: jellyfin
-    network:
-      ip: 192.168.0.21
-      port: 8096
-      protocol: TCP
-      url: http://jellyfin.lan:8096
-    runsOn: vm-media-server
-
-  - kind: Service
-    name: immich
-    network:
-      ip: 192.168.0.22
-      port: 8080
-      protocol: TCP
-      url: http://immich.lan:8080
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Storage & Backup
-  # --------------------------------------------------
-  - kind: Service
-    name: truenas-webui
-    network:
-      ip: 192.168.0.30
-      port: 443
-      protocol: TCP
-      url: https://truenas.lan
-    runsOn: truenas-core-os
-
-  - kind: Service
-    name: minio
-    network:
-      ip: 192.168.0.31
-      port: 9000
-      protocol: TCP
-      url: http://minio.lan:9000
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Monitoring & Ops
-  # --------------------------------------------------
-  - kind: Service
-    name: prometheus
-    network:
-      ip: 192.168.0.40
-      port: 9090
-      protocol: TCP
-      url: http://prometheus.lan:9090
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: grafana
-    network:
-      ip: 192.168.0.41
-      port: 3000
-      protocol: TCP
-      url: http://grafana.lan:3000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: alertmanager
-    network:
-      ip: 192.168.0.42
-      port: 9093
-      protocol: TCP
-      url: http://alertmanager.lan:9093
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Dev & Internal Tools
-  # --------------------------------------------------
-  - kind: Service
-    name: gitea
-    network:
-      ip: 192.168.0.50
-      port: 3001
-      protocol: TCP
-      url: http://git.lan:3001
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: docker-registry
-    network:
-      ip: 192.168.0.51
-      port: 5000
-      protocol: TCP
-      url: http://registry.lan:5000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: portainer
-    network:
-      ip: 192.168.0.52
-      port: 9000
-      protocol: TCP
-      url: http://portainer.lan:9000
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Network Services
-  # --------------------------------------------------
-  - kind: Service
-    name: pihole
-    network:
-      ip: 192.168.0.53
-      port: 80
-      protocol: TCP
-      url: http://pihole.lan
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: firewall-webui
-    network:
-      ip: 192.168.0.1
-      port: 443
-      protocol: TCP
-      url: https://firewall.lan
-    runsOn: firewall-os
-
-  - kind: Service
-    name: router-webui
-    network:
-      ip: 192.168.0.254
-      port: 443
-      protocol: TCP
-      url: https://router.lan
-    runsOn: router-os

+ 0 - 154
vhs/sample_config/systems.yaml

@@ -1,154 +0,0 @@
-resources:
-  # --------------------------------------------------
-  # Hypervisors (Bare Metal)
-  # --------------------------------------------------
-  - kind: System
-    type: Hypervisor
-    name: proxmox-cluster-node01
-    os: proxmox
-    cores: 16
-    ram: 128gb
-    drives:
-      - size: 1tb
-      - size: 1tb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: Hypervisor
-    name: proxmox-cluster-node02
-    os: proxmox
-    cores: 10
-    ram: 96gb
-    drives:
-      - size: 1tb
-      - size: 4tb
-    runsOn: proxmox-node02
-
-  # --------------------------------------------------
-  # Storage OS (Bare Metal)
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: truenas-core-os
-    os: truenas
-    cores: 6
-    ram: 64gb
-    drives:
-      - size: 8tb
-      - size: 8tb
-      - size: 8tb
-      - size: 8tb
-    runsOn: truenas-storage
-
-  # --------------------------------------------------
-  # IPMI / BMC Management
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: ipmi-proxmox-node01
-    os: idrac
-    cores: 1
-    ram: 1gb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: Baremetal
-    name: ipmi-proxmox-node02
-    os: ipmi
-    cores: 1
-    ram: 1gb
-    runsOn: proxmox-node02
-
-  - kind: System
-    type: Baremetal
-    name: ipmi-truenas-storage
-    os: ipmi
-    cores: 1
-    ram: 1gb
-    runsOn: truenas-storage
-
-  # --------------------------------------------------
-  # Core Network Systems
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: firewall-os
-    os: pfsense
-    cores: 4
-    ram: 8gb
-    drives:
-      - size: 32gb
-    runsOn: pfsense-fw
-
-  - kind: System
-    type: Baremetal
-    name: router-os
-    os: edgeos
-    cores: 4
-    ram: 4gb
-    drives:
-      - size: 4gb
-    runsOn: core-router
-
-  - kind: System
-    type: Baremetal
-    name: unifi-core-switch-os
-    os: unifi-os
-    cores: 2
-    ram: 2gb
-    drives:
-      - size: 8gb
-    runsOn: core-switch
-
-  - kind: System
-    type: Baremetal
-    name: unifi-access-switch-os
-    os: unifi-os
-    cores: 2
-    ram: 2gb
-    drives:
-      - size: 8gb
-    runsOn: access-switch
-
-  - kind: System
-    type: Baremetal
-    name: unifi-lounge-ap-os
-    os: unifi-firmware
-    cores: 2
-    ram: 1gb
-    drives:
-      - size: 4gb
-    runsOn: lounge-ap
-
-  # --------------------------------------------------
-  # Virtual Machines
-  # --------------------------------------------------
-  - kind: System
-    type: VM
-    name: vm-home-assistant
-    os: hassos
-    cores: 2
-    ram: 4gb
-    drives:
-      - size: 64gb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: VM
-    name: vm-media-server
-    os: ubuntu-22.04
-    cores: 4
-    ram: 8gb
-    drives:
-      - size: 500gb
-    runsOn: proxmox-node02
-
-  - kind: System
-    type: VM
-    name: vm-monitoring
-    os: debian-12
-    cores: 2
-    ram: 4gb
-    drives:
-      - size: 64gb
-    runsOn: proxmox-node01

+ 7 - 1
vhs/webui_capture.sh

@@ -10,7 +10,13 @@ DELAY=200  # delay between frames in GIF (ms)
 # -----------------------------
 # Convert to GIF using ImageMagick
 # -----------------------------
+# The /visualise/ screenshots are captured at a much taller viewport so the
+# full diagram fits in one frame — those are too tall to roll into the
+# uniform-size rotating GIF, so we leave them out (they live as standalone
+# images in webui_screenshots/ for embedding directly in the README/docs).
 echo "Creating GIF..."
-convert -delay $DELAY -loop 0 "$OUTPUT_DIR"/*.png "$GIF_OUTPUT"
+convert -delay $DELAY -loop 0 \
+  $(ls "$OUTPUT_DIR"/*.png | grep -v "_visualise_") \
+  "$GIF_OUTPUT"
 
 echo "Done! GIF saved to $GIF_OUTPUT"

二進制
vhs/webui_screenshots/localhost:5287.png


二進制
vhs/webui_screenshots/localhost:5287_cli.png


二進制
vhs/webui_screenshots/localhost:5287_hardware_tree.png


二進制
vhs/webui_screenshots/localhost:5287_resources_hardware_proxmox-node01.png


二進制
vhs/webui_screenshots/localhost:5287_servers_list.png


二進制
vhs/webui_screenshots/localhost:5287_services_list.png


二進制
vhs/webui_screenshots/localhost:5287_systems_list.png


二進制
vhs/webui_screenshots/localhost:5287_visualise_logical.png


二進制
vhs/webui_screenshots/localhost:5287_visualise_topology.png


二進制
vhs/webui_screenshots/localhost:5287_yaml.png


二進制
vhs/webui_screenshots/output.gif


部分文件因文件數量過多而無法顯示