Browse Source

Added connections

Tim Jones 1 month ago
parent
commit
a7d580d3da
40 changed files with 2033 additions and 501 deletions
  1. 10 0
      RackPeek.Domain/Persistence/IResourceCollection.cs
  2. 97 2
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  3. 80 0
      RackPeek.Domain/Resources/Connections/AddConnectionUseCase.cs
  4. 19 0
      RackPeek.Domain/Resources/Connections/Connection.cs
  5. 13 0
      RackPeek.Domain/Resources/Connections/ConnectionHelpers.cs
  6. 22 0
      RackPeek.Domain/Resources/Connections/GetConnectionForPortUseCase.cs
  7. 22 0
      RackPeek.Domain/Resources/Connections/GetConnectionsForResourceUseCase.cs
  8. 22 0
      RackPeek.Domain/Resources/Connections/RemoveConnectionUseCase.cs
  9. 1 1
      RackPeek.Domain/RpkConstants.cs
  10. 7 0
      RackPeek.Domain/ServiceCollectionExtensions.cs
  11. 89 6
      RackPeek.Domain/UseCases/Ports/RemovePortUseCase.cs
  12. 18 0
      RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs
  13. 1 1
      RackPeek/RackPeek.csproj
  14. 12 0
      Shared.Rcl/CliBootstrap.cs
  15. 88 0
      Shared.Rcl/Commands/Connections/ConnectionAddCommand.cs
  16. 55 0
      Shared.Rcl/Commands/Connections/ConnectionRemoveCommand.cs
  17. 30 0
      Shared.Rcl/Connections/ConnectionsPage.razor
  18. 401 0
      Shared.Rcl/Connections/PortConnectionModal.razor
  19. 113 0
      Shared.Rcl/Connections/PortGroupVisualizer.razor
  20. 181 0
      Shared.Rcl/Connections/PortLayout.razor
  21. 7 96
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  22. 6 98
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  23. 73 4
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  24. 3 0
      Shared.Rcl/Hardware/HardwareTreePage.razor
  25. 172 0
      Shared.Rcl/Hardware/PortGroupEditor.razor
  26. 1 0
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  27. 6 98
      Shared.Rcl/Routers/RouterCardComponent.razor
  28. 8 92
      Shared.Rcl/Servers/ServerCardComponent.razor
  29. 6 100
      Shared.Rcl/Switches/SwitchCardComponent.razor
  30. 2 0
      Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs
  31. 133 0
      Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs
  32. 146 0
      Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs
  33. 174 0
      Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs
  34. 4 2
      Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs
  35. 3 1
      Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs
  36. 1 0
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  37. 1 0
      Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs
  38. 2 0
      Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs
  39. 2 0
      Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs
  40. 2 0
      Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs

+ 10 - 0
RackPeek.Domain/Persistence/IResourceCollection.cs

@@ -1,4 +1,5 @@
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
@@ -35,4 +36,13 @@ public interface IResourceCollection {
     Task<IReadOnlyList<Resource>> GetDependantsAsync(string name);
 
     Task Merge(string incomingYaml, MergeMode mode);
+    
+    
+    
+    Task AddConnectionAsync(Connection connection);
+    Task RemoveConnectionAsync(Connection connection);
+    Task RemoveConnectionsForPortAsync(PortReference port);
+    Task<IReadOnlyList<Connection>> GetConnectionsAsync();
+    Task<IReadOnlyList<Connection>> GetConnectionsForResourceAsync(string resource);
+    Task<Connection?> GetConnectionForPortAsync(PortReference port);
 }

+ 97 - 2
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -3,6 +3,7 @@ using System.Collections.Specialized;
 using System.Diagnostics;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Hardware;
