Kaynağa Gözat

Updated demo homepage

Tim Jones 2 hafta önce
ebeveyn
işleme
385bf1120b

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

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

+ 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"), """