@@ -21,6 +22,7 @@ namespace RackPeek.Domain.Persistence.Yaml;
 public class ResourceCollection {
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public List<Resource> Resources { get; } = new();
+    public List<Connection> Connections { get; } = new();
 }
 
 public sealed class YamlResourceCollection(
@@ -191,6 +193,11 @@ public sealed class YamlResourceCollection(
 
         if (root.Resources != null)
             resourceCollection.Resources.AddRange(root.Resources);
+        
+        resourceCollection.Connections.Clear();
+
+        if (root.Connections != null)
+            resourceCollection.Connections.AddRange(root.Connections);
     }
 
     public Task AddAsync(Resource resource) {
@@ -312,11 +319,13 @@ public sealed class YamlResourceCollection(
 
         // Preserve ordering: version first, then resources
         Debug.Assert(root != null, nameof(root) + " != null");
+        
         var payload = new OrderedDictionary {
             ["version"] = root.Version,
-            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
+            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList(),
+            ["connections"] = root.Connections ?? new List<Connection>()
         };
-
+        
         await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
     }
 
@@ -362,9 +371,95 @@ public sealed class YamlResourceCollection(
 
         return map;
     }
+    
+    private static bool PortsMatch(PortReference a, PortReference b)
+    {
+        return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase)
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+    public Task AddConnectionAsync(Connection connection)
+    {
+        return UpdateConnectionsWithLockAsync(list =>
+        {
+            list.Add(connection);
+        });
+    }
+    public Task RemoveConnectionAsync(Connection connection)
+    {
+        return UpdateConnectionsWithLockAsync(list =>
+        {
+            list.RemoveAll(c =>
+                (PortsMatch(c.A, connection.A) && PortsMatch(c.B, connection.B)) ||
+                (PortsMatch(c.A, connection.B) && PortsMatch(c.B, connection.A)));
+        });
+    }
+    public Task RemoveConnectionsForPortAsync(PortReference port)
+    {
+        return UpdateConnectionsWithLockAsync(list =>
+        {
+            list.RemoveAll(c =>
+                PortsMatch(c.A, port) ||
+                PortsMatch(c.B, port));
+        });
+    }
+    public Task<IReadOnlyList<Connection>> GetConnectionsAsync()
+    {
+        IReadOnlyList<Connection> result =
+            resourceCollection.Connections
+                .ToList()
+                .AsReadOnly();
+
+        return Task.FromResult(result);
+    }
+    public Task<IReadOnlyList<Connection>> GetConnectionsForResourceAsync(string resource)
+    {
+        IReadOnlyList<Connection> result =
+            resourceCollection.Connections
+                .Where(c =>
+                    c.A.Resource.Equals(resource, StringComparison.OrdinalIgnoreCase) ||
+                    c.B.Resource.Equals(resource, StringComparison.OrdinalIgnoreCase))
+                .ToList()
+                .AsReadOnly();
+
+        return Task.FromResult(result);
+    }
+    public Task<Connection?> GetConnectionForPortAsync(PortReference port)
+    {
+        Connection? connection =
+            resourceCollection.Connections
+                .FirstOrDefault(c =>
+                    PortsMatch(c.A, port) ||
+                    PortsMatch(c.B, port));
+
+        return Task.FromResult(connection);
+    }
+    private async Task UpdateConnectionsWithLockAsync(Action<List<Connection>> action)
+    {
+        await resourceCollection.FileLock.WaitAsync();
+        try
+        {
+            action(resourceCollection.Connections);
+
+            var root = new YamlRoot
+            {
+                Version = _currentSchemaVersion,
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
+            };
+
+            await SaveRootAsync(root);
+        }
+        finally
+        {
+            resourceCollection.FileLock.Release();
+        }
+    }
 }
 
 public class YamlRoot {
     public int Version { get; set; }
     public List<Resource>? Resources { get; set; }
+    
+    public List<Connection>? Connections { get; set; }
 }

+ 80 - 0
RackPeek.Domain/Resources/Connections/AddConnectionUseCase.cs

@@ -0,0 +1,80 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IAddConnectionUseCase
+{
+    Task ExecuteAsync(
+        PortReference a,
+        PortReference b,
+        string? label = null,
+        string? notes = null);
+}
+
+public class AddConnectionUseCase(IResourceCollection repository)
+    : IAddConnectionUseCase
+{
+    public async Task ExecuteAsync(
+        PortReference a,
+        PortReference b,
+        string? label,
+        string? notes)
+    {
+        a.Resource = Normalize.HardwareName(a.Resource);
+        b.Resource = Normalize.HardwareName(b.Resource);
+
+        ThrowIfInvalid.ResourceName(a.Resource);
+        ThrowIfInvalid.ResourceName(b.Resource);
+
+        if (PortsMatch(a, b))
+            throw new InvalidOperationException(
+                "Cannot connect a port to itself.");
+
+        await ValidatePortReference(a);
+        await ValidatePortReference(b);
+
+        // Overwrite behavior:
+        // each PortReference may appear in only one connection,
+        // so remove any existing connection involving either endpoint.
+        await repository.RemoveConnectionsForPortAsync(a);
+        await repository.RemoveConnectionsForPortAsync(b);
+
+        var connection = new Connection
+        {
+            A = a,
+            B = b,
+            Label = label,
+            Notes = notes
+        };
+
+        await repository.AddConnectionAsync(connection);
+    }
+
+    private async Task ValidatePortReference(PortReference port)
+    {
+        Resource resource =
+            await repository.GetByNameAsync<Resource>(port.Resource)
+            ?? throw new NotFoundException($"Resource '{port.Resource}' not found.");
+
+        if (resource is not IPortResource pr || pr.Ports == null)
+            throw new InvalidOperationException($"Resource '{port.Resource}' has no ports.");
+
+        if (port.PortGroup < 0 || port.PortGroup >= pr.Ports.Count)
+            throw new NotFoundException($"Port group {port.PortGroup} not found.");
+
+        Port group = pr.Ports[port.PortGroup];
+
+        if (port.PortIndex < 0 || port.PortIndex >= (group.Count ?? 0))
+            throw new NotFoundException($"Port index {port.PortIndex} not found.");
+    }
+
+    private static bool PortsMatch(PortReference a, PortReference b)
+    {
+        return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase)
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+}

+ 19 - 0
RackPeek.Domain/Resources/Connections/Connection.cs

@@ -0,0 +1,19 @@
+namespace RackPeek.Domain.Resources.Connections;
+
+public class Connection
+{
+    public PortReference A { get; set; } = null!;
+
+    public PortReference B { get; set; } = null!;
+
+    public string? Label { get; set; }
+
+    public string? Notes { get; set; }
+}
+
+public class PortReference
+{
+    public string Resource { get; set; } = null!;
+    public int PortGroup { get; set; }
+    public int PortIndex { get; set; }
+}

+ 13 - 0
RackPeek.Domain/Resources/Connections/ConnectionHelpers.cs

@@ -0,0 +1,13 @@
+namespace RackPeek.Domain.Resources.Connections;
+
+public static class ConnectionHelpers
+{
+    public static bool Matches(PortReference a, PortReference b)
+    {
+        return a.Resource == b.Resource
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+
+    public static bool Contains(Connection c, PortReference port) => Matches(c.A, port) || Matches(c.B, port);
+}

+ 22 - 0
RackPeek.Domain/Resources/Connections/GetConnectionForPortUseCase.cs

@@ -0,0 +1,22 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IGetConnectionForPortUseCase
+{
+    Task<Connection?> ExecuteAsync(PortReference port);
+}
+
+public class GetConnectionForPortUseCase(IResourceCollection repository)
+    : IGetConnectionForPortUseCase
+{
+    public async Task<Connection?> ExecuteAsync(PortReference port)
+    {
+        port.Resource = Normalize.HardwareName(port.Resource);
+
+        ThrowIfInvalid.ResourceName(port.Resource);
+
+        return await repository.GetConnectionForPortAsync(port);
+    }
+}

+ 22 - 0
RackPeek.Domain/Resources/Connections/GetConnectionsForResourceUseCase.cs

@@ -0,0 +1,22 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IGetConnectionsForResourceUseCase
+{
+    Task<IReadOnlyList<Connection>> ExecuteAsync(string resource);
+}
+
+public class GetConnectionsForResourceUseCase(IResourceCollection repository)
+    : IGetConnectionsForResourceUseCase
+{
+    public async Task<IReadOnlyList<Connection>> ExecuteAsync(string resource)
+    {
+        resource = Normalize.HardwareName(resource);
+
+        ThrowIfInvalid.ResourceName(resource);
+
+        return await repository.GetConnectionsForResourceAsync(resource);
+    }
+}

+ 22 - 0
RackPeek.Domain/Resources/Connections/RemoveConnectionUseCase.cs

@@ -0,0 +1,22 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.Connections;
+
+public interface IRemoveConnectionUseCase
+{
+    Task ExecuteAsync(PortReference port);
+}
+
+public class RemoveConnectionUseCase(IResourceCollection repository)
+    : IRemoveConnectionUseCase
+{
+    public async Task ExecuteAsync(PortReference port)
+    {
+        port.Resource = Normalize.HardwareName(port.Resource);
+
+        ThrowIfInvalid.ResourceName(port.Resource);
+
+        await repository.RemoveConnectionsForPortAsync(port);
+    }
+}

+ 1 - 1
RackPeek.Domain/RpkConstants.cs

@@ -1,5 +1,5 @@
 namespace RackPeek.Domain;
 
 public static class RpkConstants {
-    public const string Version = "v1.2.0";
+    public const string Version = "v1.3.0";
 }

+ 7 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -2,6 +2,7 @@ using System.Reflection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
@@ -70,6 +71,12 @@ public static class ServiceCollectionExtensions {
         services.AddScoped(typeof(IRemovePortUseCase<>), typeof(RemovePortUseCase<>));
         services.AddScoped(typeof(IUpdatePortUseCase<>), typeof(UpdatePortUseCase<>));
 
+        services.AddScoped(typeof(IAddConnectionUseCase), typeof(AddConnectionUseCase));
+        services.AddScoped(typeof(IGetConnectionForPortUseCase), typeof(GetConnectionForPortUseCase));
+        services.AddScoped(typeof(IGetConnectionsForResourceUseCase), typeof(GetConnectionsForResourceUseCase));
+        services.AddScoped(typeof(IRemoveConnectionUseCase), typeof(RemoveConnectionUseCase));
+
+        
         IEnumerable<Type>? usecases = Assembly.GetAssembly(typeof(IUseCase))
             ?.GetTypes()
             .Where(t =>

+ 89 - 6
RackPeek.Domain/UseCases/Ports/RemovePortUseCase.cs

@@ -1,29 +1,112 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
 
 namespace RackPeek.Domain.UseCases.Ports;
 
 public interface IRemovePortUseCase<T> : IResourceUseCase<T>
-    where T : Resource {
-    public Task ExecuteAsync(string name, int index);
+    where T : Resource
+{
+    Task ExecuteAsync(string name, int index);
 }
 
-public class RemovePortUseCase<T>(IResourceCollection repository) : IRemovePortUseCase<T> where T : Resource {
-    public async Task ExecuteAsync(string name, int index) {
+public class RemovePortUseCase<T>(IResourceCollection repository)
+    : IRemovePortUseCase<T> where T : Resource
+{
+    public async Task ExecuteAsync(string name, int index)
+    {
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
 
         T resource = await repository.GetByNameAsync<T>(name)
                      ?? throw new NotFoundException($"Resource '{name}' not found.");
 
-        if (resource is not IPortResource pr) throw new NotFoundException($"Resource '{name}' not found.");
-
+        if (resource is not IPortResource pr)
+            throw new NotFoundException($"Resource '{name}' not found.");
 
         if (pr.Ports == null || index < 0 || index >= pr.Ports.Count)
             throw new NotFoundException($"Port index {index} not found on '{name}'.");
 
+        IReadOnlyList<Connection> connections =
+            await repository.GetConnectionsForResourceAsync(name);
+
+        var toRemove = new List<Connection>();
+        var toAdd = new List<Connection>();
+
+        foreach (Connection connection in connections)
+        {
+            var changed = false;
+
+            PortReference a = connection.A;
+            PortReference b = connection.B;
+
+            // handle A side
+            if (a.Resource.Equals(name, StringComparison.OrdinalIgnoreCase))
+            {
+                if (a.PortGroup == index)
+                {
+                    toRemove.Add(connection);
+                    continue;
+                }
+
+                if (a.PortGroup > index)
+                {
+                    a = new PortReference
+                    {
+                        Resource = a.Resource,
+                        PortGroup = a.PortGroup - 1,
+                        PortIndex = a.PortIndex
+                    };
+
+                    changed = true;
+                }
+            }
+
+            // handle B side
+            if (b.Resource.Equals(name, StringComparison.OrdinalIgnoreCase))
+            {
+                if (b.PortGroup == index)
+                {
+                    toRemove.Add(connection);
+                    continue;
+                }
+
+                if (b.PortGroup > index)
+                {
+                    b = new PortReference
+                    {
+                        Resource = b.Resource,
+                        PortGroup = b.PortGroup - 1,
+                        PortIndex = b.PortIndex
+                    };
+
+                    changed = true;
+                }
+            }
+
+            if (changed)
+            {
+                toRemove.Add(connection);
+
+                toAdd.Add(new Connection
+                {
+                    A = a,
+                    B = b,
+                    Label = connection.Label,
+                    Notes = connection.Notes
+                });
+            }
+        }
+
+        foreach (Connection connection in toRemove)
+            await repository.RemoveConnectionAsync(connection);
+
+        foreach (Connection connection in toAdd)
+            await repository.AddConnectionAsync(connection);
+
         pr.Ports.RemoveAt(index);
 
         await repository.UpdateAsync(resource);

+ 18 - 0
RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs

@@ -1,6 +1,7 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.SubResources;
 
@@ -41,6 +42,23 @@ public class UpdatePortUseCase<T>(IResourceCollection repository) : IUpdatePortU
             throw new NotFoundException($"Port index {index} not found on '{name}'.");
 
         Port nic = pr.Ports[index];
+
+        var oldCount = nic.Count ?? 0;
+        var newCount = ports ?? oldCount;
+
+        if (newCount < oldCount)
+        {
+            for (var i = newCount; i < oldCount; i++)
+            {
+                await repository.RemoveConnectionsForPortAsync(new PortReference
+                {
+                    Resource = name,
+                    PortGroup = index,
+                    PortIndex = i
+                });
+            }
+        }
+
         nic.Type = nicType;
         nic.Speed = speed;
         nic.Count = ports;

+ 1 - 1
RackPeek/RackPeek.csproj

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

+ 12 - 0
Shared.Rcl/CliBootstrap.cs

@@ -8,6 +8,7 @@ using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
+using Shared.Rcl.Commands.Connections;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops.Cpus;
 using Shared.Rcl.Commands.Desktops.Drive;
@@ -577,6 +578,17 @@ public static class CliBootstrap {
                 hosts.AddCommand<GenerateHostsFileCommand>("export")
                     .WithDescription("Generate a /etc/hosts compatible file.");
             });
+            
+            config.AddBranch("connections", connections =>
+            {
+                connections.SetDescription("Manage physical or logical port connections.");
+
+                connections.AddCommand<ConnectionAddCommand>("add")
+                    .WithDescription("Create a connection between two ports.");
+
+                connections.AddCommand<ConnectionRemoveCommand>("remove")
+                    .WithDescription("Remove the connection from a specific port.");
+            });
         });
     }
 

+ 88 - 0
Shared.Rcl/Commands/Connections/ConnectionAddCommand.cs

@@ -0,0 +1,88 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Connections;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Connections;
+
+public class ConnectionAddSettings : CommandSettings
+{
+    [CommandArgument(0, "<A_RESOURCE>")]
+    [Description("Resource name for endpoint A.")]
+    public string AResource { get; set; } = null!;
+
+    [CommandArgument(1, "<A_GROUP>")]
+    [Description("Port group index for endpoint A.")]
+    public int AGroup { get; set; }
+
+    [CommandArgument(2, "<A_INDEX>")]
+    [Description("Port index for endpoint A.")]
+    public int AIndex { get; set; }
+
+    [CommandArgument(3, "<B_RESOURCE>")]
+    [Description("Resource name for endpoint B.")]
+    public string BResource { get; set; } = null!;
+
+    [CommandArgument(4, "<B_GROUP>")]
+    [Description("Port group index for endpoint B.")]
+    public int BGroup { get; set; }
+
+    [CommandArgument(5, "<B_INDEX>")]
+    [Description("Port index for endpoint B.")]
+    public int BIndex { get; set; }
+
+    [CommandOption("--label")]
+    [Description("Optional label for the connection.")]
+    public string? Label { get; set; }
+
+    [CommandOption("--notes")]
+    [Description("Optional notes for the connection.")]
+    public string? Notes { get; set; }
+}
+
+public class ConnectionAddCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ConnectionAddSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ConnectionAddSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using IServiceScope scope = serviceProvider.CreateScope();
+
+        IAddConnectionUseCase useCase =
+            scope.ServiceProvider.GetRequiredService<IAddConnectionUseCase>();
+
+        var a = new PortReference
+        {
+            Resource = settings.AResource,
+            PortGroup = settings.AGroup,
+            PortIndex = settings.AIndex
+        };
+
+        var b = new PortReference
+        {
+            Resource = settings.BResource,
+            PortGroup = settings.BGroup,
+            PortIndex = settings.BIndex
+        };
+
+        await useCase.ExecuteAsync(
+            a,
+            b,
+            settings.Label,
+            settings.Notes
+        );
+
+        AnsiConsole.MarkupLine(
+            $"[green]Connection created:[/] " +
+            $"{settings.AResource}:{settings.AGroup}:{settings.AIndex} " +
+            $"<-> " +
+            $"{settings.BResource}:{settings.BGroup}:{settings.BIndex}"
+        );
+
+        return 0;
+    }
+}

+ 55 - 0
Shared.Rcl/Commands/Connections/ConnectionRemoveCommand.cs

@@ -0,0 +1,55 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Connections;
+using RackPeek.Domain.Resources.SubResources;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Connections;
+
+public class ConnectionRemoveSettings : CommandSettings
+{
+    [CommandArgument(0, "<RESOURCE>")]
+    [Description("Resource name.")]
+    public string Resource { get; set; } = null!;
+
+    [CommandArgument(1, "<PORT_GROUP>")]
+    [Description("Port group index.")]
+    public int PortGroup { get; set; }
+
+    [CommandArgument(2, "<PORT_INDEX>")]
+    [Description("Port index.")]
+    public int PortIndex { get; set; }
+}
+
+public class ConnectionRemoveCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ConnectionRemoveSettings>
+{
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ConnectionRemoveSettings settings,
+        CancellationToken cancellationToken)
+    {
+        using IServiceScope scope = serviceProvider.CreateScope();
+
+        IRemoveConnectionUseCase useCase =
+            scope.ServiceProvider.GetRequiredService<IRemoveConnectionUseCase>();
+
+        var port = new PortReference
+        {
+            Resource = settings.Resource,
+            PortGroup = settings.PortGroup,
+            PortIndex = settings.PortIndex
+        };
+
+        await useCase.ExecuteAsync(port);
+
+        AnsiConsole.MarkupLine(
+            $"[green]Connection removed from[/] " +
+            $"{settings.Resource}:{settings.PortGroup}:{settings.PortIndex}"
+        );
+
+        return 0;
+    }
+}

+ 30 - 0
Shared.Rcl/Connections/ConnectionsPage.razor

@@ -0,0 +1,30 @@
+@page "/connections"
+
+<div class="p-6 space-y-4">
+
+    <div class="text-zinc-200 text-lg">
+        Connections
+    </div>
+
+    <button class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+            @onclick="OpenModal">
+        Add Connection
+    </button>
+
+</div>
+
+<PortConnectionModal
+    IsOpen="_modalOpen"
+    IsOpenChanged="v => _modalOpen = v"
+    TestIdPrefix="connections"/>
+
+@code {
+
+    bool _modalOpen;
+
+    void OpenModal()
+    {
+        _modalOpen = true;
+    }
+
+}

+ 401 - 0
Shared.Rcl/Connections/PortConnectionModal.razor

@@ -0,0 +1,401 @@
+@using RackPeek.Domain.Resources
+@using RackPeek.Domain.Resources.Connections
+@using RackPeek.Domain.Resources.SubResources
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources.Servers
+
+@inject IResourceCollection Repository
+@inject IAddConnectionUseCase AddConnectionUseCase
+
+@if (IsOpen)
+{
+<div class="fixed inset-0 z-50 flex items-center justify-center">
+
+    <div class="absolute inset-0 bg-black/70" @onclick="Cancel"></div>
+
+    <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-3xl p-4">
+
+        <div class="flex justify-between mb-4">
+            <div class="text-zinc-100 text-sm font-medium">
+                Create Connection
+            </div>
+
+            <button class="text-zinc-400 hover:text-zinc-200"
+                    @onclick="Cancel">
+                ✕
+            </button>
+        </div>
+
+        <div class="grid grid-cols-2 gap-6 text-sm">
+
+            <!-- SIDE A -->
+            <div class="space-y-3">
+
+                <div class="text-zinc-400">Side A</div>
+
+                <select class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                        @bind="_resourceAIndex">
+
+                    <option value="">Select resource</option>
+
+                    @for (int i = 0; i < HardwareWithPorts.Count; i++)
+                    {
+                        var hw = (Resource)HardwareWithPorts[i];
+                        <option value="@i">@hw.Name</option>
+                    }
+
+                </select>
+
+                @if (_resourceA?.Ports?.Any() == true)
+                {
+                    <select class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind="_groupAIndex">
+
+                        <option value="">Select group</option>
+
+                        @for (int i = 0; i < _resourceA.Ports.Count; i++)
+                        {
+                            var g = _resourceA.Ports[i];
+                            <option value="@i">@g.Type — @g.Speed Gbps (@g.Count)</option>
+                        }
+
+                    </select>
+                }
+
+                @if (_groupA is not null)
+                {
+                    <select class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind="_portAIndex">
+
+                        <option value="">Select port</option>
+
+                        @for (int i = 0; i < _groupA.Count; i++)
+                        {
+                            <option value="@i">Port @(i + 1)</option>
+                        }
+
+                    </select>
+
+                    <PortGroupVisualizer
+                        ResourceName="@_portA.Resource"
+                        PortGroupIndex="@_portA.PortGroup"
+                        PortGroup="@_groupA"
+                        @bind-SelectedPortIndex="_portAIndex"
+                        OnPortClicked="HandleLeftPortClicked" />
+                }
+
+            </div>
+
+
+            <!-- SIDE B -->
+            <div class="space-y-3">
+
+                <div class="text-zinc-400">Side B</div>
+
+                <select class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                        @bind="_resourceBIndex">
+
+                    <option value="">Select resource</option>
+
+                    @for (int i = 0; i < HardwareWithPorts.Count; i++)
+                    {
+                        var hw = (Resource)HardwareWithPorts[i];
+                        <option value="@i">@hw.Name</option>
+                    }
+
+                </select>
+
+                @if (_resourceB?.Ports?.Any() == true)
+                {
+                    <select class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind="_groupBIndex">
+
+                        <option value="">Select group</option>
+
+                        @for (int i = 0; i < _resourceB.Ports.Count; i++)
+                        {
+                            var g = _resourceB.Ports[i];
+                            <option value="@i">@g.Type — @g.Speed Gbps (@g.Count)</option>
+                        }
+
+                    </select>
+                }
+
+                @if (_groupB is not null)
+                {
+                    <select class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind="_portBIndex">
+
+                        <option value="">Select port</option>
+
+                        @for (int i = 0; i < _groupB.Count; i++)
+                        {
+                            <option value="@i">Port @(i + 1)</option>
+                        }
+
+                    </select>
+
+                    <PortGroupVisualizer
+                        ResourceName="@_portB.Resource"
+                        PortGroupIndex="@_portB.PortGroup"
+                        PortGroup="@_groupB"
+                        @bind-SelectedPortIndex="_portBIndex"
+                        OnPortClicked="HandleRightPortClicked" />
+                }
+
+            </div>
+
+        </div>
+
+        <div class="flex justify-end gap-2 mt-6">
+
+            <button class="px-3 py-1 border border-zinc-700 rounded text-zinc-300"
+                    @onclick="Cancel">
+                Cancel
+            </button>
+
+            <button class="px-3 py-1 rounded bg-emerald-600 text-black"
+                    disabled="@(!CanSubmit)"
+                    @onclick="HandleSubmit">
+                Add Connection
+            </button>
+
+        </div>
+
+    </div>
+
+</div>
+}
+
+@code {
+
+[Parameter] public bool IsOpen { get; set; }
+[Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+[Parameter] public string? TestIdPrefix { get; set; }
+
+private string BaseTestId =>
+    string.IsNullOrWhiteSpace(TestIdPrefix)
+        ? "connection-modal"
+        : $"{TestIdPrefix}-connection-modal";
+
+[Parameter] public PortReference? SeedPort { get; set; }
+
+List<IPortResource> HardwareWithPorts = new();
+
+IPortResource? _resourceA;
+IPortResource? _resourceB;
+
+Port? _groupA;
+Port? _groupB;
+
+PortReference _portA = new();
+PortReference _portB = new();
+
+int? _resourceAIndexValue;
+int? _resourceBIndexValue;
+
+int? _groupAIndexValue;
+int? _groupBIndexValue;
+
+int? _portAIndex;
+int? _portBIndex;
+
+
+int? _resourceAIndex
+{
+    get => _resourceAIndexValue;
+    set
+    {
+        _resourceAIndexValue = value;
+
+        if (value is null)
+        {
+            _resourceA = null;
+            _groupA = null;
+            _portAIndex = null;
+            return;
+        }
+
+        _resourceA = HardwareWithPorts[value.Value];
+
+        _portA.Resource = ((Resource)_resourceA).Name;
+
+        _groupAIndex = null;
+        _portAIndex = null;
+    }
+}
+
+
+int? _resourceBIndex
+{
+    get => _resourceBIndexValue;
+    set
+    {
+        _resourceBIndexValue = value;
+
+        if (value is null)
+        {
+            _resourceB = null;
+            _groupB = null;
+            _portBIndex = null;
+            return;
+        }
+
+        _resourceB = HardwareWithPorts[value.Value];
+
+        _portB.Resource = ((Resource)_resourceB).Name;
+
+        _groupBIndex = null;
+        _portBIndex = null;
+    }
+}
+
+
+int? _groupAIndex
+{
+    get => _groupAIndexValue;
+    set
+    {
+        _groupAIndexValue = value;
+
+        if (value is null || _resourceA == null)
+        {
+            _groupA = null;
+            _portAIndex = null;
+            return;
+        }
+
+        _groupA = _resourceA.Ports![value.Value];
+
+        _portA.PortGroup = value.Value;
+
+        _portAIndex = null;
+    }
+}
+
+
+int? _groupBIndex
+{
+    get => _groupBIndexValue;
+    set
+    {
+        _groupBIndexValue = value;
+
+        if (value is null || _resourceB == null)
+        {
+            _groupB = null;
+            _portBIndex = null;
+            return;
+        }
+
+        _groupB = _resourceB.Ports![value.Value];
+
+        _portB.PortGroup = value.Value;
+
+        _portBIndex = null;
+    }
+}
+
+
+bool CanSubmit =>
+    _groupA != null &&
+    _groupB != null &&
+    _portAIndex != null &&
+    _portBIndex != null;
+
+
+protected override async Task OnParametersSetAsync()
+{
+    if (!IsOpen) return;
+
+    var all = await Repository.GetAllOfTypeAsync<IPortResource>();
+
+    HardwareWithPorts = all
+        .Where(h => h.Ports?.Any() == true)
+        .ToList();
+
+    if (SeedPort != null)
+        SeedSinglePortA(SeedPort);
+}
+
+
+async Task HandleLeftPortClicked(PortReference port)
+{
+    var existing = await Repository.GetConnectionForPortAsync(port);
+
+    if (existing != null)
+        SeedConnection(existing);
+    else
+        SeedSinglePortA(port);
+}
+
+
+async Task HandleRightPortClicked(PortReference port)
+{
+    var existing = await Repository.GetConnectionForPortAsync(port);
+
+    if (existing != null)
+        SeedConnection(existing);
+    else
+        SeedSinglePortB(port);
+}
+
+
+void SeedSinglePortA(PortReference port)
+{
+    _resourceAIndex = HardwareWithPorts.FindIndex(r => ((Resource)r).Name == port.Resource);
+
+    _groupAIndex = port.PortGroup;
+
+    _portAIndex = port.PortIndex;
+}
+
+
+void SeedSinglePortB(PortReference port)
+{
+    _resourceBIndex = HardwareWithPorts.FindIndex(r => ((Resource)r).Name == port.Resource);
+
+    _groupBIndex = port.PortGroup;
+
+    _portBIndex = port.PortIndex;
+}
+
+
+void SeedConnection(Connection conn)
+{
+    SeedSinglePortA(conn.A);
+    SeedSinglePortB(conn.B);
+}
+
+async Task HandleSubmit()
+{
+    if (!CanSubmit) return;
+
+    var a = new PortReference
+    {
+        Resource = _portA.Resource,
+        PortGroup = _portA.PortGroup,
+        PortIndex = _portAIndex!.Value
+    };
+
+    var b = new PortReference
+    {
+        Resource = _portB.Resource,
+        PortGroup = _portB.PortGroup,
+        PortIndex = _portBIndex!.Value
+    };
+
+    await AddConnectionUseCase.ExecuteAsync(a, b, null, null);
+
+    await Cancel();
+}
+
+
+
+async Task Cancel()
+{
+    await IsOpenChanged.InvokeAsync(false);
+}
+
+}

+ 113 - 0
Shared.Rcl/Connections/PortGroupVisualizer.razor

@@ -0,0 +1,113 @@
+@using RackPeek.Domain.Resources.Connections
+@using RackPeek.Domain.Resources.SubResources
+@using RackPeek.Domain.Persistence
+
+@inject IResourceCollection Repository
+
+@if (PortGroup is not null && !string.IsNullOrWhiteSpace(ResourceName))
+{
+    <div class="flex flex-wrap">
+
+        @for (int i = 0; i < PortGroup.Count; i++)
+        {
+            var index = i;
+
+            var port = new PortReference
+            {
+                Resource = ResourceName,
+                PortGroup = PortGroupIndex,
+                PortIndex = index
+            };
+
+            var conn = GetConnection(port);
+            bool selected = SelectedPortIndex == index;
+
+            <button type="button"
+                    title="@GetTooltip(conn, port)"
+                    class="group flex flex-col items-center w-6 leading-none"
+                    @onclick="() => SelectPort(index, port)">
+
+                <div class="w-6 h-3 flex items-center justify-center
+                            shadow-inner
+                            border-t border-b border-r
+                            @(index == 0 ? "border-l" : "")
+                            @(selected
+                                ? "bg-emerald-500 border-emerald-400"
+                                : conn != null
+                                    ? "bg-blue-600 border-blue-500"
+                                    : "bg-zinc-800 border-zinc-700 group-hover:bg-zinc-700")">
+
+                    <div class="w-2 h-[1.5px]
+                                @(selected
+                                    ? "bg-black"
+                                    : conn != null
+                                        ? "bg-blue-200"
+                                        : "bg-zinc-600")">
+                    </div>
+
+                </div>
+
+                <div class="text-[8px] text-zinc-500 mt-[1px]">
+                    @(index + 1)
+                </div>
+
+            </button>
+        }
+
+    </div>
+}
+
+@code {
+
+    [Parameter] public string ResourceName { get; set; } = "";
+    [Parameter] public int PortGroupIndex { get; set; }
+    [Parameter] public Port? PortGroup { get; set; }
+
+    [Parameter] public int? SelectedPortIndex { get; set; }
+    [Parameter] public EventCallback<int?> SelectedPortIndexChanged { get; set; }
+
+    [Parameter] public EventCallback<PortReference> OnPortClicked { get; set; }
+
+    private List<Connection> _connections = new();
+
+    protected override async Task OnParametersSetAsync()
+    {
+        _connections = (await Repository.GetConnectionsAsync()).ToList();
+    }
+
+    async Task SelectPort(int index, PortReference port)
+    {
+        if (SelectedPortIndexChanged.HasDelegate)
+            await SelectedPortIndexChanged.InvokeAsync(index);
+
+        if (OnPortClicked.HasDelegate)
+            await OnPortClicked.InvokeAsync(port);
+    }
+
+    Connection? GetConnection(PortReference port)
+    {
+        return _connections.FirstOrDefault(c =>
+            (c.A.Resource == port.Resource &&
+             c.A.PortGroup == port.PortGroup &&
+             c.A.PortIndex == port.PortIndex)
+            ||
+            (c.B.Resource == port.Resource &&
+             c.B.PortGroup == port.PortGroup &&
+             c.B.PortIndex == port.PortIndex));
+    }
+
+    string GetTooltip(Connection? conn, PortReference port)
+    {
+        if (conn == null)
+            return "Available";
+
+        var other =
+            conn.A.Resource == port.Resource &&
+            conn.A.PortGroup == port.PortGroup &&
+            conn.A.PortIndex == port.PortIndex
+                ? conn.B
+                : conn.A;
+
+        return $"{other.Resource} (port {other.PortIndex + 1})";
+    }
+}

+ 181 - 0
Shared.Rcl/Connections/PortLayout.razor

@@ -0,0 +1,181 @@
+@using RackPeek.Domain.Resources.Connections
+@using RackPeek.Domain.Resources.SubResources
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources.Servers
+
+@inject IResourceCollection Repository
+
+@if (PortGroup is null || string.IsNullOrWhiteSpace(ResourceName))
+{
+    <div class="text-zinc-500 text-xs">
+        No ports available.
+    </div>
+}
+else
+{
+    <div class="flex flex-wrap border border-zinc-800 w-fit">
+
+        @for (int i = 0; i < PortGroup.Count; i++)
+        {
+            var index = i;
+
+            var port = new PortReference
+            {
+                Resource = ResourceName,
+                PortGroup = PortGroupIndex,
+                PortIndex = index
+            };
+
+            var conn = GetConnection(port);
+            var other = GetOther(conn, port);
+            var isConnected = other != null;
+
+            Port? otherGroup = null;
+
+            if (isConnected)
+            {
+                otherGroup = GetDestinationPortGroup(other!);
+            }
+
+            if (isConnected)
+            {
+                <NavLink href="@($"resources/hardware/{Uri.EscapeDataString(other!.Resource)}")"
+                         class="block">
+
+                    <div class="@PortClass(true)">
+
+                        <div class="text-zinc-500">
+                            @(index + 1)
+                        </div>
+
+                        <div class="truncate">
+                            @other!.Resource
+                        </div>
+
+                        @if (otherGroup != null)
+                        {
+                            <div class="text-[9px] text-zinc-400 leading-tight">
+                                @otherGroup.Type — @otherGroup.Speed Gbps
+                                (port @(other.PortIndex + 1) / @otherGroup.Count)
+                            </div>
+                        }
+
+                    </div>
+
+                </NavLink>
+            }
+            else
+            {
+                <button class="block text-left"
+                        @onclick="() => HandlePortClick(port)">
+
+                    <div class="@PortClass(false)">
+
+                        <div class="text-zinc-500">
+                            @(index + 1)
+                        </div>
+
+                        <div class="text-zinc-700 italic">
+                            free
+                        </div>
+
+                    </div>
+
+                </button>
+            }
+        }
+
+    </div>
+}
+
+@code {
+
+    [Parameter] public string ResourceName { get; set; } = "";
+    [Parameter] public int PortGroupIndex { get; set; }
+    [Parameter] public Port? PortGroup { get; set; }
+
+    [Parameter] public EventCallback<PortReference> OnPortClicked { get; set; }
+
+    private List<Connection> _connections = new();
+    private Dictionary<string, IPortResource?> _portResources = new();
+
+    protected override async Task OnParametersSetAsync()
+    {
+        _connections = (await Repository.GetConnectionsAsync()).ToList();
+    }
+
+    async Task HandlePortClick(PortReference port)
+    {
+        if (OnPortClicked.HasDelegate)
+            await OnPortClicked.InvokeAsync(port);
+    }
+
+    string PortClass(bool connected)
+    {
+        return $@"
+            w-28
+            h-12
+            border-r
+            border-b
+            border-zinc-800
+            text-[10px]
+            leading-tight
+            flex
+            flex-col
+            justify-center
+            px-1
+            transition
+            hover:bg-zinc-800
+            {(connected ? "bg-blue-950/40 text-blue-200" : "text-zinc-500")}
+        ";
+    }
+
+    Connection? GetConnection(PortReference port)
+    {
+        return _connections.FirstOrDefault(c =>
+            (c.A.Resource == port.Resource &&
+             c.A.PortGroup == port.PortGroup &&
+             c.A.PortIndex == port.PortIndex)
+            ||
+            (c.B.Resource == port.Resource &&
+             c.B.PortGroup == port.PortGroup &&
+             c.B.PortIndex == port.PortIndex));
+    }
+
+    PortReference? GetOther(Connection? conn, PortReference port)
+    {
+        if (conn == null)
+            return null;
+
+        if (conn.A.Resource == port.Resource &&
+            conn.A.PortGroup == port.PortGroup &&
+            conn.A.PortIndex == port.PortIndex)
+            return conn.B;
+
+        return conn.A;
+    }
+
+    Port? GetDestinationPortGroup(PortReference other)
+    {
+        if (!_portResources.ContainsKey(other.Resource))
+        {
+            var res = Repository.GetByNameAsync(other.Resource).Result;
+
+            if (res is IPortResource pr)
+                _portResources[other.Resource] = pr;
+            else
+                _portResources[other.Resource] = null;
+        }
+
+        var portResource = _portResources[other.Resource];
+
+        if (portResource?.Ports == null)
+            return null;
+
+        if (other.PortGroup < 0 || other.PortGroup >= portResource.Ports.Count)
+            return null;
+
+        return portResource.Ports[other.PortGroup];
+    }
+
+}

+ 7 - 96
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -3,7 +3,7 @@
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
-@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Hardware
 @inject IGetResourceByNameUseCase<Desktop> GetByNameUseCase
 @inject UpdateDesktopUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<Desktop> DeleteUseCase
@@ -13,9 +13,6 @@
 @inject IAddDriveUseCase<Desktop> AddDriveUseCase
 @inject IUpdateDriveUseCase<Desktop> UpdateDriveUseCase
 @inject IRemoveDriveUseCase<Desktop> RemoveDriveUseCase
-@inject IAddPortUseCase<Desktop> AddNicUseCase
-@inject IUpdatePortUseCase<Desktop> UpdateNicUseCase
-@inject IRemovePortUseCase<Desktop> RemoveNicUseCase
 @inject IAddGpuUseCase<Desktop> AddGpuUseCase
 @inject IUpdateGpuUseCase<Desktop> UpdateGpuUseCase
 @inject IRemoveGpuUseCase<Desktop> RemoveGpuUseCase
@@ -152,35 +149,12 @@
         </div>
 
         <!-- NICs -->
-        <div data-testid="desktop-nic-section">
-            <div class="flex items-center justify-between mb-1 group">
-                <div class="text-zinc-400">
-                    NICs
-                    <button data-testid="add-nic-button"
-                            class="hover:text-emerald-400 transition"
-                            @onclick="OpenAddNic">
-                        +
-                    </button>
-                </div>
-            </div>
-
-            @if (Desktop.Ports?.Any() == true)
-            {
-                @foreach (var nic in Desktop.Ports)
-                {
-                    <div
-                        class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
-                        <button data-testid=@($"edit-nic-{nic.Type}-{nic.Speed}")
-                                class="hover:text-emerald-400"
-                                @onclick="() => OpenEditNic(nic)">
-                            @nic.Type — @nic.Speed Gbps (@nic.Count ports)
-                        </button>
-                    </div>
-                }
-            }
-        </div>
+        <PortGroupEditor T="Desktop"
+                         Resource="Desktop"
+                         OnResourceChanged="r => Desktop = r" 
+                         TestIdPrefix="desktop-ports"/>
 
-        <!-- GPUs -->
+                         <!-- GPUs -->
         <div data-testid="desktop-gpu-section">
             <div class="flex items-center justify-between mb-1 group">
                 <div class="text-zinc-400">
@@ -297,15 +271,6 @@
           OnDelete="HandleGpuDelete"
           TestIdPrefix="desktop"/>
 
-
-<PortModal
-    IsOpen="@_nicModalOpen"
-    IsOpenChanged="v => _nicModalOpen = v"
-    Value="@_editingNic"
-    OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"
-    TestIdPrefix="desktop-nic"/>
-
 @code {
     [Parameter] [EditorRequired] public Desktop Desktop { get; set; } = default!;
 
@@ -420,61 +385,7 @@
     }
 
     #endregion
-
-    #region NICs
-
-    bool _nicModalOpen;
-    int _editingNicIndex;
-    Port? _editingNic;
-
-    void OpenAddNic()
-    {
-        _editingNicIndex = -1;
-        _editingNic = null;
-        _nicModalOpen = true;
-    }
-
-    void OpenEditNic(Port nic)
-    {
-        Desktop.Ports ??= new List<Port>();
-        _editingNicIndex = Desktop.Ports.IndexOf(nic);
-        _editingNic = nic;
-        _nicModalOpen = true;
-    }
-
-    async Task HandleNicSubmit(Port nic)
-    {
-        Desktop.Ports ??= new List<Port>();
-
-        if (_editingNicIndex < 0)
-        {
-            await AddNicUseCase.ExecuteAsync(
-                Desktop.Name,
-                nic.Type,
-                nic.Speed,
-                nic.Count);
-        }
-        else
-        {
-            await UpdateNicUseCase.ExecuteAsync(
-                Desktop.Name,
-                _editingNicIndex,
-                nic.Type,
-                nic.Speed,
-                nic.Count);
-        }
-
-        Desktop = await GetByNameUseCase.ExecuteAsync(Desktop.Name);
-    }
-
-    async Task HandleNicDelete(Port nic)
-    {
-        await RemoveNicUseCase.ExecuteAsync(Desktop.Name, _editingNicIndex);
-        Desktop = await GetByNameUseCase.ExecuteAsync(Desktop.Name);
-    }
-
-    #endregion
-
+    
     #region GPUs
 
     bool _gpuModalOpen;

+ 6 - 98
Shared.Rcl/Firewalls/FirewallCardComponent.razor

@@ -1,11 +1,7 @@
 @using RackPeek.Domain.Resources.Firewalls
-@using RackPeek.Domain.Resources.SubResources
-@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Hardware
 @inject UpdateFirewallUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Firewall> GetByNameUseCase
-@inject IAddPortUseCase<Firewall> AddPortUseCase
-@inject IUpdatePortUseCase<Firewall> UpdatePortUseCase
-@inject IRemovePortUseCase<Firewall> RemovePortUseCase
 @inject IDeleteResourceUseCase<Firewall> DeleteUseCase
 @inject ICloneResourceUseCase<Firewall> CloneUseCase
 @inject IRenameResourceUseCase<Firewall> RenameUseCase
@@ -133,35 +129,10 @@
         </div>
 
         <!-- Ports -->
-        <div class="md:col-span-2"
-             data-testid="firewall-ports-section">
-
-            <div class="flex items-center justify-between mb-1 group">
-                <div class="text-zinc-400">
-                    Ports
-                    <button data-testid="add-port-button"
-                            class="hover:text-emerald-400 ml-1"
-                            @onclick="OpenAddPort">
-                        +
-                    </button>
-                </div>
-            </div>
-
-            @if (Firewall.Ports?.Any() == true)
-            {
-                @foreach (var port in Firewall.Ports)
-                {
-                    <div
-                        class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
-                        <button data-testid=@($"edit-port-{port.Type}-{port.Speed}")
-                                class="hover:text-emerald-400"
-                                @onclick="() => OpenEditPort(port)">
-                            @port.Count× @port.Type — @port.Speed Gbps
-                        </button>
-                    </div>
-                }
-            }
-        </div>
+        <PortGroupEditor T="Firewall"
+                         Resource="Firewall"
+                         OnResourceChanged="r => Firewall = r" 
+                         TestIdPrefix="firewall-ports"/>
 
         <ResourceTagEditor Resource="Firewall"
                            TestIdPrefix="firewall"/>
@@ -192,12 +163,6 @@
     </div>
 </div>
 
-<PortModal IsOpen="@_portModalOpen"
-           IsOpenChanged="v => _portModalOpen = v"
-           Value="@_editingPort"
-           OnSubmit="HandlePortSubmit"
-           OnDelete="HandlePortDelete"/>
-
 <ConfirmModal IsOpen="_confirmDeleteOpen"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               Title="Delete firewall"
@@ -256,64 +221,7 @@
     {
         _isEditing = false;
     }
-
-    #region Ports
-
-    bool _portModalOpen;
-    int _editingPortIndex;
-    Port? _editingPort;
-
-    void OpenAddPort()
-    {
-        _editingPortIndex = -1;
-        _editingPort = null;
-        _portModalOpen = true;
-    }
-
-    void OpenEditPort(Port port)
-    {
-        Firewall.Ports ??= new List<Port>();
-        _editingPortIndex = Firewall.Ports.IndexOf(port);
-        _editingPort = port;
-        _portModalOpen = true;
-    }
-
-    async Task HandlePortSubmit(Port port)
-    {
-        if (_editingPortIndex < 0)
-        {
-            await AddPortUseCase.ExecuteAsync(
-                Firewall.Name,
-                port.Type,
-                port.Speed,
-                port.Count);
-        }
-        else
-        {
-            await UpdatePortUseCase.ExecuteAsync(
-                Firewall.Name,
-                _editingPortIndex,
-                port.Type,
-                port.Speed,
-                port.Count);
-        }
-
-        Firewall = await GetByNameUseCase.ExecuteAsync(Firewall.Name);
-        StateHasChanged();
-    }
-
-    async Task HandlePortDelete(Port _)
-    {
-        await RemovePortUseCase.ExecuteAsync(
-            Firewall.Name,
-            _editingPortIndex);
-
-        Firewall = await GetByNameUseCase.ExecuteAsync(Firewall.Name);
-        StateHasChanged();
-    }
-
-    #endregion
-
+    
     public class FirewallEditModel
     {
         public string? Model { get; set; }

+ 73 - 4
Shared.Rcl/Hardware/HardwareDetailsPage.razor

@@ -1,6 +1,7 @@
 @page "/resources/hardware/{HardwareName}"
 @using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Resources.AccessPoints
+@using RackPeek.Domain.Resources.Connections
 @using RackPeek.Domain.Resources.Desktops
 @using RackPeek.Domain.Resources.Firewalls
 @using RackPeek.Domain.Resources.Hardware
@@ -9,6 +10,7 @@
 @using RackPeek.Domain.Resources.Switches
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.UpsUnits
+@using RackPeek.Domain.Resources.SubResources
 @using Shared.Rcl.AccessPoints
 @using Shared.Rcl.Desktops
 @using Shared.Rcl.Firewalls
@@ -17,7 +19,9 @@
 @using Shared.Rcl.Servers
 @using Shared.Rcl.Switches
 @using Shared.Rcl.Ups
+@using Shared.Rcl.Connections
 @using Router = RackPeek.Domain.Resources.Routers.Router
+
 @inject IResourceCollection Repo
 @inject GetHardwareSystemTreeUseCase GetHardwareSystemTreeUseCase
 @inject NavigationManager Nav
@@ -30,6 +34,7 @@
 />
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
+
     @if (_hardware is null && !_loading)
     {
         <div class="text-zinc-500">Hardware not found</div>
@@ -40,6 +45,9 @@
     }
     else
     {
+
+        @* ================= Hardware Card ================= *@
+
         @if (_hardware != null)
         {
             <h1 class="text-lg text-zinc-100 mb-6">
@@ -87,6 +95,8 @@
         }
 
 
+        @* ================= Dependency Tree ================= *@
+
         @if (_tree is not null && _tree.Systems.Any())
         {
             <HardwareDependencyTreeComponent Tree="_tree"/>
@@ -98,22 +108,72 @@
             </div>
         }
 
+
+        @* ================= Ports ================= *@
+
+        @if (_hardware is IPortResource portResource && portResource.Ports?.Any() == true)
+        {
+            <div class="mt-8 space-y-6">
+
+                <div class="text-zinc-400 text-sm uppercase tracking-wide">
+                    Ports
+                </div>
+
+                @for (int i = 0; i < portResource.Ports.Count; i++)
+                {
+                    var portGroup = portResource.Ports[i];
+
+                    <div class="space-y-2">
+
+                        <div class="text-xs text-zinc-500">
+                            @portGroup.Type — @portGroup.Speed Gbps (@portGroup.Count ports)
+                        </div>
+
+                        <PortLayout
+                            ResourceName="@_hardware!.Name"
+                            PortGroupIndex="i"
+                            PortGroup="portGroup"
+                            OnPortClicked="HandlePortClicked"
+                        />
+
+                    </div>
+                }
+
+            </div>
+        }
+
+        
         <div class="m-4">
             <AddResourceComponent TResource="SystemResource"
                                   Placeholder="System name"
                                   OnCreated="NavigateToNewResource"
-                                  RunsOn="@(new List<string>  { HardwareName })"/>
+                                  RunsOn="@(new List<string> { HardwareName })"/>
         </div>
+
     }
+
 </div>
 
+
+<PortConnectionModal
+    IsOpen="@_connectionModalOpen"
+    IsOpenChanged="v => _connectionModalOpen = v"
+    SeedPort="@_selectedPort"
+/>
+
+
 @code {
+
     [Parameter] public string HardwareName { get; set; } = string.Empty;
 
     private Hardware? _hardware;
     private bool _loading = true;
     private HardwareDependencyTree? _tree;
 
+    private bool _connectionModalOpen;
+    private PortReference? _selectedPort;
+
+
     protected override async Task OnParametersSetAsync()
     {
         _loading = true;
@@ -130,16 +190,25 @@
         _loading = false;
     }
 
+
+    void HandlePortClicked(PortReference port)
+    {
+        _selectedPort = port;
+        _connectionModalOpen = true;
+    }
+
+
     private Task DeleteCallback(string obj)
     {
         Nav.NavigateTo("/hardware/tree");
         return Task.CompletedTask;
     }
 
-    private Task NavigateToNewResource(string serverName)
+
+    private Task NavigateToNewResource(string systemName)
     {
-        Nav.NavigateTo($"resources/systems/{Uri.EscapeDataString(serverName)}");
+        Nav.NavigateTo($"resources/systems/{Uri.EscapeDataString(systemName)}");
         return Task.CompletedTask;
     }
 
-}
+}

+ 3 - 0
Shared.Rcl/Hardware/HardwareTreePage.razor

@@ -1,5 +1,6 @@
 @page "/hardware/tree"
 @using RackPeek.Domain.Resources.Hardware
+@using Shared.Rcl.Connections
 @inject IHardwareRepository HardwareRepository
 
 <PageTitle>Hardware</PageTitle>
@@ -9,6 +10,8 @@
     Hardware
 </h1>
 
+
+
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6"
      data-testid="hardware-page-root">
 

+ 172 - 0
Shared.Rcl/Hardware/PortGroupEditor.razor

@@ -0,0 +1,172 @@
+@typeparam T where T : Resource, RackPeek.Domain.Resources.Servers.IPortResource
+@using RackPeek.Domain.Resources
+@using RackPeek.Domain.Resources.SubResources
+@using RackPeek.Domain.UseCases.Ports
+@using RackPeek.Domain.Resources.Connections
+@using Shared.Rcl.Connections
+
+@inject IAddPortUseCase<T> AddNicUseCase
+@inject IUpdatePortUseCase<T> UpdateNicUseCase
+@inject IRemovePortUseCase<T> RemoveNicUseCase
+@inject IGetResourceByNameUseCase<T> GetByNameUseCase
+
+<div data-testid="@($"{BaseTestId}-section")">
+
+    <div class="flex items-center justify-between mb-1 group">
+        <div class="text-zinc-400">
+            Ports
+            <button
+                data-testid="@($"{BaseTestId}-add-button")"
+                class="hover:text-emerald-400 group-hover:opacity-100 transition"
+                title="Add Port"
+                @onclick="OpenAddNic">
+                +
+            </button>
+        </div>
+    </div>
+
+    @if (Resource.Ports?.Any() == true)
+    {
+        @foreach (var nic in Resource.Ports)
+        {
+            var idx = GetPortIndex(nic);
+
+            <div class="group hover:bg-zinc-800/40 rounded px-1 py-1 space-y-1"
+                 data-testid="@($"{BaseTestId}-item-{idx}")">
+
+                <div class="flex items-center justify-between text-zinc-300">
+
+                    <button
+                        data-testid="@($"{BaseTestId}-edit-{idx}")"
+                        class="hover:text-emerald-400 text-sm"
+                        title="Edit NIC"
+                        @onclick="() => OpenEditNic(nic)">
+                        @nic.Type — @nic.Speed Gbps (@nic.Count ports)
+                    </button>
+
+                </div>
+
+                <div class="pl-1"
+                     data-testid="@($"{BaseTestId}-ports-{idx}")">
+
+                    <PortGroupVisualizer
+                        ResourceName="@Resource.Name"
+                        PortGroupIndex="@idx"
+                        PortGroup="@nic"
+                        OnPortClicked="HandlePortClicked" />
+
+                </div>
+
+            </div>
+        }
+    }
+
+</div>
+
+
+<PortModal
+    IsOpen="@_nicModalOpen"
+    IsOpenChanged="v => _nicModalOpen = v"
+    Value="@_editingNic"
+    OnSubmit="HandleNicSubmit"
+    OnDelete="HandleNicDelete"
+    TestIdPrefix="@($"{BaseTestId}-modal")" />
+
+
+<PortConnectionModal
+    IsOpen="@_connectionModalOpen"
+    IsOpenChanged="v => _connectionModalOpen = v"
+    SeedPort="@_selectedPort"
+    TestIdPrefix="@($"{BaseTestId}-connection")" />
+
+
+@code {
+
+    [Parameter, EditorRequired] public T Resource { get; set; } = default!;
+    [Parameter] public EventCallback<T> OnResourceChanged { get; set; }
+    [Parameter] public string? TestIdPrefix { get; set; }
+
+    private string BaseTestId =>
+        string.IsNullOrWhiteSpace(TestIdPrefix)
+            ? "port-group-editor"
+            : $"{TestIdPrefix}-port-group";
+
+    bool _nicModalOpen;
+    bool _connectionModalOpen;
+
+    int _editingNicIndex;
+    Port? _editingNic;
+
+    PortReference? _selectedPort;
+
+    void OpenAddNic()
+    {
+        _editingNicIndex = -1;
+        _editingNic = null;
+        _nicModalOpen = true;
+    }
+
+    void OpenEditNic(Port nic)
+    {
+        Resource.Ports ??= new List<Port>();
+
+        _editingNicIndex = Resource.Ports.IndexOf(nic);
+        _editingNic = nic;
+
+        _nicModalOpen = true;
+    }
+
+    async Task HandleNicSubmit(Port nic)
+    {
+        Resource.Ports ??= new List<Port>();
+
+        if (_editingNicIndex < 0)
+        {
+            await AddNicUseCase.ExecuteAsync(
+                Resource.Name,
+                nic.Type,
+                nic.Speed,
+                nic.Count);
+        }
+        else
+        {
+            await UpdateNicUseCase.ExecuteAsync(
+                Resource.Name,
+                _editingNicIndex,
+                nic.Type,
+                nic.Speed,
+                nic.Count);
+        }
+
+        await RefreshResource();
+    }
+
+    async Task HandleNicDelete(Port nic)
+    {
+        await RemoveNicUseCase.ExecuteAsync(Resource.Name, _editingNicIndex);
+        await RefreshResource();
+    }
+
+    async Task RefreshResource()
+    {
+        Resource = await GetByNameUseCase.ExecuteAsync(Resource.Name);
+
+        if (OnResourceChanged.HasDelegate)
+            await OnResourceChanged.InvokeAsync(Resource);
+
+        StateHasChanged();
+    }
+
+    int GetPortIndex(Port port)
+    {
+        Resource.Ports ??= new List<Port>();
+        return Resource.Ports.IndexOf(port);
+    }
+
+    void HandlePortClicked(PortReference port)
+    {
+        _selectedPort = port;
+        _connectionModalOpen = true;
+    }
+
+}

+ 1 - 0
Shared.Rcl/Laptops/LaptopCardComponent.razor

@@ -3,6 +3,7 @@
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
+@using Shared.Rcl.Hardware
 @inject IGetResourceByNameUseCase<Laptop> GetByNameUseCase
 @inject UpdateLaptopUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<Laptop> DeleteUseCase

+ 6 - 98
Shared.Rcl/Routers/RouterCardComponent.razor

@@ -1,13 +1,9 @@
 @using RackPeek.Domain.Resources.Routers
-@using RackPeek.Domain.Resources.SubResources
-@using RackPeek.Domain.UseCases.Ports
 @using Router = RackPeek.Domain.Resources.Routers.Router
+@using Shared.Rcl.Hardware
 
 @inject UpdateRouterUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Router> GetByNameUseCase
-@inject IAddPortUseCase<Router> AddPortUseCase
-@inject IUpdatePortUseCase<Router> UpdatePortUseCase
-@inject IRemovePortUseCase<Router> RemovePortUseCase
 @inject IDeleteResourceUseCase<Router> DeleteUseCase
 @inject IRenameResourceUseCase<Router> RenameUseCase
 @inject ICloneResourceUseCase<Router> CloneUseCase
@@ -135,35 +131,10 @@
         </div>
 
         <!-- Ports -->
-        <div class="md:col-span-2"
-             data-testid="router-ports-section">
-
-            <div class="flex items-center justify-between mb-1 group">
-                <div class="text-zinc-400">
-                    Ports
-                    <button data-testid="add-port-button"
-                            class="hover:text-emerald-400 ml-1"
-                            @onclick="OpenAddPort">
-                        +
-                    </button>
-                </div>
-            </div>
-
-            @if (Router.Ports?.Any() == true)
-            {
-                @foreach (var port in Router.Ports)
-                {
-                    <div
-                        class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
-                        <button data-testid=@($"edit-port-{port.Type}-{port.Speed}")
-                                class="hover:text-emerald-400"
-                                @onclick="() => OpenEditPort(port)">
-                            @port.Count× @port.Type — @port.Speed Gbps
-                        </button>
-                    </div>
-                }
-            }
-        </div>
+        <PortGroupEditor T="Router"
+                         Resource="Router"
+                         OnResourceChanged="r => Router = r" 
+                         TestIdPrefix="router-ports"/>
 
         <ResourceTagEditor Resource="Router"
                            TestIdPrefix="router"/>
@@ -194,12 +165,6 @@
     </div>
 </div>
 
-<PortModal IsOpen="@_portModalOpen"
-           IsOpenChanged="v => _portModalOpen = v"
-           Value="@_editingPort"
-           OnSubmit="HandlePortSubmit"
-           OnDelete="HandlePortDelete"/>
-
 <ConfirmModal IsOpen="_confirmDeleteOpen"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               Title="Delete router"
@@ -258,64 +223,7 @@
     {
         _isEditing = false;
     }
-
-    #region Ports
-
-    bool _portModalOpen;
-    int _editingPortIndex;
-    Port? _editingPort;
-
-    void OpenAddPort()
-    {
-        _editingPortIndex = -1;
-        _editingPort = null;
-        _portModalOpen = true;
-    }
-
-    void OpenEditPort(Port port)
-    {
-        Router.Ports ??= new List<Port>();
-        _editingPortIndex = Router.Ports.IndexOf(port);
-        _editingPort = port;
-        _portModalOpen = true;
-    }
-
-    async Task HandlePortSubmit(Port port)
-    {
-        if (_editingPortIndex < 0)
-        {
-            await AddPortUseCase.ExecuteAsync(
-                Router.Name,
-                port.Type,
-                port.Speed,
-                port.Count);
-        }
-        else
-        {
-            await UpdatePortUseCase.ExecuteAsync(
-                Router.Name,
-                _editingPortIndex,
-                port.Type,
-                port.Speed,
-                port.Count);
-        }
-
-        Router = await GetByNameUseCase.ExecuteAsync(Router.Name);
-        StateHasChanged();
-    }
-
-    async Task HandlePortDelete(Port _)
-    {
-        await RemovePortUseCase.ExecuteAsync(
-            Router.Name,
-            _editingPortIndex);
-
-        Router = await GetByNameUseCase.ExecuteAsync(Router.Name);
-        StateHasChanged();
-    }
-
-    #endregion
-
+    
     public class RouterEditModel
     {
         public string? Model { get; set; }

+ 8 - 92
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -4,6 +4,8 @@
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
 @using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Connections
+@using Shared.Rcl.Hardware
 @inject IAddCpuUseCase<Server> AddCpuUseCase
 @inject IRemoveCpuUseCase<Server> RemoveCpuUseCase
 @inject IUpdateCpuUseCase<Server> UpdateCpuUseCase
@@ -168,37 +170,11 @@
             }
         </div>
 
-        <div>
-            <div class="flex items-center justify-between mb-1 group">
-                <div class="text-zinc-400">
-                    NICs
-                    <button
-                        class="hover:text-emerald-400 group-hover:opacity-100 transition"
-                        title="Add NIC"
-                        @onclick="OpenAddNic">
-                        +
-                    </button>
-                </div>
-            </div>
-
-            @if (Server.Ports?.Any() == true)
-            {
-                @foreach (var nic in Server.Ports)
-                {
-                    <div
-                        class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
-                        <button
-                            class="hover:text-emerald-400"
-                            title="Edit NIC"
-                            @onclick="() => OpenEditNic(nic)">
-                            @nic.Type — @nic.Speed Gbps (@nic.Count ports)
-                        </button>
-                    </div>
-                }
-            }
-        </div>
-
-
+        <PortGroupEditor T="Server"
+                         Resource="Server"
+                         OnResourceChanged="r => Server = r"
+                         TestIdPrefix="server-ports"/>
+        
         <div>
             <div class="flex items-center justify-between mb-1 group">
                 <div class="text-zinc-400">
@@ -287,13 +263,6 @@
     TestIdPrefix="server-drive"/>
 
 
-<PortModal
-    IsOpen="@_nicModalOpen"
-    IsOpenChanged="v => _nicModalOpen = v"
-    Value="@_editingNic"
-    OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"
-    TestIdPrefix="server-nic"/>
 
 <GpuModal
     IsOpen="@_gpuModalOpen"
@@ -451,60 +420,7 @@
     }
 
     #endregion
-
-    #region NICs
-
-    bool _nicModalOpen;
-    int _editingNicIndex;
-    Port? _editingNic;
-
-    void OpenAddNic()
-    {
-        _editingNicIndex = -1;
-        _editingNic = null;
-        _nicModalOpen = true;
-    }
-
-    void OpenEditNic(Port nic)
-    {
-        Server.Ports ??= new List<Port>();
-        _editingNicIndex = Server.Ports.IndexOf(nic);
-        _editingNic = nic;
-        _nicModalOpen = true;
-    }
-
-    async Task HandleNicSubmit(Port nic)
-    {
-        Server.Ports ??= new List<Port>();
-
-        if (_editingNicIndex < 0)
-        {
-            await AddNicUseCase.ExecuteAsync(
-                Server.Name,
-                nic.Type,
-                nic.Speed,
-                nic.Count);
-        }
-        else
-        {
-            await UpdateNicUseCase.ExecuteAsync(
-                Server.Name,
-                _editingNicIndex,
-                nic.Type,
-                nic.Speed,
-                nic.Count);
-        }
-
-        Server = await GetByNameUseCase.ExecuteAsync(Server.Name);
-    }
-
-    async Task HandleNicDelete(Port nic)
-    {
-        await RemoveNicUseCase.ExecuteAsync(Server.Name, _editingNicIndex);
-        Server = await GetByNameUseCase.ExecuteAsync(Server.Name);
-    }
-
-    #endregion
+    
 
     #region GPUs
 

+ 6 - 100
Shared.Rcl/Switches/SwitchCardComponent.razor

@@ -1,11 +1,7 @@
-@using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.Switches
-@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Hardware
 @inject UpdateSwitchUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Switch> GetByNameUseCase
-@inject IAddPortUseCase<Switch> AddPortUseCase
-@inject IUpdatePortUseCase<Switch> UpdatePortUseCase
-@inject IRemovePortUseCase<Switch> RemovePortUseCase
 @inject IDeleteResourceUseCase<Switch> DeleteUseCase
 @inject ICloneResourceUseCase<Switch> CloneUseCase
 @inject IRenameResourceUseCase<Switch> RenameUseCase
@@ -133,35 +129,10 @@
         </div>
 
         <!-- Ports -->
-        <div class="md:col-span-2"
-             data-testid="switch-ports-section">
-
-            <div class="flex items-center justify-between mb-1 group">
-                <div class="text-zinc-400">
-                    Ports
-                    <button data-testid="add-port-button"
-                            class="hover:text-emerald-400 ml-1"
-                            @onclick="OpenAddPort">
-                        +
-                    </button>
-                </div>
-            </div>
-
-            @if (Switch.Ports?.Any() == true)
-            {
-                @foreach (var port in Switch.Ports)
-                {
-                    <div
-                        class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
-                        <button data-testid=@($"edit-port-{port.Type}-{port.Speed}")
-                                class="hover:text-emerald-400"
-                                @onclick="() => OpenEditPort(port)">
-                            @port.Count× @port.Type — @port.Speed Gbps
-                        </button>
-                    </div>
-                }
-            }
-        </div>
+        <PortGroupEditor T="Switch"
+                         Resource="Switch"
+                         OnResourceChanged="r => Switch = r" 
+                         TestIdPrefix="switch-ports"/>
 
         <ResourceTagEditor Resource="Switch"
                            TestIdPrefix="switch"/>
@@ -192,12 +163,6 @@
     </div>
 </div>
 
-<PortModal IsOpen="@_portModalOpen"
-           IsOpenChanged="v => _portModalOpen = v"
-           Value="@_editingPort"
-           OnSubmit="HandlePortSubmit"
-           OnDelete="HandlePortDelete"/>
-
 <ConfirmModal IsOpen="_confirmDeleteOpen"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               Title="Delete switch"
@@ -257,64 +222,7 @@
     {
         _isEditing = false;
     }
-
-    #region Ports
-
-    bool _portModalOpen;
-    int _editingPortIndex;
-    Port? _editingPort;
-
-    void OpenAddPort()
-    {
-        _editingPortIndex = -1;
-        _editingPort = null;
-        _portModalOpen = true;
-    }
-
-    void OpenEditPort(Port port)
-    {
-        Switch.Ports ??= new List<Port>();
-        _editingPortIndex = Switch.Ports.IndexOf(port);
-        _editingPort = port;
-        _portModalOpen = true;
-    }
-
-    async Task HandlePortSubmit(Port port)
-    {
-        if (_editingPortIndex < 0)
-        {
-            await AddPortUseCase.ExecuteAsync(
-                Switch.Name,
-                port.Type,
-                port.Speed,
-                port.Count);
-        }
-        else
-        {
-            await UpdatePortUseCase.ExecuteAsync(
-                Switch.Name,
-                _editingPortIndex,
-                port.Type,
-                port.Speed,
-                port.Count);
-        }
-
-        Switch = await GetByNameUseCase.ExecuteAsync(Switch.Name);
-        StateHasChanged();
-    }
-
-    async Task HandlePortDelete(Port _)
-    {
-        await RemovePortUseCase.ExecuteAsync(
-            Switch.Name,
-            _editingPortIndex);
-
-        Switch = await GetByNameUseCase.ExecuteAsync(Switch.Name);
-        StateHasChanged();
-    }
-
-    #endregion
-
+    
     public class SwitchEditModel
     {
         public string? Model { get; set; }
@@ -333,8 +241,6 @@
             };
         }
     }
-
-
 }
 
 @code {

+ 2 - 0
Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs

@@ -41,6 +41,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
                        model: Unifi-U6-Lite
                        speed: 1
                        name: ap01
+                     connections: []
 
                      """, yaml);
 
@@ -65,6 +66,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
                        model: Aruba-AP-515
                        speed: 2.5
                        name: ap02
+                     connections: []
 
                      """, yaml);
 

+ 133 - 0
Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs

@@ -0,0 +1,133 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.ConnectionTests;
+
+[Collection("Yaml CLI tests")]
+public class ConnectionRemoveWorkflowTests(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("switches", "routers")]
+    [InlineData("firewalls", "routers")]
+    public async Task connections_remove_cli_workflow_test(string aType, string bType)
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        // Create resources
+        await ExecuteAsync(aType, "add", "node-a");
+        await ExecuteAsync(bType, "add", "node-b");
+
+        // Add ports
+        await ExecuteAsync(
+            aType, "port", "add", "node-a",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "2"
+        );
+
+        await ExecuteAsync(
+            bType, "port", "add", "node-b",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "2"
+        );
+
+        // Create connection
+        await ExecuteAsync(
+            "connections", "add",
+            "node-a", "0", "0",
+            "node-b", "0", "0"
+        );
+
+        // Remove connection
+        (var output, var yaml) = await ExecuteAsync(
+            "connections", "remove",
+            "node-a", "0", "0"
+        );
+
+        Assert.Contains("Connection removed", output);
+
+        // YAML should no longer contain connection
+        outputHelper.WriteLine(yaml);
+        Assert.Contains("connections: []", yaml);
+    }
+
+    [Fact]
+    public async Task removing_connection_from_other_endpoint_works()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("servers", "add", "srv02");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv01",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv02",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        await ExecuteAsync(
+            "connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0"
+        );
+
+        // Remove using other side
+        (var output, var yaml) = await ExecuteAsync(
+            "connections", "remove",
+            "srv02", "0", "0"
+        );
+
+        Assert.Contains("Connection removed", output);
+
+        outputHelper.WriteLine(yaml);
+        Assert.Contains("connections: []", yaml);
+    }
+
+    [Fact]
+    public async Task removing_nonexistent_connection_is_safe()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("switches", "add", "sw01");
+
+        await ExecuteAsync(
+            "switches", "port", "add", "sw01",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "2"
+        );
+
+        (var output, _) = await ExecuteAsync(
+            "connections", "remove",
+            "sw01", "0", "0"
+        );
+
+        Assert.Contains("Connection removed", output);
+    }
+}

+ 146 - 0
Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs

@@ -0,0 +1,146 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.ConnectionTests;
+
+[Collection("Yaml CLI tests")]
+public class ConnectionWorkflowTests(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("switches", "routers")]
+    [InlineData("firewalls", "routers")]
+
+    public async Task connections_cli_workflow_test(string aType, string bType)
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        // Create resources
+        await ExecuteAsync(aType, "add", "node-a");
+        await ExecuteAsync(bType, "add", "node-b");
+
+        // Add NIC to A
+        await ExecuteAsync(
+            aType, "port", "add", "node-a",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "2"
+        );
+
+        // Add NIC to B
+        await ExecuteAsync(
+            bType, "port", "add", "node-b",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "2"
+        );
+
+        // Create connection
+        (var output, var yaml) = await ExecuteAsync(
+            "connections", "add",
+            "node-a", "0", "0",
+            "node-b", "0", "0",
+            "--label", "uplink"
+        );
+
+        Assert.Contains("Connection created", output);
+
+        // YAML validation
+        Assert.Contains("connections:", yaml);
+        Assert.Contains("node-a", yaml);
+        Assert.Contains("node-b", yaml);
+        Assert.Contains("uplink", yaml);
+    }
+
+    [Fact]
+    public async Task connections_overwrite_existing_port_connection()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("servers", "add", "srv02");
+        await ExecuteAsync("servers", "add", "srv03");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv01",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv02",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv03",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        // First connection
+        await ExecuteAsync(
+            "connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0"
+        );
+
+        // Overwrite by connecting srv01 to srv03
+        (var output, var yaml) = await ExecuteAsync(
+            "connections", "add",
+            "srv01", "0", "0",
+            "srv03", "0", "0"
+        );
+
+        Assert.Contains("Connection created", output);
+
+        // YAML should contain srv01 <-> srv03
+        Assert.Contains("srv03", yaml);
+
+        // srv02 should no longer be connected
+        Assert.DoesNotContain("srv02\n  portGroup: 0\n  portIndex: 0", yaml);
+    }
+
+    [Fact]
+    public async Task connections_cannot_connect_port_to_itself()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("servers", "add", "srv01");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv01",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--ports", "2"
+        );
+
+        var output = await YamlCliTestHost.RunAsync(
+            new[] { "connections", "add", "srv01", "0", "0", "srv01", "0", "0" },
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        Assert.Contains("Cannot connect a port to itself", output);
+    }
+}

+ 174 - 0
Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs

@@ -0,0 +1,174 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.ConnectionTests;
+
+[Collection("Yaml CLI tests")]
+public class PortConnectionWorkflowTests(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("switches", "routers")]
+    [InlineData("firewalls", "routers")]
+    public async Task removing_port_removes_connections(string aType, string bType)
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync(aType, "add", "node-a");
+        await ExecuteAsync(bType, "add", "node-b");
+
+        await ExecuteAsync(aType, "port", "add", "node-a",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "2");
+
+        await ExecuteAsync(bType, "port", "add", "node-b",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "2");
+
+        await ExecuteAsync("connections", "add",
+            "node-a", "0", "0",
+            "node-b", "0", "0");
+
+        (var output, var yaml) = await ExecuteAsync(
+            aType, "port", "del", "node-a",
+            "--index", "0");
+
+        Assert.Contains("Port 0 removed", output);
+
+        Assert.Contains("connections: []", yaml);
+    }
+
+    [Theory]
+    [InlineData("switches", "routers")]
+    [InlineData("firewalls", "routers")]
+    public async Task removing_port_shifts_connection_groups(string aType, string bType)
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync(aType, "add", "node-a");
+        await ExecuteAsync(bType, "add", "node-b");
+
+        await ExecuteAsync(aType, "port", "add", "node-a",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "1");
+
+        await ExecuteAsync(aType, "port", "add", "node-a",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "1");
+
+        await ExecuteAsync(bType, "port", "add", "node-b",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "1");
+
+        await ExecuteAsync("connections", "add",
+            "node-a", "1", "0",
+            "node-b", "0", "0");
+
+        await ExecuteAsync(
+            aType, "port", "del", "node-a",
+            "--index", "0");
+
+        (string output, string yaml) executeAsync = await ExecuteAsync(
+            "connections", "add",
+            "node-a", "0", "0",
+            "node-b", "0", "0");
+
+        Assert.Contains("node-a", executeAsync.yaml);
+        Assert.Contains("node-b", executeAsync.yaml);
+    }
+
+    [Theory]
+    [InlineData("switches", "routers")]
+    [InlineData("firewalls", "routers")]
+    public async Task shrinking_port_count_removes_connections(string aType, string bType)
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync(aType, "add", "node-a");
+        await ExecuteAsync(bType, "add", "node-b");
+
+        await ExecuteAsync(aType, "port", "add", "node-a",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "3");
+
+        await ExecuteAsync(bType, "port", "add", "node-b",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "3");
+
+        await ExecuteAsync("connections", "add",
+            "node-a", "0", "2",
+            "node-b", "0", "0");
+
+        await ExecuteAsync(
+            aType, "port", "set", "node-a",
+            "--index", "0",
+            "--count", "1");
+
+        (string output, string yaml) executeAsync = await ExecuteAsync(
+            "connections", "add",
+            "node-a", "0", "0",
+            "node-b", "0", "1");
+
+        Assert.Contains("node-a", executeAsync.yaml);
+    }
+
+    [Theory]
+    [InlineData("switches", "routers")]
+    [InlineData("firewalls", "routers")]
+    public async Task shrinking_port_count_preserves_valid_connections(string aType, string bType)
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync(aType, "add", "node-a");
+        await ExecuteAsync(bType, "add", "node-b");
+
+        await ExecuteAsync(aType, "port", "add", "node-a",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "3");
+
+        await ExecuteAsync(bType, "port", "add", "node-b",
+            "--type", "RJ45",
+            "--speed", "1",
+            "--count", "3");
+
+        await ExecuteAsync("connections", "add",
+            "node-a", "0", "0",
+            "node-b", "0", "0");
+
+        await ExecuteAsync(
+            aType, "port", "set", "node-a",
+            "--index", "0",
+            "--count", "2");
+
+        (string output, string yaml) executeAsync = await ExecuteAsync(
+            "connections", "add",
+            "node-a", "0", "1",
+            "node-b", "0", "1");
+
+        Assert.Contains("node-a", executeAsync.yaml);
+    }
+}

+ 4 - 2
Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs

@@ -48,7 +48,8 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
                        managed: true
                        poe: false
                        name: fw01
-
+                     connections: []
+                     
                      """, yaml);
 
         // Add second firewall
@@ -76,7 +77,8 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
                        managed: false
                        poe: false
                        name: fw02
-
+                     connections: []
+                     
                      """, yaml);
 
         // Get firewall

+ 3 - 1
Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs

@@ -48,7 +48,8 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: true
                        poe: false
                        name: rt01
-
+                     connections: []
+                     
                      """, yaml);
 
         // Add second router
@@ -76,6 +77,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: false
                        poe: false
                        name: rt02
+                     connections: []
 
                      """, yaml);
 

+ 1 - 0
Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs

@@ -48,6 +48,7 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                          mts: 3200
                        ipmi: true
                        name: srv01
+                     connections: []
 
                      """, yaml);
 

+ 1 - 0
Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs

@@ -59,6 +59,7 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
                        name: svc01
                        runsOn:
                        - sys01
+                     connections: []
 
                      """, yaml);
 

+ 2 - 0
Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs

@@ -49,6 +49,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: true
                        poe: true
                        name: sw01
+                     connections: []
 
                      """, yaml);
 
@@ -78,6 +79,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: false
                        poe: false
                        name: sw02
+                     connections: []
 
                      """, yaml);
 

+ 2 - 0
Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs

@@ -58,6 +58,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        name: sys01
                        runsOn:
                        - proxmox-node01
+                     connections: []
 
                      """, yaml);
 
@@ -179,6 +180,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        runsOn:
                        - proxmox-node01
                        - sys01
+                     connections: []
 
                      """, yaml);
     }

+ 2 - 0
Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs

@@ -43,6 +43,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
                        model: APC-SmartUPS-1500
                        va: 1500
                        name: ups01
+                     connections: []
 
                      """, yaml);
 
@@ -68,6 +69,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
                        model: CyberPower-2200VA
                        va: 2200
                        name: ups02
+                     connections: []
 
                      """, yaml);