Parcourir la source

#200 physical connections (#247)

* Migrate nics -> ports in v3 schema

* Added connections

* Updated schema

* Ran format

* Ran format

* Ran Format

* Added PortsPom / E2E ui tests

* Updated workflows
Tim Jones il y a 3 semaines
Parent
commit
28e3eedacf
94 fichiers modifiés avec 5762 ajouts et 1147 suppressions
  1. 1 1
      .github/workflows/publish-docker-nightly.yaml
  2. 0 30
      .github/workflows/test-cli.yml
  3. 54 7
      .github/workflows/test.yml
  4. 9 0
      RackPeek.Domain/Persistence/IResourceCollection.cs
  5. 56 1
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  6. 89 3
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  7. 5 1
      RackPeek.Domain/Resources/AccessPoints/AccessPoint.cs
  8. 74 0
      RackPeek.Domain/Resources/Connections/AddConnectionUseCase.cs
  9. 17 0
      RackPeek.Domain/Resources/Connections/Connection.cs
  10. 11 0
      RackPeek.Domain/Resources/Connections/ConnectionHelpers.cs
  11. 19 0
      RackPeek.Domain/Resources/Connections/GetConnectionForPortUseCase.cs
  12. 19 0
      RackPeek.Domain/Resources/Connections/GetConnectionsForResourceUseCase.cs
  13. 19 0
      RackPeek.Domain/Resources/Connections/RemoveConnectionUseCase.cs
  14. 1 1
      RackPeek.Domain/Resources/Desktops/DescribeDesktopUseCase.cs
  15. 2 2
      RackPeek.Domain/Resources/Desktops/Desktop.cs
  16. 3 3
      RackPeek.Domain/Resources/Desktops/DesktopHardwareReport.cs
  17. 1 1
      RackPeek.Domain/Resources/Servers/DescribeServerUseCase.cs
  18. 0 7
      RackPeek.Domain/Resources/Servers/INicResource.cs
  19. 2 2
      RackPeek.Domain/Resources/Servers/Server.cs
  20. 6 6
      RackPeek.Domain/Resources/Servers/ServerHardwareReport.cs
  21. 1 1
      RackPeek.Domain/RpkConstants.cs
  22. 6 4
      RackPeek.Domain/ServiceCollectionExtensions.cs
  23. 0 46
      RackPeek.Domain/UseCases/Nics/AddNicUseCase.cs
  24. 0 29
      RackPeek.Domain/UseCases/Nics/RemoveNicUseCase.cs
  25. 0 50
      RackPeek.Domain/UseCases/Nics/UpdateNicUseCase.cs
  26. 72 4
      RackPeek.Domain/UseCases/Ports/RemovePortUseCase.cs
  27. 13 0
      RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs
  28. 1 1
      RackPeek.Domain/UseCases/SSH/SshConfigExportUseCase.cs
  29. 1 1
      RackPeek.Domain/UseCases/SSH/SshConfigGenerator.cs
  30. 1 1
      RackPeek.Domain/UseCases/SSH/SshExportOptions.cs
  31. 253 419
      RackPeek.Web.Viewer/wwwroot/config/config.yaml
  32. 667 0
      RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json
  33. 667 0
      RackPeek.Web/wwwroot/schemas/v3/schema.v3.json
  34. 1 1
      RackPeek/RackPeek.csproj
  35. 7 0
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  36. 11 0
      Shared.Rcl/CliBootstrap.cs
  37. 83 0
      Shared.Rcl/Commands/Connections/ConnectionAddCommand.cs
  38. 50 0
      Shared.Rcl/Commands/Connections/ConnectionRemoveCommand.cs
  39. 1 1
      Shared.Rcl/Commands/Desktops/DesktopGetCommand.cs
  40. 2 2
      Shared.Rcl/Commands/Desktops/Nics/DesktopNicAddCommand.cs
  41. 2 2
      Shared.Rcl/Commands/Desktops/Nics/DesktopNicRemoveCommand.cs
  42. 2 2
      Shared.Rcl/Commands/Desktops/Nics/DesktopNicSetCommand.cs
  43. 1 1
      Shared.Rcl/Commands/Exporters/GenerateSshConfigCommand.cs
  44. 2 2
      Shared.Rcl/Commands/Servers/Nics/ServerNicAddCommand.cs
  45. 2 2
      Shared.Rcl/Commands/Servers/Nics/ServerNicRemoveCommand.cs
  46. 2 2
      Shared.Rcl/Commands/Servers/Nics/ServerNicUpdateCommand.cs
  47. 1 1
      Shared.Rcl/Components/CrumbLevel.razor
  48. 2 2
      Shared.Rcl/Components/ResourceBreadCrumbComponent.razor
  49. 1 1
      Shared.Rcl/Components/SshExport.razor
  50. 30 0
      Shared.Rcl/Connections/ConnectionsPage.razor
  51. 409 0
      Shared.Rcl/Connections/PortConnectionModal.razor
  52. 119 0
      Shared.Rcl/Connections/PortGroupVisualizer.razor
  53. 180 0
      Shared.Rcl/Connections/PortLayout.razor
  54. 2 1
      Shared.Rcl/ConsoleRunner.cs
  55. 5 94
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  56. 5 97
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  57. 70 4
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  58. 2 0
      Shared.Rcl/Hardware/HardwareTreePage.razor
  59. 171 0
      Shared.Rcl/Hardware/PortGroupEditor.razor
  60. 5 97
      Shared.Rcl/Routers/RouterCardComponent.razor
  61. 9 94
      Shared.Rcl/Servers/ServerCardComponent.razor
  62. 5 98
      Shared.Rcl/Switches/SwitchCardComponent.razor
  63. 1 4
      Shared.Rcl/Systems/SystemsListPage.razor
  64. 98 0
      Tests.E2e/AccessPointCardTests.cs
  65. 2 1
      Tests.E2e/Infra/PlaywrightFixture.cs
  66. 35 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  67. 166 0
      Tests.E2e/PageObjectModels/PortsPom.cs
  68. 3 3
      Tests/Api/InventoryEndpointTests.cs
  69. 4 2
      Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs
  70. 128 0
      Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs
  71. 140 0
      Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs
  72. 168 0
      Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs
  73. 4 2
      Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs
  74. 4 2
      Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs
  75. 2 1
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  76. 2 1
      Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs
  77. 4 2
      Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs
  78. 4 2
      Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs
  79. 4 2
      Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs
  80. 34 0
      Tests/TestConfigs/v3/01-server.yaml
  81. 17 0
      Tests/TestConfigs/v3/02-firewall.yaml
  82. 17 0
      Tests/TestConfigs/v3/03-router.yaml
  83. 17 0
      Tests/TestConfigs/v3/04-switch.yaml
  84. 15 0
      Tests/TestConfigs/v3/05-accesspoint.yaml
  85. 11 0
      Tests/TestConfigs/v3/06-ups.yaml
  86. 25 0
      Tests/TestConfigs/v3/07-desktop.yaml
  87. 18 0
      Tests/TestConfigs/v3/08-laptop.yaml
  88. 12 0
      Tests/TestConfigs/v3/09-service.yaml
  89. 16 0
      Tests/TestConfigs/v3/10-system.yaml
  90. 522 0
      Tests/TestConfigs/v3/11-demo-config.yaml
  91. 33 0
      Tests/Tests.csproj
  92. 1 0
      Tests/Yaml/SchemaTests.cs
  93. 667 0
      Tests/schemas/schema.v3.json
  94. 336 0
      schemas/v3/schema.v3.json

+ 1 - 1
.github/workflows/publish-docker-nightly.yaml

@@ -2,7 +2,7 @@ name: Docker Nightly Publish (amd64)
 
 
 on:
 on:
   push:
   push:
-    branches: [ main ]
+    branches: [ staging ]
   workflow_dispatch:
   workflow_dispatch:
 
 
 permissions:
 permissions:

+ 0 - 30
.github/workflows/test-cli.yml

@@ -1,30 +0,0 @@
-name: CLI Tests
-
-on:
-  pull_request:
-  workflow_dispatch:
-
-jobs:
-  build:
-    runs-on: ubuntu-latest
-
-    steps:
-      - name: Checkout
-        uses: actions/checkout@v3
-
-      - name: Setup .NET
-        uses: actions/setup-dotnet@v3
-        with:
-          dotnet-version: 10.0.x
-
-      - name: Restore
-        run: dotnet restore
-
-      - name: Check Formatting
-        run: dotnet format --verify-no-changes
-
-      - name: Build
-        run: dotnet build --no-restore --configuration Release
-
-      - name: Test
-        run: dotnet test Tests --no-build --configuration Release --verbosity normal

+ 54 - 7
.github/workflows/test-webui.yml → .github/workflows/test.yml

@@ -1,12 +1,60 @@
-name: WebUi Tests
+name: Tests
 
 
 on:
 on:
   pull_request:
   pull_request:
   workflow_dispatch:
   workflow_dispatch:
 
 
 jobs:
 jobs:
-  build:
-    runs-on: ubuntu-24.04  # pin for consistency (22.04 is also fine)
+
+  format:
+    name: Format Check
+    runs-on: ubuntu-latest
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore
+
+      - name: Check Formatting
+        run: dotnet format --verify-no-changes
+
+
+  cli-tests:
+    name: CLI Tests
+    runs-on: ubuntu-latest
+    needs: format
+
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Setup .NET
+        uses: actions/setup-dotnet@v4
+        with:
+          dotnet-version: 10.0.x
+
+      - name: Restore
+        run: dotnet restore
+
+      - name: Build
+        run: dotnet build --no-restore --configuration Release
+
+      - name: Run CLI Tests
+        run: dotnet test Tests --no-build --configuration Release --verbosity normal
+
+
+  webui-tests:
+    name: WebUI / Playwright Tests
+    runs-on: ubuntu-24.04
+    needs: cli-tests
+
     steps:
     steps:
       - name: Checkout
       - name: Checkout
         uses: actions/checkout@v4
         uses: actions/checkout@v4
@@ -24,7 +72,7 @@ jobs:
           restore-keys: |
           restore-keys: |
             ${{ runner.os }}-nuget-
             ${{ runner.os }}-nuget-
 
 
-      - name: Cache Playwright browsers (Chromium)
+      - name: Cache Playwright browsers
         uses: actions/cache@v4
         uses: actions/cache@v4
         with:
         with:
           path: ~/.cache/ms-playwright
           path: ~/.cache/ms-playwright
@@ -38,8 +86,7 @@ jobs:
       - name: Build
       - name: Build
         run: dotnet build --no-restore --configuration Release
         run: dotnet build --no-restore --configuration Release
 
 
-      # Prefer the Playwright script that comes with the NuGet package (no global tool install)
-      - name: Install Playwright Browsers (Chromium only)
+      - name: Install Playwright Browsers
         shell: bash
         shell: bash
         run: |
         run: |
           pwsh Tests.E2e/bin/Release/net*/playwright.ps1 install --with-deps chromium
           pwsh Tests.E2e/bin/Release/net*/playwright.ps1 install --with-deps chromium
@@ -52,4 +99,4 @@ jobs:
             .
             .
 
 
       - name: Run E2E Tests
       - name: Run E2E Tests
-        run: dotnet test Tests.E2e --configuration Release --verbosity normal
+        run: dotnet test Tests.E2e --configuration Release --verbosity normal

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

@@ -1,4 +1,5 @@
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
@@ -35,4 +36,12 @@ public interface IResourceCollection {
     Task<IReadOnlyList<Resource>> GetDependantsAsync(string name);
     Task<IReadOnlyList<Resource>> GetDependantsAsync(string name);
 
 
     Task Merge(string incomingYaml, MergeMode mode);
     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);
 }
 }

+ 56 - 1
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -22,7 +22,8 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         ListOfMigrations = new List<Func<IServiceProvider, Dictionary<object, object>, ValueTask>>
         ListOfMigrations = new List<Func<IServiceProvider, Dictionary<object, object>, ValueTask>>
         {
         {
             EnsureSchemaVersionExists,
             EnsureSchemaVersionExists,
-            ConvertScalarRunsOnToList
+            ConvertScalarRunsOnToList,
+            ConvertNicsToPortsV3
         };
         };
 
 
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
@@ -110,5 +111,59 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         return ValueTask.CompletedTask;
         return ValueTask.CompletedTask;
     }
     }
 
 
+    public static ValueTask ConvertNicsToPortsV3(
+        IServiceProvider serviceProvider,
+        Dictionary<object, object> obj) {
+        if (!obj.TryGetValue("resources", out var resourcesObj))
+            return ValueTask.CompletedTask;
+
+        if (resourcesObj is not List<object> resources)
+            return ValueTask.CompletedTask;
+
+        foreach (var resourceObj in resources) {
+            if (resourceObj is not Dictionary<object, object> resourceDict)
+                continue;
+
+            if (!resourceDict.TryGetValue("nics", out var nicsObj))
+                continue;
+
+            if (nicsObj is not List<object> nics)
+                continue;
+
+            var ports = new List<Dictionary<object, object>>();
+
+            foreach (var nicObj in nics) {
+                if (nicObj is not Dictionary<object, object> nicDict)
+                    continue;
+
+                var port = new Dictionary<object, object>();
+
+                if (nicDict.TryGetValue("type", out var type))
+                    port["type"] = type;
+
+                if (nicDict.TryGetValue("speed", out var speed))
+                    port["speed"] = speed;
+
+                if (nicDict.TryGetValue("ports", out var portCount))
+                    port["count"] = portCount;
+
+                ports.Add(port);
+            }
+
+            resourceDict.Remove("nics");
+
+            if (resourceDict.TryGetValue("ports", out var existingPortsObj)
+                && existingPortsObj is List<object> existingPorts)
+                foreach (Dictionary<object, object> p in ports)
+                    existingPorts.Add(p);
+            else
+                resourceDict["ports"] = ports.Cast<object>().ToList();
+        }
+
+        obj["version"] = 3;
+
+        return ValueTask.CompletedTask;
+    }
+
     #endregion
     #endregion
 }
 }

+ 89 - 3
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -3,6 +3,7 @@ using System.Collections.Specialized;
 using System.Diagnostics;
 using System.Diagnostics;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Hardware;
@@ -21,6 +22,7 @@ namespace RackPeek.Domain.Persistence.Yaml;
 public class ResourceCollection {
 public class ResourceCollection {
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public List<Resource> Resources { get; } = new();
     public List<Resource> Resources { get; } = new();
+    public List<Connection> Connections { get; } = new();
 }
 }
 
 
 public sealed class YamlResourceCollection(
 public sealed class YamlResourceCollection(
@@ -135,7 +137,8 @@ public sealed class YamlResourceCollection(
 
 
             var rootToSave = new YamlRoot {
             var rootToSave = new YamlRoot {
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
-                Resources = resourceCollection.Resources
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
             };
             };
 
 
             await SaveRootAsync(rootToSave);
             await SaveRootAsync(rootToSave);
@@ -191,6 +194,11 @@ public sealed class YamlResourceCollection(
 
 
         if (root.Resources != null)
         if (root.Resources != null)
             resourceCollection.Resources.AddRange(root.Resources);
             resourceCollection.Resources.AddRange(root.Resources);
+
+        resourceCollection.Connections.Clear();
+
+        if (root.Connections != null)
+            resourceCollection.Connections.AddRange(root.Connections);
     }
     }
 
 
     public Task AddAsync(Resource resource) {
     public Task AddAsync(Resource resource) {
@@ -218,6 +226,55 @@ public sealed class YamlResourceCollection(
             list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
             list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
     }
     }
 
 
+    public Task AddConnectionAsync(Connection connection) => 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 string? ResolveSystemIp(
     private string? ResolveSystemIp(
         SystemResource system,
         SystemResource system,
         Dictionary<string, SystemResource> systemsByName,
         Dictionary<string, SystemResource> systemsByName,
@@ -279,7 +336,8 @@ public sealed class YamlResourceCollection(
             // Always write current schema version when app writes the file.
             // Always write current schema version when app writes the file.
             var root = new YamlRoot {
             var root = new YamlRoot {
                 Version = _currentSchemaVersion,
                 Version = _currentSchemaVersion,
-                Resources = resourceCollection.Resources
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
             };
             };
 
 
             await SaveRootAsync(root);
             await SaveRootAsync(root);
@@ -312,9 +370,11 @@ public sealed class YamlResourceCollection(
 
 
         // Preserve ordering: version first, then resources
         // Preserve ordering: version first, then resources
         Debug.Assert(root != null, nameof(root) + " != null");
         Debug.Assert(root != null, nameof(root) + " != null");
+
         var payload = new OrderedDictionary {
         var payload = new OrderedDictionary {
             ["version"] = root.Version,
             ["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));
         await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
@@ -362,9 +422,35 @@ public sealed class YamlResourceCollection(
 
 
         return map;
         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;
+    }
+
+    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 class YamlRoot {
     public int Version { get; set; }
     public int Version { get; set; }
     public List<Resource>? Resources { get; set; }
     public List<Resource>? Resources { get; set; }
+
+    public List<Connection>? Connections { get; set; }
 }
 }

+ 5 - 1
RackPeek.Domain/Resources/AccessPoints/AccessPoint.cs

@@ -1,7 +1,11 @@
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.SubResources;
+
 namespace RackPeek.Domain.Resources.AccessPoints;
 namespace RackPeek.Domain.Resources.AccessPoints;
 
 
-public class AccessPoint : Hardware.Hardware {
+public class AccessPoint : Hardware.Hardware, IPortResource {
     public const string KindLabel = "AccessPoint";
     public const string KindLabel = "AccessPoint";
     public string? Model { get; set; }
     public string? Model { get; set; }
     public double? Speed { get; set; }
     public double? Speed { get; set; }
+    public List<Port>? Ports { get; set; }
 }
 }

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

@@ -0,0 +1,74 @@
+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;
+    }
+}

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

@@ -0,0 +1,17 @@
+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; }
+}

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

@@ -0,0 +1,11 @@
+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);
+}

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

@@ -0,0 +1,19 @@
+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);
+    }
+}

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

@@ -0,0 +1,19 @@
+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);
+    }
+}

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

@@ -0,0 +1,19 @@
+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/Resources/Desktops/DescribeDesktopUseCase.cs

@@ -33,7 +33,7 @@ public class DescribeDesktopUseCase(IResourceCollection repository) : IUseCase {
             desktop.Cpus?.Count ?? 0,
             desktop.Cpus?.Count ?? 0,
             ramSummary,
             ramSummary,
             desktop.Drives?.Count ?? 0,
             desktop.Drives?.Count ?? 0,
-            desktop.Nics?.Count ?? 0,
+            desktop.Ports?.Count ?? 0,
             desktop.Gpus?.Count ?? 0,
             desktop.Gpus?.Count ?? 0,
             desktop.Labels
             desktop.Labels
         );
         );

+ 2 - 2
RackPeek.Domain/Resources/Desktops/Desktop.cs

@@ -3,12 +3,12 @@ using RackPeek.Domain.Resources.SubResources;
 
 
 namespace RackPeek.Domain.Resources.Desktops;
 namespace RackPeek.Domain.Resources.Desktops;
 
 
-public class Desktop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, INicResource {
+public class Desktop : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, IPortResource {
     public const string KindLabel = "Desktop";
     public const string KindLabel = "Desktop";
     public Ram? Ram { get; set; }
     public Ram? Ram { get; set; }
     public string? Model { get; set; }
     public string? Model { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
     public List<Gpu>? Gpus { get; set; }
-    public List<Nic>? Nics { get; set; }
+    public List<Port>? Ports { get; set; }
 }
 }

+ 3 - 3
RackPeek.Domain/Resources/Desktops/DesktopHardwareReport.cs

@@ -44,14 +44,14 @@ public class DesktopHardwareReportUseCase(IResourceCollection repository) : IUse
                 .Where(d => d.Type == "hdd")
                 .Where(d => d.Type == "hdd")
                 .Sum(d => d.Size) ?? 0;
                 .Sum(d => d.Size) ?? 0;
 
 
-            var nicSummary = desktop.Nics == null
+            var nicSummary = desktop.Ports == null
                 ? "Unknown"
                 ? "Unknown"
                 : string.Join(", ",
                 : string.Join(", ",
-                    desktop.Nics
+                    desktop.Ports
                         .GroupBy(n => n.Speed ?? 0)
                         .GroupBy(n => n.Speed ?? 0)
                         .OrderBy(g => g.Key)
                         .OrderBy(g => g.Key)
                         .Select(g => {
                         .Select(g => {
-                            var count = g.Sum(n => n.Ports ?? 0);
+                            var count = g.Sum(n => n.Count ?? 0);
                             return $"{count}×{g.Key}G";
                             return $"{count}×{g.Key}G";
                         }));
                         }));
 
 

+ 1 - 1
RackPeek.Domain/Resources/Servers/DescribeServerUseCase.cs

@@ -37,7 +37,7 @@ public class DescribeServerUseCase(IResourceCollection repository) : IUseCase {
             server.Cpus?.Sum(c => c.Threads) ?? 0,
             server.Cpus?.Sum(c => c.Threads) ?? 0,
             server.Ram?.Size ?? 0,
             server.Ram?.Size ?? 0,
             server.Drives?.Sum(d => d.Size) ?? 0,
             server.Drives?.Sum(d => d.Size) ?? 0,
-            server.Nics?.Sum(n => n.Ports) ?? 0,
+            server.Ports?.Sum(n => n.Count) ?? 0,
             server.Ipmi ?? false
             server.Ipmi ?? false
         );
         );
     }
     }

+ 0 - 7
RackPeek.Domain/Resources/Servers/INicResource.cs

@@ -1,7 +0,0 @@
-using RackPeek.Domain.Resources.SubResources;
-
-namespace RackPeek.Domain.Resources.Servers;
-
-public interface INicResource {
-    public List<Nic>? Nics { get; set; }
-}

+ 2 - 2
RackPeek.Domain/Resources/Servers/Server.cs

@@ -2,12 +2,12 @@ using RackPeek.Domain.Resources.SubResources;
 
 
 namespace RackPeek.Domain.Resources.Servers;
 namespace RackPeek.Domain.Resources.Servers;
 
 
-public class Server : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, INicResource {
+public class Server : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, IPortResource {
     public const string KindLabel = "Server";
     public const string KindLabel = "Server";
     public Ram? Ram { get; set; }
     public Ram? Ram { get; set; }
     public bool? Ipmi { get; set; }
     public bool? Ipmi { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
     public List<Gpu>? Gpus { get; set; }
-    public List<Nic>? Nics { get; set; }
+    public List<Port>? Ports { get; set; }
 }
 }

+ 6 - 6
RackPeek.Domain/Resources/Servers/ServerHardwareReport.cs

@@ -22,13 +22,13 @@ public record ServerHardwareRow(
     int TotalGpuVramGb,
     int TotalGpuVramGb,
     string GpuSummary,
     string GpuSummary,
     bool Ipmi,
     bool Ipmi,
-    IReadOnlyList<Nic> Nics
+    IReadOnlyList<Port> Ports
 ) {
 ) {
     public string NicSummary =>
     public string NicSummary =>
         string.Join(", ",
         string.Join(", ",
-            (Nics ?? [])
+            (Ports ?? [])
             .SelectMany(n => {
             .SelectMany(n => {
-                var ports = n.Ports ?? 1;
+                var ports = n.Count ?? 1;
                 var speed = n.Speed ?? 0;
                 var speed = n.Speed ?? 0;
                 return Enumerable.Repeat(speed, ports);
                 return Enumerable.Repeat(speed, ports);
             })
             })
@@ -64,8 +64,8 @@ public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseC
                 .Where(d => d.Type == "hdd")
                 .Where(d => d.Type == "hdd")
                 .Sum(d => d.Size) ?? 0;
                 .Sum(d => d.Size) ?? 0;
 
 
-            var totalNicPorts = server.Nics?.Sum(n => n.Ports) ?? 0;
-            var maxNicSpeed = server.Nics?.Max(n => n.Speed) ?? 0;
+            var totalNicPorts = server.Ports?.Sum(n => n.Count) ?? 0;
+            var maxNicSpeed = server.Ports?.Max(n => n.Speed) ?? 0;
 
 
             var gpuCount = server.Gpus?.Count ?? 0;
             var gpuCount = server.Gpus?.Count ?? 0;
 
 
@@ -95,7 +95,7 @@ public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseC
                 totalGpuVram,
                 totalGpuVram,
                 gpuSummary,
                 gpuSummary,
                 server.Ipmi ?? false,
                 server.Ipmi ?? false,
-                server.Nics ?? new List<Nic>()
+                server.Ports ?? new List<Port>()
             );
             );
         }).ToList();
         }).ToList();
 
 

+ 1 - 1
RackPeek.Domain/RpkConstants.cs

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

+ 6 - 4
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -2,6 +2,7 @@ using System.Reflection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
@@ -10,7 +11,6 @@ using RackPeek.Domain.UseCases.Cpus;
 using RackPeek.Domain.UseCases.Drives;
 using RackPeek.Domain.UseCases.Drives;
 using RackPeek.Domain.UseCases.Gpus;
 using RackPeek.Domain.UseCases.Gpus;
 using RackPeek.Domain.UseCases.Labels;
 using RackPeek.Domain.UseCases.Labels;
-using RackPeek.Domain.UseCases.Nics;
 using RackPeek.Domain.UseCases.Ports;
 using RackPeek.Domain.UseCases.Ports;
 using RackPeek.Domain.UseCases.Tags;
 using RackPeek.Domain.UseCases.Tags;
 
 
@@ -71,9 +71,11 @@ public static class ServiceCollectionExtensions {
         services.AddScoped(typeof(IRemovePortUseCase<>), typeof(RemovePortUseCase<>));
         services.AddScoped(typeof(IRemovePortUseCase<>), typeof(RemovePortUseCase<>));
         services.AddScoped(typeof(IUpdatePortUseCase<>), typeof(UpdatePortUseCase<>));
         services.AddScoped(typeof(IUpdatePortUseCase<>), typeof(UpdatePortUseCase<>));
 
 
-        services.AddScoped(typeof(IAddNicUseCase<>), typeof(AddNicUseCase<>));
-        services.AddScoped(typeof(IRemoveNicUseCase<>), typeof(RemoveNicUseCase<>));
-        services.AddScoped(typeof(IUpdateNicUseCase<>), typeof(UpdateNicUseCase<>));
+        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))
         IEnumerable<Type>? usecases = Assembly.GetAssembly(typeof(IUseCase))
             ?.GetTypes()
             ?.GetTypes()

+ 0 - 46
RackPeek.Domain/UseCases/Nics/AddNicUseCase.cs

@@ -1,46 +0,0 @@
-using RackPeek.Domain.Helpers;
-using RackPeek.Domain.Persistence;
-using RackPeek.Domain.Resources;
-using RackPeek.Domain.Resources.Servers;
-using RackPeek.Domain.Resources.SubResources;
-
-namespace RackPeek.Domain.UseCases.Nics;
-
-public interface IAddNicUseCase<T> : IResourceUseCase<T>
-    where T : Resource {
-    public Task ExecuteAsync(
-        string name,
-        string? type,
-        double? speed,
-        int? ports);
-}
-
-public class AddNicUseCase<T>(IResourceCollection repository) : IAddNicUseCase<T> where T : Resource {
-    public async Task ExecuteAsync(
-        string name,
-        string? type,
-        double? speed,
-        int? ports) {
-        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
-        // ToDo validate / normalize all inputs
-
-        name = Normalize.HardwareName(name);
-        ThrowIfInvalid.ResourceName(name);
-
-        var nicType = Normalize.NicType(type ?? string.Empty);
-        ThrowIfInvalid.NicType(nicType);
-
-        T resource = await repository.GetByNameAsync<T>(name) ??
-                     throw new NotFoundException($"Resource '{name}' not found.");
-
-        if (resource is not INicResource nr) throw new NotFoundException($"Resource '{name}' not found.");
-
-        nr.Nics ??= new List<Nic>();
-        nr.Nics.Add(new Nic {
-            Type = nicType,
-            Speed = speed,
-            Ports = ports
-        });
-        await repository.UpdateAsync(resource);
-    }
-}

+ 0 - 29
RackPeek.Domain/UseCases/Nics/RemoveNicUseCase.cs

@@ -1,29 +0,0 @@
-using RackPeek.Domain.Helpers;
-using RackPeek.Domain.Persistence;
-using RackPeek.Domain.Resources;
-using RackPeek.Domain.Resources.Servers;
-
-namespace RackPeek.Domain.UseCases.Nics;
-
-public interface IRemoveNicUseCase<T> : IResourceUseCase<T>
-    where T : Resource {
-    public Task ExecuteAsync(string name, int index);
-}
-
-public class RemoveNicUseCase<T>(IResourceCollection repository) : IRemoveNicUseCase<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 INicResource nr) throw new NotFoundException($"Resource '{name}' not found.");
-
-        if (nr.Nics == null || index < 0 || index >= nr.Nics.Count)
-            throw new NotFoundException($"NIC index {index} not found on desktop '{name}'.");
-
-        nr.Nics.RemoveAt(index);
-
-        await repository.UpdateAsync(resource);
-    }
-}

+ 0 - 50
RackPeek.Domain/UseCases/Nics/UpdateNicUseCase.cs

@@ -1,50 +0,0 @@
-using RackPeek.Domain.Helpers;
-using RackPeek.Domain.Persistence;
-using RackPeek.Domain.Resources;
-using RackPeek.Domain.Resources.Servers;
-using RackPeek.Domain.Resources.SubResources;
-
-namespace RackPeek.Domain.UseCases.Nics;
-
-public interface IUpdateNicUseCase<T> : IResourceUseCase<T>
-    where T : Resource {
-    public Task ExecuteAsync(
-        string name,
-        int index,
-        string? type,
-        double? speed,
-        int? ports);
-}
-
-public class UpdateNicUseCase<T>(IResourceCollection repository) : IUpdateNicUseCase<T> where T : Resource {
-    public async Task ExecuteAsync(
-        string name,
-        int index,
-        string? type,
-        double? speed,
-        int? ports) {
-        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
-        // ToDo validate / normalize all inputs
-
-        name = Normalize.HardwareName(name);
-        ThrowIfInvalid.ResourceName(name);
-
-        var nicType = Normalize.NicType(type ?? string.Empty);
-        ThrowIfInvalid.NicType(nicType);
-
-        T resource = await repository.GetByNameAsync<T>(name) ??
-                     throw new NotFoundException($"Resource '{name}' not found.");
-
-        if (resource is not INicResource nr) throw new NotFoundException($"Resource '{name}' not found.");
-
-        if (nr.Nics == null || index < 0 || index >= nr.Nics.Count)
-            throw new NotFoundException($"NIC index {index} not found on desktop '{name}'.");
-
-        Nic nic = nr.Nics[index];
-        nic.Type = nicType;
-        nic.Speed = speed;
-        nic.Ports = ports;
-
-        await repository.UpdateAsync(resource);
-    }
-}

+ 72 - 4
RackPeek.Domain/UseCases/Ports/RemovePortUseCase.cs

@@ -1,16 +1,18 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
 
 
 namespace RackPeek.Domain.UseCases.Ports;
 namespace RackPeek.Domain.UseCases.Ports;
 
 
 public interface IRemovePortUseCase<T> : IResourceUseCase<T>
 public interface IRemovePortUseCase<T> : IResourceUseCase<T>
     where T : Resource {
     where T : Resource {
-    public Task ExecuteAsync(string name, int index);
+    Task ExecuteAsync(string name, int index);
 }
 }
 
 
-public class RemovePortUseCase<T>(IResourceCollection repository) : IRemovePortUseCase<T> where T : Resource {
+public class RemovePortUseCase<T>(IResourceCollection repository)
+    : IRemovePortUseCase<T> where T : Resource {
     public async Task ExecuteAsync(string name, int index) {
     public async Task ExecuteAsync(string name, int index) {
         name = Normalize.HardwareName(name);
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
         ThrowIfInvalid.ResourceName(name);
@@ -18,12 +20,78 @@ public class RemovePortUseCase<T>(IResourceCollection repository) : IRemovePortU
         T resource = await repository.GetByNameAsync<T>(name)
         T resource = await repository.GetByNameAsync<T>(name)
                      ?? throw new NotFoundException($"Resource '{name}' not found.");
                      ?? 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)
         if (pr.Ports == null || index < 0 || index >= pr.Ports.Count)
             throw new NotFoundException($"Port index {index} not found on '{name}'.");
             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);
         pr.Ports.RemoveAt(index);
 
 
         await repository.UpdateAsync(resource);
         await repository.UpdateAsync(resource);

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

@@ -1,6 +1,7 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.SubResources;
 using RackPeek.Domain.Resources.SubResources;
 
 
@@ -41,6 +42,18 @@ public class UpdatePortUseCase<T>(IResourceCollection repository) : IUpdatePortU
             throw new NotFoundException($"Port index {index} not found on '{name}'.");
             throw new NotFoundException($"Port index {index} not found on '{name}'.");
 
 
         Port nic = pr.Ports[index];
         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.Type = nicType;
         nic.Speed = speed;
         nic.Speed = speed;
         nic.Count = ports;
         nic.Count = ports;

+ 1 - 1
RackPeek.Domain/UseCases/SSH/SshConfigExportUseCase.cs

@@ -1,7 +1,7 @@
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
 
 
-namespace RackPeek.Domain.UseCases.Ssh;
+namespace RackPeek.Domain.UseCases.SSH;
 
 
 public class SshConfigExportUseCase(IResourceCollection repository) : IUseCase {
 public class SshConfigExportUseCase(IResourceCollection repository) : IUseCase {
     public async Task<SshExportResult?> ExecuteAsync(SshExportOptions options) {
     public async Task<SshExportResult?> ExecuteAsync(SshExportOptions options) {

+ 1 - 1
RackPeek.Domain/UseCases/SSH/SshConfigGenerator.cs

@@ -1,7 +1,7 @@
 using System.Text;
 using System.Text;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
 
 
-namespace RackPeek.Domain.UseCases.Ssh;
+namespace RackPeek.Domain.UseCases.SSH;
 
 
 public static class SshConfigGenerator {
 public static class SshConfigGenerator {
     public static SshExportResult ToSshConfig(
     public static SshExportResult ToSshConfig(

+ 1 - 1
RackPeek.Domain/UseCases/SSH/SshExportOptions.cs

@@ -1,4 +1,4 @@
-namespace RackPeek.Domain.UseCases.Ssh;
+namespace RackPeek.Domain.UseCases.SSH;
 
 
 public sealed record SshExportOptions {
 public sealed record SshExportOptions {
     /// <summary>
     /// <summary>

+ 253 - 419
RackPeek.Web.Viewer/wwwroot/config/config.yaml

@@ -1,513 +1,347 @@
+version: 3
 resources:
 resources:
-  # ------------------------
-  # Servers
-  # ------------------------
   - kind: Server
   - kind: Server
-    name: proxmox-node01
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
     cpus:
     cpus:
       - model: AMD EPYC 7302P
       - model: AMD EPYC 7302P
         cores: 16
         cores: 16
         threads: 32
         threads: 32
-    ram:
-      size: 128gb
-      mts: 3200
     drives:
     drives:
       - type: ssd
       - type: ssd
-        size: 1tb
+        size: 1024
       - type: ssd
       - type: ssd
-        size: 1tb
-    nics:
+        size: 1024
+    ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
-        ports: 2
+        speed: 1
+        count: 2
       - type: sfp+
       - type: sfp+
-        speed: 10gb
-        ports: 2
-    ipmi: true
-
+        speed: 10
+        count: 2
+    name: proxmox-node01
+    tags:
+      - prod
+      - infra
+    labels:
+      install-date: 2023-02-11
+      rack-unit: U10
+    notes: |-
+      # Proxmox main node
+      - 400w idle
+      - 1200w load
   - kind: Server
   - kind: Server
-    name: proxmox-node02
+    ram:
+      size: 96
+      mts: 2666
+    ipmi: true
     cpus:
     cpus:
       - model: Intel Xeon Silver 4210
       - model: Intel Xeon Silver 4210
         cores: 10
         cores: 10
         threads: 20
         threads: 20
-    ram:
-      size: 96gb
-      mts: 2666
     drives:
     drives:
       - type: ssd
       - type: ssd
-        size: 1tb
+        size: 1024
       - type: hdd
       - type: hdd
-        size: 4tb
-    nics:
+        size: 4096
+    ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
-        ports: 2
+        speed: 1
+        count: 2
       - type: sfp+
       - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
+        speed: 10
+        count: 1
+    name: proxmox-node02
+    tags:
+      - prod
+      - infra
+    labels:
+      install-date: 2023-02-12
+      rack-unit: U11
   - kind: Server
   - kind: Server
-    name: truenas-storage
+    ram:
+      size: 64
+      mts: 2666
+    ipmi: true
     cpus:
     cpus:
       - model: Intel Xeon E-2236
       - model: Intel Xeon E-2236
         cores: 6
         cores: 6
         threads: 12
         threads: 12
-    ram:
-      size: 64gb
-      mts: 2666
     drives:
     drives:
       - type: hdd
       - type: hdd
-        size: 8tb
+        size: 8192
       - type: hdd
       - type: hdd
-        size: 8tb
+        size: 8192
       - type: hdd
       - type: hdd
-        size: 8tb
+        size: 8192
       - type: hdd
       - type: hdd
-        size: 8tb
-    nics:
+        size: 8192
+    ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
-        ports: 1
+        speed: 1
+        count: 1
       - type: sfp+
       - type: sfp+
-        speed: 10gb
-        ports: 1
-    ipmi: true
-
-  # ------------------------
-  # Network
-  # ------------------------
+        speed: 10
+        count: 1
+    name: truenas-storage
+    tags:
+      - prod
+    labels:
+      install-date: 2022-08-20
+      rack-unit: U12
   - kind: Firewall
   - kind: Firewall
-    name: pfsense-fw
     model: Netgate-6100
     model: Netgate-6100
+    managed: true
+    poe: false
     ports:
     ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 4
         count: 4
       - type: sfp+
       - type: sfp+
-        speed: 10gb
+        speed: 10
         count: 2
         count: 2
-    managed: true
-    poe: false
-
+    name: pfsense-fw
+    tags:
+      - infra
+    labels:
+      install-date: 2022-03-10
   - kind: Router
   - kind: Router
-    name: core-router
     model: Ubiquiti-ER-4
     model: Ubiquiti-ER-4
+    managed: true
+    poe: false
     ports:
     ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 4
         count: 4
       - type: sfp
       - type: sfp
-        speed: 10gb
+        speed: 10
         count: 1
         count: 1
-    managed: true
-    poe: false
-
+    name: core-router
+    tags:
+      - infra
+    labels:
+      install-date: 2022-03-01
   - kind: Switch
   - kind: Switch
-    name: core-switch
     model: UniFi-USW-Enterprise-24
     model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
     ports:
     ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 12
         count: 12
       - type: rj45
       - type: rj45
-        speed: 2.5gb
+        speed: 2.5
         count: 8
         count: 8
       - type: sfp+
       - type: sfp+
-        speed: 10gb
+        speed: 10
         count: 4
         count: 4
-    managed: true
-    poe: true
-
+    name: core-switch
+    tags:
+      - infra
+    labels:
+      rack-unit: U15
   - kind: Switch
   - kind: Switch
-    name: access-switch
     model: UniFi-USW-16-PoE
     model: UniFi-USW-16-PoE
+    managed: true
+    poe: true
     ports:
     ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 16
         count: 16
       - type: sfp
       - type: sfp
-        speed: 1gb
+        speed: 1
         count: 2
         count: 2
-    managed: true
-    poe: true
-
+    name: access-switch
+    tags:
+      - infra
+    labels:
+      rack-unit: U16
   - kind: AccessPoint
   - kind: AccessPoint
-    name: lounge-ap
     model: UniFi-U6-Pro
     model: UniFi-U6-Pro
-    speed: 2.5gb
-
-  # ------------------------
-  # Power
-  # ------------------------
-  - kind: Ups
-    name: rack-ups
-    model: APC-SmartUPS-2200
-    va: 2200
-
-  # ------------------------
-  # Desktops
-  # ------------------------
-  - kind: Desktop
-    name: workstation-linux
-    cpus:
-      - model: AMD Ryzen 9 5900X
-        cores: 12
-        threads: 24
-    ram:
-      size: 64gb
-      mts: 3600
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: ssd
-        size: 2tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: NVIDIA RTX 3080
-        vram: 10gb
-
-  - kind: Desktop
-    name: gaming-pc
-    cpus:
-      - model: Intel Core i7-12700K
-        cores: 12
-        threads: 20
-    ram:
-      size: 32gb
-      mts: 3200
-    drives:
-      - type: ssd
-        size: 1tb
-    nics:
+    speed: 2.5
+    ports:
       - type: rj45
       - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: NVIDIA RTX 3070
-        vram: 8gb
-
-  # ------------------------
-  # Laptop
-  # ------------------------
-  - kind: Laptop
-    name: dev-laptop
-    cpus:
-      - model: Intel Core i7-1260P
-        cores: 12
-        threads: 16
-    ram:
-      size: 32gb
-      mts: 5200
-    drives:
-      - type: ssd
-        size: 1tb
-  # --------------------------------------------------
-  # Smart Home
-  # --------------------------------------------------
+        speed: 2.5
+        count: 1
+    name: lounge-ap
+    labels:
+      install-date: 2023-06-05
+      service-at: lounge
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 16
+    ram: 128
+    ip: 10.0.20.10
+    name: proxmox-cluster-node01
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 10
+    ram: 96
+    ip: 10.0.20.11
+    name: proxmox-cluster-node02
+    runsOn:
+      - proxmox-node02
+  - kind: System
+    type: VM
+    os: hassos
+    cores: 2
+    ram: 4
+    ip: 192.168.0.10
+    name: vm-home-assistant
+    runsOn:
+      - proxmox-cluster-node01
+  - kind: System
+    type: VM
+    os: ubuntu-22.04
+    cores: 4
+    ram: 8
+    ip: 192.168.0.20
+    name: vm-media-server
+    runsOn:
+      - proxmox-cluster-node02
+  - kind: System
+    type: VM
+    os: debian-12
+    cores: 2
+    ram: 4
+    ip: 192.168.0.40
+    name: vm-monitoring
+    runsOn:
+      - proxmox-cluster-node01
+  - kind: System
+    type: container
+    os: alpine
+    cores: 1
+    ram: 1
+    ip: 192.168.0.41
+    name: ctr-grafana
+    runsOn:
+      - vm-monitoring
   - kind: Service
   - kind: Service
-    name: home-assistant
     network:
     network:
-      ip: 192.168.0.10
       port: 8123
       port: 8123
       protocol: TCP
       protocol: TCP
       url: http://homeassistant.lan:8123
       url: http://homeassistant.lan:8123
-    runsOn: vm-home-assistant
-
-  # --------------------------------------------------
-  # Media & Photos
-  # --------------------------------------------------
+    name: home-assistant
+    labels:
+      service-at: home
+    runsOn:
+      - vm-home-assistant
   - kind: Service
   - kind: Service
-    name: plex
     network:
     network:
-      ip: 192.168.0.20
       port: 32400
       port: 32400
       protocol: TCP
       protocol: TCP
       url: http://plex.lan:32400
       url: http://plex.lan:32400
-    runsOn: vm-media-server
-
+    name: plex
+    tags:
+      - media
+    runsOn:
+      - vm-media-server
   - kind: Service
   - kind: Service
-    name: jellyfin
     network:
     network:
-      ip: 192.168.0.21
       port: 8096
       port: 8096
       protocol: TCP
       protocol: TCP
       url: http://jellyfin.lan:8096
       url: http://jellyfin.lan:8096
-    runsOn: vm-media-server
-
+    name: jellyfin
+    tags:
+      - media
+    runsOn:
+      - vm-media-server
   - kind: Service
   - kind: Service
-    name: immich
     network:
     network:
-      ip: 192.168.0.22
       port: 8080
       port: 8080
       protocol: TCP
       protocol: TCP
       url: http://immich.lan:8080
       url: http://immich.lan:8080
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Storage & Backup
-  # --------------------------------------------------
-  - kind: Service
-    name: truenas-webui
-    network:
-      ip: 192.168.0.30
-      port: 443
-      protocol: TCP
-      url: https://truenas.lan
-    runsOn: truenas-core-os
-
-  - kind: Service
-    name: minio
-    network:
-      ip: 192.168.0.31
-      port: 9000
-      protocol: TCP
-      url: http://minio.lan:9000
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Monitoring & Ops
-  # --------------------------------------------------
-  - kind: Service
-    name: prometheus
-    network:
-      ip: 192.168.0.40
-      port: 9090
-      protocol: TCP
-      url: http://prometheus.lan:9090
-    runsOn: vm-monitoring
-
+    name: immich
+    tags:
+      - media
+    runsOn:
+      - vm-media-server
   - kind: Service
   - kind: Service
-    name: grafana
     network:
     network:
-      ip: 192.168.0.41
       port: 3000
       port: 3000
       protocol: TCP
       protocol: TCP
       url: http://grafana.lan:3000
       url: http://grafana.lan:3000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: alertmanager
-    network:
-      ip: 192.168.0.42
-      port: 9093
-      protocol: TCP
-      url: http://alertmanager.lan:9093
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Dev & Internal Tools
-  # --------------------------------------------------
-  - kind: Service
-    name: gitea
-    network:
-      ip: 192.168.0.50
-      port: 3001
-      protocol: TCP
-      url: http://git.lan:3001
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: docker-registry
-    network:
-      ip: 192.168.0.51
-      port: 5000
-      protocol: TCP
-      url: http://registry.lan:5000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: portainer
-    network:
-      ip: 192.168.0.52
-      port: 9000
-      protocol: TCP
-      url: http://portainer.lan:9000
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Network Services
-  # --------------------------------------------------
-  - kind: Service
-    name: pihole
-    network:
-      ip: 192.168.0.53
-      port: 80
-      protocol: TCP
-      url: http://pihole.lan
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: firewall-webui
-    network:
-      ip: 192.168.0.1
-      port: 443
-      protocol: TCP
-      url: https://firewall.lan
-    runsOn: firewall-os
-
+    name: grafana
+    tags:
+      - containers
+    runsOn:
+      - ctr-grafana
   - kind: Service
   - kind: Service
-    name: router-webui
     network:
     network:
-      ip: 192.168.0.254
-      port: 443
+      port: 9090
       protocol: TCP
       protocol: TCP
-      url: https://router.lan
-    runsOn: router-os
-  # --------------------------------------------------
-  # Hypervisors (Bare Metal)
-  # --------------------------------------------------
-  - kind: System
-    type: Hypervisor
-    name: proxmox-cluster-node01
-    os: proxmox
-    cores: 16
-    ram: 128gb
-    drives:
-      - size: 1tb
-      - size: 1tb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: Hypervisor
-    name: proxmox-cluster-node02
-    os: proxmox
-    cores: 10
-    ram: 96gb
-    drives:
-      - size: 1tb
-      - size: 4tb
-    runsOn: proxmox-node02
-
-  # --------------------------------------------------
-  # Storage OS (Bare Metal)
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: truenas-core-os
-    os: truenas
-    cores: 6
-    ram: 64gb
-    drives:
-      - size: 8tb
-      - size: 8tb
-      - size: 8tb
-      - size: 8tb
-    runsOn: truenas-storage
-
-  # --------------------------------------------------
-  # IPMI / BMC Management
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: ipmi-proxmox-node01
-    os: idrac
-    cores: 1
-    ram: 1gb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: Baremetal
-    name: ipmi-proxmox-node02
-    os: ipmi
-    cores: 1
-    ram: 1gb
-    runsOn: proxmox-node02
-
-  - kind: System
-    type: Baremetal
-    name: ipmi-truenas-storage
-    os: ipmi
-    cores: 1
-    ram: 1gb
-    runsOn: truenas-storage
-
-  # --------------------------------------------------
-  # Core Network Systems
-  # --------------------------------------------------
-  - kind: System
-    type: Baremetal
-    name: firewall-os
-    os: pfsense
-    cores: 4
-    ram: 8gb
-    drives:
-      - size: 32gb
-    runsOn: pfsense-fw
-
-  - kind: System
-    type: Baremetal
-    name: router-os
-    os: edgeos
-    cores: 4
-    ram: 4gb
-    drives:
-      - size: 4gb
-    runsOn: core-router
-
-  - kind: System
-    type: Baremetal
-    name: unifi-core-switch-os
-    os: unifi-os
-    cores: 2
-    ram: 2gb
-    drives:
-      - size: 8gb
-    runsOn: core-switch
-
-  - kind: System
-    type: Baremetal
-    name: unifi-access-switch-os
-    os: unifi-os
-    cores: 2
-    ram: 2gb
-    drives:
-      - size: 8gb
-    runsOn: access-switch
-
-  - kind: System
-    type: Baremetal
-    name: unifi-lounge-ap-os
-    os: unifi-firmware
-    cores: 2
-    ram: 1gb
-    drives:
-      - size: 4gb
-    runsOn: lounge-ap
-
-  # --------------------------------------------------
-  # Virtual Machines
-  # --------------------------------------------------
-  - kind: System
-    type: VM
-    name: vm-home-assistant
-    os: hassos
-    cores: 2
-    ram: 4gb
-    drives:
-      - size: 64gb
-    runsOn: proxmox-node01
-
-  - kind: System
-    type: VM
-    name: vm-media-server
-    os: ubuntu-22.04
-    cores: 4
-    ram: 8gb
-    drives:
-      - size: 500gb
-    runsOn: proxmox-node02
-
-  - kind: System
-    type: VM
-    name: vm-monitoring
-    os: debian-12
-    cores: 2
-    ram: 4gb
-    drives:
-      - size: 64gb
-    runsOn: proxmox-node01
+      url: http://prometheus.lan:9090
+    name: prometheus
+    runsOn:
+      - vm-monitoring
+connections:
+  - a:
+      resource: core-router
+      portGroup: 0
+      portIndex: 0
+    b:
+      resource: pfsense-fw
+      portGroup: 0
+      portIndex: 0
+    label: wan-link
+  - a:
+      resource: pfsense-fw
+      portGroup: 1
+      portIndex: 0
+    b:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 0
+    label: firewall-lan
+  - a:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 1
+    b:
+      resource: access-switch
+      portGroup: 1
+      portIndex: 0
+    label: switch-uplink
+  - a:
+      resource: proxmox-node01
+      portGroup: 1
+      portIndex: 0
+    b:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 2
+    label: node01-10g
+  - a:
+      resource: proxmox-node02
+      portGroup: 1
+      portIndex: 0
+    b:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 3
+    label: node02-10g
+  - a:
+      resource: truenas-storage
+      portGroup: 1
+      portIndex: 0
+    b:
+      resource: core-switch
+      portGroup: 0
+      portIndex: 4
+    label: storage-link
+  - a:
+      resource: lounge-ap
+      portGroup: 0
+      portIndex: 0
+    b:
+      resource: access-switch
+      portGroup: 0
+      portIndex: 1
+    label: wifi-uplink

+ 667 - 0
RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json

@@ -0,0 +1,667 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v3/schema.v3.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 3
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "type",
+            "os",
+            "cores",
+            "ram"
+          ],
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 667 - 0
RackPeek.Web/wwwroot/schemas/v3/schema.v3.json

@@ -0,0 +1,667 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v3/schema.v3.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 3
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "type",
+            "os",
+            "cores",
+            "ram"
+          ],
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 1 - 1
RackPeek/RackPeek.csproj

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

+ 7 - 0
Shared.Rcl/AccessPoints/AccessPointCardComponent.razor

@@ -1,4 +1,5 @@
 @using RackPeek.Domain.Resources.AccessPoints
 @using RackPeek.Domain.Resources.AccessPoints
+@using Shared.Rcl.Hardware
 @inject UpdateAccessPointUseCase UpdateUseCase
 @inject UpdateAccessPointUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<AccessPoint> DeleteUseCase
 @inject IDeleteResourceUseCase<AccessPoint> DeleteUseCase
 @inject IRenameResourceUseCase<AccessPoint> RenameUseCase
 @inject IRenameResourceUseCase<AccessPoint> RenameUseCase
@@ -104,6 +105,12 @@
             }
             }
         </div>
         </div>
 
 
+        <!-- NICs -->
+        <PortGroupEditor T="AccessPoint"
+                         Resource="AccessPoint"
+                         OnResourceChanged="r => AccessPoint = r"
+                         TestIdPrefix="accesspoint-ports"/>
+
         <ResourceTagEditor Resource="AccessPoint"
         <ResourceTagEditor Resource="AccessPoint"
                            TestIdPrefix="accesspoint"/>
                            TestIdPrefix="accesspoint"/>
 
 

+ 11 - 0
Shared.Rcl/CliBootstrap.cs

@@ -8,6 +8,7 @@ using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
 using Shared.Rcl.Commands.AccessPoints.Labels;
+using Shared.Rcl.Commands.Connections;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops.Cpus;
 using Shared.Rcl.Commands.Desktops.Cpus;
 using Shared.Rcl.Commands.Desktops.Drive;
 using Shared.Rcl.Commands.Desktops.Drive;
@@ -577,6 +578,16 @@ public static class CliBootstrap {
                 hosts.AddCommand<GenerateHostsFileCommand>("export")
                 hosts.AddCommand<GenerateHostsFileCommand>("export")
                     .WithDescription("Generate a /etc/hosts compatible file.");
                     .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.");
+            });
         });
         });
     }
     }
 
 

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

@@ -0,0 +1,83 @@
+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;
+    }
+}

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

@@ -0,0 +1,50 @@
+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 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;
+    }
+}

+ 1 - 1
Shared.Rcl/Commands/Desktops/DesktopGetCommand.cs

@@ -39,7 +39,7 @@ public class DesktopGetCommand(IServiceProvider provider)
                 (d.Cpus?.Count ?? 0).ToString(),
                 (d.Cpus?.Count ?? 0).ToString(),
                 d.Ram == null ? "None" : $"{d.Ram.Size}GB",
                 d.Ram == null ? "None" : $"{d.Ram.Size}GB",
                 (d.Drives?.Count ?? 0).ToString(),
                 (d.Drives?.Count ?? 0).ToString(),
-                (d.Nics?.Count ?? 0).ToString(),
+                (d.Ports?.Count ?? 0).ToString(),
                 (d.Gpus?.Count ?? 0).ToString()
                 (d.Gpus?.Count ?? 0).ToString()
             );
             );
 
 

+ 2 - 2
Shared.Rcl/Commands/Desktops/Nics/DesktopNicAddCommand.cs

@@ -1,7 +1,7 @@
 using System.ComponentModel;
 using System.ComponentModel;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Desktops;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -32,7 +32,7 @@ public class DesktopNicAddCommand(IServiceProvider provider)
         DesktopNicAddSettings settings,
         DesktopNicAddSettings settings,
         CancellationToken cancellationToken) {
         CancellationToken cancellationToken) {
         using IServiceScope scope = provider.CreateScope();
         using IServiceScope scope = provider.CreateScope();
-        IAddNicUseCase<Desktop> useCase = scope.ServiceProvider.GetRequiredService<IAddNicUseCase<Desktop>>();
+        IAddPortUseCase<Desktop> useCase = scope.ServiceProvider.GetRequiredService<IAddPortUseCase<Desktop>>();
 
 
         await useCase.ExecuteAsync(settings.DesktopName, settings.Type, settings.Speed, settings.Ports);
         await useCase.ExecuteAsync(settings.DesktopName, settings.Type, settings.Speed, settings.Ports);
 
 

+ 2 - 2
Shared.Rcl/Commands/Desktops/Nics/DesktopNicRemoveCommand.cs

@@ -1,7 +1,7 @@
 using System.ComponentModel;
 using System.ComponentModel;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Desktops;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -24,7 +24,7 @@ public class DesktopNicRemoveCommand(IServiceProvider provider)
         DesktopNicRemoveSettings settings,
         DesktopNicRemoveSettings settings,
         CancellationToken cancellationToken) {
         CancellationToken cancellationToken) {
         using IServiceScope scope = provider.CreateScope();
         using IServiceScope scope = provider.CreateScope();
-        IRemoveNicUseCase<Desktop> useCase = scope.ServiceProvider.GetRequiredService<IRemoveNicUseCase<Desktop>>();
+        IRemovePortUseCase<Desktop> useCase = scope.ServiceProvider.GetRequiredService<IRemovePortUseCase<Desktop>>();
 
 
         await useCase.ExecuteAsync(settings.DesktopName, settings.Index);
         await useCase.ExecuteAsync(settings.DesktopName, settings.Index);
 
 

+ 2 - 2
Shared.Rcl/Commands/Desktops/Nics/DesktopNicSetCommand.cs

@@ -1,7 +1,7 @@
 using System.ComponentModel;
 using System.ComponentModel;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Desktops;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -36,7 +36,7 @@ public class DesktopNicSetCommand(IServiceProvider provider)
         DesktopNicSetSettings settings,
         DesktopNicSetSettings settings,
         CancellationToken cancellationToken) {
         CancellationToken cancellationToken) {
         using IServiceScope scope = provider.CreateScope();
         using IServiceScope scope = provider.CreateScope();
-        IUpdateNicUseCase<Desktop> useCase = scope.ServiceProvider.GetRequiredService<IUpdateNicUseCase<Desktop>>();
+        IUpdatePortUseCase<Desktop> useCase = scope.ServiceProvider.GetRequiredService<IUpdatePortUseCase<Desktop>>();
 
 
         await useCase.ExecuteAsync(settings.DesktopName, settings.Index, settings.Type, settings.Speed, settings.Ports);
         await useCase.ExecuteAsync(settings.DesktopName, settings.Index, settings.Type, settings.Speed, settings.Ports);
 
 

+ 1 - 1
Shared.Rcl/Commands/Exporters/GenerateSshConfigCommand.cs

@@ -1,5 +1,5 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
-using RackPeek.Domain.UseCases.Ssh;
+using RackPeek.Domain.UseCases.SSH;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 

+ 2 - 2
Shared.Rcl/Commands/Servers/Nics/ServerNicAddCommand.cs

@@ -1,6 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -21,7 +21,7 @@ public class ServerNicAddCommand(IServiceProvider serviceProvider)
         ServerNicAddSettings settings,
         ServerNicAddSettings settings,
         CancellationToken cancellationToken) {
         CancellationToken cancellationToken) {
         using IServiceScope scope = serviceProvider.CreateScope();
         using IServiceScope scope = serviceProvider.CreateScope();
-        IAddNicUseCase<Server> useCase = scope.ServiceProvider.GetRequiredService<IAddNicUseCase<Server>>();
+        IAddPortUseCase<Server> useCase = scope.ServiceProvider.GetRequiredService<IAddPortUseCase<Server>>();
 
 
         await useCase.ExecuteAsync(
         await useCase.ExecuteAsync(
             settings.Name,
             settings.Name,

+ 2 - 2
Shared.Rcl/Commands/Servers/Nics/ServerNicRemoveCommand.cs

@@ -1,6 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -17,7 +17,7 @@ public class ServerNicRemoveCommand(IServiceProvider serviceProvider)
         ServerNicRemoveSettings settings,
         ServerNicRemoveSettings settings,
         CancellationToken cancellationToken) {
         CancellationToken cancellationToken) {
         using IServiceScope scope = serviceProvider.CreateScope();
         using IServiceScope scope = serviceProvider.CreateScope();
-        IRemoveNicUseCase<Server> useCase = scope.ServiceProvider.GetRequiredService<IRemoveNicUseCase<Server>>();
+        IRemovePortUseCase<Server> useCase = scope.ServiceProvider.GetRequiredService<IRemovePortUseCase<Server>>();
 
 
         await useCase.ExecuteAsync(
         await useCase.ExecuteAsync(
             settings.Name,
             settings.Name,

+ 2 - 2
Shared.Rcl/Commands/Servers/Nics/ServerNicUpdateCommand.cs

@@ -1,6 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -23,7 +23,7 @@ public class ServerNicUpdateCommand(IServiceProvider serviceProvider)
         ServerNicUpdateSettings settings,
         ServerNicUpdateSettings settings,
         CancellationToken cancellationToken) {
         CancellationToken cancellationToken) {
         using IServiceScope scope = serviceProvider.CreateScope();
         using IServiceScope scope = serviceProvider.CreateScope();
-        IUpdateNicUseCase<Server> useCase = scope.ServiceProvider.GetRequiredService<IUpdateNicUseCase<Server>>();
+        IUpdatePortUseCase<Server> useCase = scope.ServiceProvider.GetRequiredService<IUpdatePortUseCase<Server>>();
 
 
         await useCase.ExecuteAsync(
         await useCase.ExecuteAsync(
             settings.Name,
             settings.Name,

+ 1 - 1
Shared.Rcl/Components/CrumbLevel.razor

@@ -41,5 +41,5 @@
 </div>
 </div>
 
 
 @code {
 @code {
-    [Parameter][EditorRequired] public List<ResourceBreadCrumbComponent.Breadcrumb> Items { get; set; } = new();
+    [Parameter] [EditorRequired] public List<ResourceBreadCrumbComponent.Breadcrumb> Items { get; set; } = new();
 }
 }

+ 2 - 2
Shared.Rcl/Components/ResourceBreadCrumbComponent.razor

@@ -17,11 +17,11 @@
 </div>
 </div>
 
 
 @code {
 @code {
-    [Parameter][EditorRequired] public ResourceType ResourceType { get; set; }
+    [Parameter] [EditorRequired] public ResourceType ResourceType { get; set; }
 
 
     public string Kind { get; set; } = string.Empty;
     public string Kind { get; set; } = string.Empty;
 
 
-    [Parameter][EditorRequired] public string ResourceName { get; set; } = default!;
+    [Parameter] [EditorRequired] public string ResourceName { get; set; } = default!;
 
 
     private List<List<Breadcrumb>> Levels { get; } = new();
     private List<List<Breadcrumb>> Levels { get; } = new();
 
 

+ 1 - 1
Shared.Rcl/Components/SshExport.razor

@@ -1,5 +1,5 @@
 @page "/ssh/export"
 @page "/ssh/export"
-@using RackPeek.Domain.UseCases.Ssh
+@using RackPeek.Domain.UseCases.SSH
 @inject SshConfigExportUseCase SshUseCase
 @inject SshConfigExportUseCase SshUseCase
 
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900 max-w-5xl mx-auto"
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900 max-w-5xl mx-auto"

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

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

@@ -0,0 +1,409 @@
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources
+@using RackPeek.Domain.Resources.Connections
+@using RackPeek.Domain.Resources.Servers
+@using RackPeek.Domain.Resources.SubResources
+@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"
+            data-testid="@($"{BaseTestId}-container")">
+            
+            <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"
+                            data-testid="@($"{BaseTestId}-resource-a")"
+                            @bind="_resourceAIndex">
+
+                        <option value="">Select resource</option>
+
+                        @for (var 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"
+                                data-testid="@($"{BaseTestId}-group-a")"
+                                @bind="_groupAIndex">
+
+                            <option value="">Select group</option>
+
+                            @for (var 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"
+                                data-testid="@($"{BaseTestId}-port-a")"
+                                @bind="_portAIndex">
+
+                            <option value="">Select port</option>
+
+                            @for (var 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"
+                            data-testid="@($"{BaseTestId}-resource-b")"
+                            @bind="_resourceBIndex">
+
+                        <option value="">Select resource</option>
+
+                        @for (var 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"
+                                data-testid="@($"{BaseTestId}-group-b")"
+                                @bind="_groupBIndex">
+
+                            <option value="">Select group</option>
+
+                            @for (var 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"
+                                data-testid="@($"{BaseTestId}-port-b")"
+                                @bind="_portBIndex">
+
+                            <option value="">Select port</option>
+
+                            @for (var 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"
+                        data-testid="@($"{BaseTestId}-cancel")"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+
+                <button class="px-3 py-1 rounded bg-emerald-600 text-black"
+                        disabled="@(!CanSubmit)"
+                        data-testid="@($"{BaseTestId}-submit")"
+                        @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;
+
+    readonly PortReference _portA = new();
+    readonly 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);
+
+        await Cancel();
+    }
+
+
+    async Task Cancel()
+    {
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+}

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

@@ -0,0 +1,119 @@
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources.Connections
+@using RackPeek.Domain.Resources.SubResources
+@inject IResourceCollection Repository
+
+@if (PortGroup is not null && !string.IsNullOrWhiteSpace(ResourceName))
+{
+    <div class="flex flex-wrap">
+
+        @for (var i = 0; i < PortGroup.Count; i++)
+        {
+            var index = i;
+
+            var port = new PortReference
+            {
+                Resource = ResourceName,
+                PortGroup = PortGroupIndex,
+                PortIndex = index
+            };
+
+            var conn = GetConnection(port);
+            var selected = SelectedPortIndex == index;
+            
+            <button type="button"
+                    data-testid="@($"{BaseTestId}-port-{index}")"
+                    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 string? TestIdPrefix { 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();
+    private string BaseTestId =>
+        string.IsNullOrWhiteSpace(TestIdPrefix)
+            ? "port-visualizer"
+            : TestIdPrefix;
+    
+    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})";
+    }
+
+}

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

@@ -0,0 +1,180 @@
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources.Connections
+@using RackPeek.Domain.Resources.Servers
+@using RackPeek.Domain.Resources.SubResources
+@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 (var 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 readonly 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];
+    }
+
+}

+ 2 - 1
Shared.Rcl/ConsoleRunner.cs

@@ -1,3 +1,4 @@
+using System.Text;
 using RackPeek.Domain;
 using RackPeek.Domain;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
@@ -28,7 +29,7 @@ public class ConsoleEmulator : IConsoleEmulator {
 
 
     internal static string[] ParseArguments(string input) {
     internal static string[] ParseArguments(string input) {
         var args = new List<string>();
         var args = new List<string>();
-        var current = new System.Text.StringBuilder();
+        var current = new StringBuilder();
         char? quote = null;
         char? quote = null;
 
 
         for (var i = 0; i < input.Length; i++) {
         for (var i = 0; i < input.Length; i++) {

+ 5 - 94
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -3,7 +3,7 @@
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
 @using RackPeek.Domain.UseCases.Gpus
-@using RackPeek.Domain.UseCases.Nics
+@using Shared.Rcl.Hardware
 @inject IGetResourceByNameUseCase<Desktop> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Desktop> GetByNameUseCase
 @inject UpdateDesktopUseCase UpdateUseCase
 @inject UpdateDesktopUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<Desktop> DeleteUseCase
 @inject IDeleteResourceUseCase<Desktop> DeleteUseCase
@@ -13,9 +13,6 @@
 @inject IAddDriveUseCase<Desktop> AddDriveUseCase
 @inject IAddDriveUseCase<Desktop> AddDriveUseCase
 @inject IUpdateDriveUseCase<Desktop> UpdateDriveUseCase
 @inject IUpdateDriveUseCase<Desktop> UpdateDriveUseCase
 @inject IRemoveDriveUseCase<Desktop> RemoveDriveUseCase
 @inject IRemoveDriveUseCase<Desktop> RemoveDriveUseCase
-@inject IAddNicUseCase<Desktop> AddNicUseCase
-@inject IUpdateNicUseCase<Desktop> UpdateNicUseCase
-@inject IRemoveNicUseCase<Desktop> RemoveNicUseCase
 @inject IAddGpuUseCase<Desktop> AddGpuUseCase
 @inject IAddGpuUseCase<Desktop> AddGpuUseCase
 @inject IUpdateGpuUseCase<Desktop> UpdateGpuUseCase
 @inject IUpdateGpuUseCase<Desktop> UpdateGpuUseCase
 @inject IRemoveGpuUseCase<Desktop> RemoveGpuUseCase
 @inject IRemoveGpuUseCase<Desktop> RemoveGpuUseCase
@@ -152,33 +149,10 @@
         </div>
         </div>
 
 
         <!-- NICs -->
         <!-- 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.Nics?.Any() == true)
-            {
-                @foreach (var nic in Desktop.Nics)
-                {
-                    <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.Ports 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 data-testid="desktop-gpu-section">
@@ -297,15 +271,6 @@
           OnDelete="HandleGpuDelete"
           OnDelete="HandleGpuDelete"
           TestIdPrefix="desktop"/>
           TestIdPrefix="desktop"/>
 
 
-
-<NicModal
-    IsOpen="@_nicModalOpen"
-    IsOpenChanged="v => _nicModalOpen = v"
-    Value="@_editingNic"
-    OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"
-    TestIdPrefix="desktop-nic"/>
-
 @code {
 @code {
     [Parameter] [EditorRequired] public Desktop Desktop { get; set; } = default!;
     [Parameter] [EditorRequired] public Desktop Desktop { get; set; } = default!;
 
 
@@ -421,60 +386,6 @@
 
 
     #endregion
     #endregion
 
 
-    #region NICs
-
-    bool _nicModalOpen;
-    int _editingNicIndex;
-    Nic? _editingNic;
-
-    void OpenAddNic()
-    {
-        _editingNicIndex = -1;
-        _editingNic = null;
-        _nicModalOpen = true;
-    }
-
-    void OpenEditNic(Nic nic)
-    {
-        Desktop.Nics ??= new List<Nic>();
-        _editingNicIndex = Desktop.Nics.IndexOf(nic);
-        _editingNic = nic;
-        _nicModalOpen = true;
-    }
-
-    async Task HandleNicSubmit(Nic nic)
-    {
-        Desktop.Nics ??= new List<Nic>();
-
-        if (_editingNicIndex < 0)
-        {
-            await AddNicUseCase.ExecuteAsync(
-                Desktop.Name,
-                nic.Type,
-                nic.Speed,
-                nic.Ports);
-        }
-        else
-        {
-            await UpdateNicUseCase.ExecuteAsync(
-                Desktop.Name,
-                _editingNicIndex,
-                nic.Type,
-                nic.Speed,
-                nic.Ports);
-        }
-
-        Desktop = await GetByNameUseCase.ExecuteAsync(Desktop.Name);
-    }
-
-    async Task HandleNicDelete(Nic nic)
-    {
-        await RemoveNicUseCase.ExecuteAsync(Desktop.Name, _editingNicIndex);
-        Desktop = await GetByNameUseCase.ExecuteAsync(Desktop.Name);
-    }
-
-    #endregion
-
     #region GPUs
     #region GPUs
 
 
     bool _gpuModalOpen;
     bool _gpuModalOpen;

+ 5 - 97
Shared.Rcl/Firewalls/FirewallCardComponent.razor

@@ -1,11 +1,7 @@
 @using RackPeek.Domain.Resources.Firewalls
 @using RackPeek.Domain.Resources.Firewalls
-@using RackPeek.Domain.Resources.SubResources
-@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Hardware
 @inject UpdateFirewallUseCase UpdateUseCase
 @inject UpdateFirewallUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Firewall> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Firewall> GetByNameUseCase
-@inject IAddPortUseCase<Firewall> AddPortUseCase
-@inject IUpdatePortUseCase<Firewall> UpdatePortUseCase
-@inject IRemovePortUseCase<Firewall> RemovePortUseCase
 @inject IDeleteResourceUseCase<Firewall> DeleteUseCase
 @inject IDeleteResourceUseCase<Firewall> DeleteUseCase
 @inject ICloneResourceUseCase<Firewall> CloneUseCase
 @inject ICloneResourceUseCase<Firewall> CloneUseCase
 @inject IRenameResourceUseCase<Firewall> RenameUseCase
 @inject IRenameResourceUseCase<Firewall> RenameUseCase
@@ -133,35 +129,10 @@
         </div>
         </div>
 
 
         <!-- Ports -->
         <!-- 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"
         <ResourceTagEditor Resource="Firewall"
                            TestIdPrefix="firewall"/>
                            TestIdPrefix="firewall"/>
@@ -192,12 +163,6 @@
     </div>
     </div>
 </div>
 </div>
 
 
-<PortModal IsOpen="@_portModalOpen"
-           IsOpenChanged="v => _portModalOpen = v"
-           Value="@_editingPort"
-           OnSubmit="HandlePortSubmit"
-           OnDelete="HandlePortDelete"/>
-
 <ConfirmModal IsOpen="_confirmDeleteOpen"
 <ConfirmModal IsOpen="_confirmDeleteOpen"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               Title="Delete firewall"
               Title="Delete firewall"
@@ -257,63 +222,6 @@
         _isEditing = false;
         _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 class FirewallEditModel
     {
     {
         public string? Model { get; set; }
         public string? Model { get; set; }

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

@@ -1,6 +1,7 @@
 @page "/resources/hardware/{HardwareName}"
 @page "/resources/hardware/{HardwareName}"
 @using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Resources.AccessPoints
 @using RackPeek.Domain.Resources.AccessPoints
+@using RackPeek.Domain.Resources.Connections
 @using RackPeek.Domain.Resources.Desktops
 @using RackPeek.Domain.Resources.Desktops
 @using RackPeek.Domain.Resources.Firewalls
 @using RackPeek.Domain.Resources.Firewalls
 @using RackPeek.Domain.Resources.Hardware
 @using RackPeek.Domain.Resources.Hardware
@@ -10,6 +11,7 @@
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.UpsUnits
 @using RackPeek.Domain.Resources.UpsUnits
 @using Shared.Rcl.AccessPoints
 @using Shared.Rcl.AccessPoints
+@using Shared.Rcl.Connections
 @using Shared.Rcl.Desktops
 @using Shared.Rcl.Desktops
 @using Shared.Rcl.Firewalls
 @using Shared.Rcl.Firewalls
 @using Shared.Rcl.Laptops
 @using Shared.Rcl.Laptops
@@ -18,6 +20,7 @@
 @using Shared.Rcl.Switches
 @using Shared.Rcl.Switches
 @using Shared.Rcl.Ups
 @using Shared.Rcl.Ups
 @using Router = RackPeek.Domain.Resources.Routers.Router
 @using Router = RackPeek.Domain.Resources.Routers.Router
+
 @inject IResourceCollection Repo
 @inject IResourceCollection Repo
 @inject GetHardwareSystemTreeUseCase GetHardwareSystemTreeUseCase
 @inject GetHardwareSystemTreeUseCase GetHardwareSystemTreeUseCase
 @inject NavigationManager Nav
 @inject NavigationManager Nav
@@ -30,6 +33,7 @@
 />
 />
 
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
+
     @if (_hardware is null && !_loading)
     @if (_hardware is null && !_loading)
     {
     {
         <div class="text-zinc-500">Hardware not found</div>
         <div class="text-zinc-500">Hardware not found</div>
@@ -40,6 +44,8 @@
     }
     }
     else
     else
     {
     {
+        @* ================= Hardware Card ================= *@
+
         @if (_hardware != null)
         @if (_hardware != null)
         {
         {
             <h1 class="text-lg text-zinc-100 mb-6">
             <h1 class="text-lg text-zinc-100 mb-6">
@@ -87,6 +93,8 @@
         }
         }
 
 
 
 
+        @* ================= Dependency Tree ================= *@
+
         @if (_tree is not null && _tree.Systems.Any())
         @if (_tree is not null && _tree.Systems.Any())
         {
         {
             <HardwareDependencyTreeComponent Tree="_tree"/>
             <HardwareDependencyTreeComponent Tree="_tree"/>
@@ -98,22 +106,71 @@
             </div>
             </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 (var 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">
         <div class="m-4">
             <AddResourceComponent TResource="SystemResource"
             <AddResourceComponent TResource="SystemResource"
                                   Placeholder="System name"
                                   Placeholder="System name"
                                   OnCreated="NavigateToNewResource"
                                   OnCreated="NavigateToNewResource"
-                                  RunsOn="@(new List<string>  { HardwareName })"/>
+                                  RunsOn="@(new List<string> { HardwareName })"/>
         </div>
         </div>
     }
     }
+
 </div>
 </div>
 
 
+
+<PortConnectionModal
+    IsOpen="@_connectionModalOpen"
+    IsOpenChanged="v => _connectionModalOpen = v"
+    SeedPort="@_selectedPort"
+/>
+
+
 @code {
 @code {
+
     [Parameter] public string HardwareName { get; set; } = string.Empty;
     [Parameter] public string HardwareName { get; set; } = string.Empty;
 
 
     private Hardware? _hardware;
     private Hardware? _hardware;
     private bool _loading = true;
     private bool _loading = true;
     private HardwareDependencyTree? _tree;
     private HardwareDependencyTree? _tree;
 
 
+    private bool _connectionModalOpen;
+    private PortReference? _selectedPort;
+
+
     protected override async Task OnParametersSetAsync()
     protected override async Task OnParametersSetAsync()
     {
     {
         _loading = true;
         _loading = true;
@@ -130,16 +187,25 @@
         _loading = false;
         _loading = false;
     }
     }
 
 
+
+    void HandlePortClicked(PortReference port)
+    {
+        _selectedPort = port;
+        _connectionModalOpen = true;
+    }
+
+
     private Task DeleteCallback(string obj)
     private Task DeleteCallback(string obj)
     {
     {
         Nav.NavigateTo("/hardware/tree");
         Nav.NavigateTo("/hardware/tree");
         return Task.CompletedTask;
         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;
         return Task.CompletedTask;
     }
     }
 
 
-}
+}

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

@@ -9,6 +9,8 @@
     Hardware
     Hardware
 </h1>
 </h1>
 
 
+
+
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6"
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6"
      data-testid="hardware-page-root">
      data-testid="hardware-page-root">
 
 

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

@@ -0,0 +1,171 @@
+@using RackPeek.Domain.Resources.Connections
+@using RackPeek.Domain.Resources.SubResources
+@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Connections
+@typeparam T where T : RackPeek.Domain.Resources.Resource, RackPeek.Domain.Resources.Servers.IPortResource
+
+@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")"
+                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 Port Group"
+                        @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"
+                        TestIdPrefix="@($"{BaseTestId}-visualizer-{idx}")"
+                        OnPortClicked="HandlePortClicked"/>
+
+                </div>
+
+            </div>
+        }
+    }
+
+</div>
+
+<PortModal
+    IsOpen="@_nicModalOpen"
+    IsOpenChanged="v => _nicModalOpen = v"
+    Value="@_editingNic"
+    OnSubmit="HandleNicSubmit"
+    OnDelete="HandleNicDelete"
+    TestIdPrefix="@($"{BaseTestId}")"/>
+
+<PortConnectionModal
+    IsOpen="@_connectionModalOpen"
+    IsOpenChanged="v => _connectionModalOpen = v"
+    SeedPort="@_selectedPort"
+    TestIdPrefix="@($"{BaseTestId}")"/>
+
+@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;
+    }
+
+}

+ 5 - 97
Shared.Rcl/Routers/RouterCardComponent.razor

@@ -1,13 +1,9 @@
 @using RackPeek.Domain.Resources.Routers
 @using RackPeek.Domain.Resources.Routers
-@using RackPeek.Domain.Resources.SubResources
-@using RackPeek.Domain.UseCases.Ports
 @using Router = RackPeek.Domain.Resources.Routers.Router
 @using Router = RackPeek.Domain.Resources.Routers.Router
+@using Shared.Rcl.Hardware
 
 
 @inject UpdateRouterUseCase UpdateUseCase
 @inject UpdateRouterUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Router> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Router> GetByNameUseCase
-@inject IAddPortUseCase<Router> AddPortUseCase
-@inject IUpdatePortUseCase<Router> UpdatePortUseCase
-@inject IRemovePortUseCase<Router> RemovePortUseCase
 @inject IDeleteResourceUseCase<Router> DeleteUseCase
 @inject IDeleteResourceUseCase<Router> DeleteUseCase
 @inject IRenameResourceUseCase<Router> RenameUseCase
 @inject IRenameResourceUseCase<Router> RenameUseCase
 @inject ICloneResourceUseCase<Router> CloneUseCase
 @inject ICloneResourceUseCase<Router> CloneUseCase
@@ -135,35 +131,10 @@
         </div>
         </div>
 
 
         <!-- Ports -->
         <!-- 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"
         <ResourceTagEditor Resource="Router"
                            TestIdPrefix="router"/>
                            TestIdPrefix="router"/>
@@ -194,12 +165,6 @@
     </div>
     </div>
 </div>
 </div>
 
 
-<PortModal IsOpen="@_portModalOpen"
-           IsOpenChanged="v => _portModalOpen = v"
-           Value="@_editingPort"
-           OnSubmit="HandlePortSubmit"
-           OnDelete="HandlePortDelete"/>
-
 <ConfirmModal IsOpen="_confirmDeleteOpen"
 <ConfirmModal IsOpen="_confirmDeleteOpen"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               Title="Delete router"
               Title="Delete router"
@@ -259,63 +224,6 @@
         _isEditing = false;
         _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 class RouterEditModel
     {
     {
         public string? Model { get; set; }
         public string? Model { get; set; }

+ 9 - 94
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -3,7 +3,8 @@
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
 @using RackPeek.Domain.UseCases.Gpus
-@using RackPeek.Domain.UseCases.Nics
+@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Hardware
 @inject IAddCpuUseCase<Server> AddCpuUseCase
 @inject IAddCpuUseCase<Server> AddCpuUseCase
 @inject IRemoveCpuUseCase<Server> RemoveCpuUseCase
 @inject IRemoveCpuUseCase<Server> RemoveCpuUseCase
 @inject IUpdateCpuUseCase<Server> UpdateCpuUseCase
 @inject IUpdateCpuUseCase<Server> UpdateCpuUseCase
@@ -12,9 +13,9 @@
 @inject IUpdateDriveUseCase<Server> UpdateDriveUseCase
 @inject IUpdateDriveUseCase<Server> UpdateDriveUseCase
 @inject IRemoveDriveUseCase<Server> RemoveDriveUseCase
 @inject IRemoveDriveUseCase<Server> RemoveDriveUseCase
 
 
-@inject IAddNicUseCase<Server> AddNicUseCase
-@inject IUpdateNicUseCase<Server> UpdateNicUseCase
-@inject IRemoveNicUseCase<Server> RemoveNicUseCase
+@inject IAddPortUseCase<Server> AddNicUseCase
+@inject IUpdatePortUseCase<Server> UpdateNicUseCase
+@inject IRemovePortUseCase<Server> RemoveNicUseCase
 
 
 @inject IAddGpuUseCase<Server> AddGpuUseCase
 @inject IAddGpuUseCase<Server> AddGpuUseCase
 @inject IUpdateGpuUseCase<Server> UpdateGpuUseCase
 @inject IUpdateGpuUseCase<Server> UpdateGpuUseCase
@@ -168,36 +169,10 @@
             }
             }
         </div>
         </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.Nics?.Any() == true)
-            {
-                @foreach (var nic in Server.Nics)
-                {
-                    <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.Ports ports)
-                        </button>
-                    </div>
-                }
-            }
-        </div>
-
+        <PortGroupEditor T="Server"
+                         Resource="Server"
+                         OnResourceChanged="r => Server = r"
+                         TestIdPrefix="server-ports"/>
 
 
         <div>
         <div>
             <div class="flex items-center justify-between mb-1 group">
             <div class="flex items-center justify-between mb-1 group">
@@ -287,13 +262,6 @@
     TestIdPrefix="server-drive"/>
     TestIdPrefix="server-drive"/>
 
 
 
 
-<NicModal
-    IsOpen="@_nicModalOpen"
-    IsOpenChanged="v => _nicModalOpen = v"
-    Value="@_editingNic"
-    OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"
-    TestIdPrefix="server-nic"/>
 
 
 <GpuModal
 <GpuModal
     IsOpen="@_gpuModalOpen"
     IsOpen="@_gpuModalOpen"
@@ -452,59 +420,6 @@
 
 
     #endregion
     #endregion
 
 
-    #region NICs
-
-    bool _nicModalOpen;
-    int _editingNicIndex;
-    Nic? _editingNic;
-
-    void OpenAddNic()
-    {
-        _editingNicIndex = -1;
-        _editingNic = null;
-        _nicModalOpen = true;
-    }
-
-    void OpenEditNic(Nic nic)
-    {
-        Server.Nics ??= new List<Nic>();
-        _editingNicIndex = Server.Nics.IndexOf(nic);
-        _editingNic = nic;
-        _nicModalOpen = true;
-    }
-
-    async Task HandleNicSubmit(Nic nic)
-    {
-        Server.Nics ??= new List<Nic>();
-
-        if (_editingNicIndex < 0)
-        {
-            await AddNicUseCase.ExecuteAsync(
-                Server.Name,
-                nic.Type,
-                nic.Speed,
-                nic.Ports);
-        }
-        else
-        {
-            await UpdateNicUseCase.ExecuteAsync(
-                Server.Name,
-                _editingNicIndex,
-                nic.Type,
-                nic.Speed,
-                nic.Ports);
-        }
-
-        Server = await GetByNameUseCase.ExecuteAsync(Server.Name);
-    }
-
-    async Task HandleNicDelete(Nic nic)
-    {
-        await RemoveNicUseCase.ExecuteAsync(Server.Name, _editingNicIndex);
-        Server = await GetByNameUseCase.ExecuteAsync(Server.Name);
-    }
-
-    #endregion
 
 
     #region GPUs
     #region GPUs
 
 

+ 5 - 98
Shared.Rcl/Switches/SwitchCardComponent.razor

@@ -1,11 +1,7 @@
-@using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.Switches
 @using RackPeek.Domain.Resources.Switches
-@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Hardware
 @inject UpdateSwitchUseCase UpdateUseCase
 @inject UpdateSwitchUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Switch> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Switch> GetByNameUseCase
-@inject IAddPortUseCase<Switch> AddPortUseCase
-@inject IUpdatePortUseCase<Switch> UpdatePortUseCase
-@inject IRemovePortUseCase<Switch> RemovePortUseCase
 @inject IDeleteResourceUseCase<Switch> DeleteUseCase
 @inject IDeleteResourceUseCase<Switch> DeleteUseCase
 @inject ICloneResourceUseCase<Switch> CloneUseCase
 @inject ICloneResourceUseCase<Switch> CloneUseCase
 @inject IRenameResourceUseCase<Switch> RenameUseCase
 @inject IRenameResourceUseCase<Switch> RenameUseCase
@@ -133,35 +129,10 @@
         </div>
         </div>
 
 
         <!-- Ports -->
         <!-- 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"
         <ResourceTagEditor Resource="Switch"
                            TestIdPrefix="switch"/>
                            TestIdPrefix="switch"/>
@@ -192,12 +163,6 @@
     </div>
     </div>
 </div>
 </div>
 
 
-<PortModal IsOpen="@_portModalOpen"
-           IsOpenChanged="v => _portModalOpen = v"
-           Value="@_editingPort"
-           OnSubmit="HandlePortSubmit"
-           OnDelete="HandlePortDelete"/>
-
 <ConfirmModal IsOpen="_confirmDeleteOpen"
 <ConfirmModal IsOpen="_confirmDeleteOpen"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               IsOpenChanged="v => _confirmDeleteOpen = v"
               Title="Delete switch"
               Title="Delete switch"
@@ -258,63 +223,6 @@
         _isEditing = false;
         _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 class SwitchEditModel
     {
     {
         public string? Model { get; set; }
         public string? Model { get; set; }
@@ -334,7 +242,6 @@
         }
         }
     }
     }
 
 
-
 }
 }
 
 
 @code {
 @code {

+ 1 - 4
Shared.Rcl/Systems/SystemsListPage.razor

@@ -10,10 +10,7 @@
                         TestId="systems"
                         TestId="systems"
                         Resources="@Systems"
                         Resources="@Systems"
                         ShouldGroup="true"
                         ShouldGroup="true"
-                        GroupBy="@(s =>
-                                 {
-                                     return s.RunsOn.FirstOrDefault();
-                                 })"
+                        GroupBy="@(s => { return s.RunsOn.FirstOrDefault(); })"
                         OnCreated="NavigateToNewResource">
                         OnCreated="NavigateToNewResource">
 
 
     <ItemTemplate Context="systemResource">
     <ItemTemplate Context="systemResource">

+ 98 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -295,4 +295,102 @@ public class AccessPointCardTests(
             await context.CloseAsync();
             await context.CloseAsync();
         }
         }
     }
     }
+
+    [Fact]
+    public async Task User_Can_Add_Ports_To_Two_AccessPoints_And_Connect_Them() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var ap1 = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var ap2 = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync(_fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            // -------------------------------------------------
+            // Create first AP
+            // -------------------------------------------------
+
+            await list.AddAccessPointAsync(ap1);
+            await page.WaitForURLAsync($"**/resources/hardware/{ap1}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(ap1);
+
+            // Add port group to AP1
+            await card.AddPortGroupAsync(
+                "rj45",
+                "1",
+                2);
+
+            // -------------------------------------------------
+            // Create second AP
+            // -------------------------------------------------
+
+            await layout.GotoHardwareAsync();
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(ap2);
+            await page.WaitForURLAsync($"**/resources/hardware/{ap2}");
+
+            await card.AssertCardVisibleAsync(ap2);
+
+            // Add port group to AP2
+            await card.AddPortGroupAsync(
+                "sfp+",
+                "2.5",
+                2);
+            // -------------------------------------------------
+            // Go back to AP1 to create connection
+            // -------------------------------------------------
+
+            await layout.GotoHardwareAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+            await list.OpenAccessPointAsync(ap1);
+
+            await card.AssertCardVisibleAsync(ap1);
+
+            // -------------------------------------------------
+            // Open connection modal from port
+            // -------------------------------------------------
+
+            await card.OpenConnectionFromPortAsync(0, 0);
+
+            // -------------------------------------------------
+            // Create connection
+            // -------------------------------------------------
+
+            await card.CreateConnectionAsync(
+                ap1,
+                "rj45 — 1 Gbps (2)", // example label — adjust if needed
+                "Port 1",
+                ap2,
+                "sfp+ — 2.5 Gbps (2)",
+                "Port 1");
+
+            // -------------------------------------------------
+            // Verify connection indicator appears
+            // -------------------------------------------------
+
+            await card.Ports.AssertPortVisibleAsync("accesspoint-ports", 0, 0);
+
+            await context.CloseAsync();
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }
 }

+ 2 - 1
Tests.E2e/Infra/PlaywrightFixture.cs

@@ -34,8 +34,9 @@ public class PlaywrightFixture : IAsyncLifetime {
 
 
         Browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions {
         Browser = await _playwright.Chromium.LaunchAsync(new BrowserTypeLaunchOptions {
             Headless = true,
             Headless = true,
+            SlowMo = 400,
             //Headless = false,
             //Headless = false,
-            SlowMo = 500,
+            //SlowMo = 1500,
             Args = new[]
             Args = new[]
             {
             {
                 "--disable-dev-shm-usage",
                 "--disable-dev-shm-usage",

+ 35 - 0
Tests.E2e/PageObjectModels/AccessPointCardPom.cs

@@ -6,7 +6,9 @@ namespace Tests.E2e.PageObjectModels;
 public class AccessPointCardPom(IPage page) {
 public class AccessPointCardPom(IPage page) {
     public TagsPom Tags => new(page);
     public TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
     public LabelsPom Labels => new(page);
+    public PortsPom Ports => new(page);
 
 
+    private const string _portsPrefix = "accesspoint-ports";
     // Modals
     // Modals
     public ILocator DeleteConfirmModal => page.GetByTestId("AccessPoint-confirm-modal");
     public ILocator DeleteConfirmModal => page.GetByTestId("AccessPoint-confirm-modal");
     public ILocator DeleteConfirmButton => page.GetByTestId("AccessPoint-confirm-modal-confirm");
     public ILocator DeleteConfirmButton => page.GetByTestId("AccessPoint-confirm-modal-confirm");
@@ -153,4 +155,37 @@ public class AccessPointCardPom(IPage page) {
 
 
     private static string Sanitize(string value)
     private static string Sanitize(string value)
         => value.Replace(" ", "-");
         => value.Replace(" ", "-");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public async Task AddPortGroupAsync(
+        string type,
+        string speed,
+        int count) {
+        await Ports.AddPortGroupAsync(
+            "accesspoint-ports",
+            type,
+            speed,
+            count);
+    }
+    public async Task OpenConnectionFromPortAsync(int groupIndex, int portIndex) => await Ports.OpenConnectionFromPortAsync(_portsPrefix, groupIndex, portIndex);
+
+    public async Task CreateConnectionAsync(
+        string resourceA,
+        string groupA,
+        string portA,
+        string resourceB,
+        string groupB,
+        string portB) {
+        await Ports.CreateConnectionAsync(
+            _portsPrefix,
+            resourceA,
+            groupA,
+            portA,
+            resourceB,
+            groupB,
+            portB);
+    }
 }
 }

+ 166 - 0
Tests.E2e/PageObjectModels/PortsPom.cs

@@ -0,0 +1,166 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class PortsPom(IPage page) {
+    public TagsPom Tags => new(page);
+    public LabelsPom Labels => new(page);
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "accesspoint-ports";
+
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Root(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-section");
+
+    public ILocator AddButton(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-add");
+
+    // -------------------------------------------------
+    // Port Groups
+    // -------------------------------------------------
+
+    public ILocator PortGroup(string testIdPrefix, int index)
+        => page.GetByTestId($"{testIdPrefix}-port-group-item-{index}");
+
+    public ILocator EditPortGroupButton(string testIdPrefix, int index)
+        => page.GetByTestId($"{testIdPrefix}-port-group-edit-{index}");
+
+    public ILocator PortsContainer(string testIdPrefix, int index)
+        => page.GetByTestId($"{testIdPrefix}-port-group-ports-{index}");
+
+    // -------------------------------------------------
+    // Individual Ports
+    // -------------------------------------------------
+
+    public ILocator Port(string testIdPrefix, int groupIndex, int portIndex)
+        => page.GetByTestId($"{testIdPrefix}-port-group-visualizer-{groupIndex}-port-{portIndex}");
+
+    // -------------------------------------------------
+    // Port Modal
+    // -------------------------------------------------
+
+    public ILocator PortModal(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-port-modal");
+
+    // -------------------------------------------------
+    // Connection Modal
+    // -------------------------------------------------
+
+    public ILocator ConnectionModal(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-container");
+
+    public ILocator ResourceASelect(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-resource-a");
+
+    public ILocator GroupASelect(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-group-a");
+
+    public ILocator PortASelect(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-port-a");
+
+    public ILocator ResourceBSelect(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-resource-b");
+
+    public ILocator GroupBSelect(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-group-b");
+
+    public ILocator PortBSelect(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-port-b");
+
+    public ILocator SubmitConnection(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-submit");
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertPortGroupVisibleAsync(string prefix, int index)
+        => await Assertions.Expect(PortGroup(prefix, index)).ToBeVisibleAsync();
+
+    public async Task AssertPortVisibleAsync(string prefix, int groupIndex, int portIndex)
+        => await Assertions.Expect(Port(prefix, groupIndex, portIndex)).ToBeVisibleAsync();
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddPortGroupAsync(string prefix) {
+        await AddButton(prefix).ClickAsync();
+        await Assertions.Expect(PortModal(prefix)).ToBeVisibleAsync();
+    }
+
+    public async Task OpenConnectionFromPortAsync(string prefix, int groupIndex, int portIndex) {
+        await Port(prefix, groupIndex, portIndex).ClickAsync();
+        await Assertions.Expect(ConnectionModal(prefix)).ToBeVisibleAsync();
+    }
+
+    public async Task CreateConnectionAsync(
+        string prefix,
+        string resourceA,
+        string groupA,
+        string portA,
+        string resourceB,
+        string groupB,
+        string portB) {
+        await ResourceASelect(prefix).SelectOptionAsync(
+            new SelectOptionValue { Label = resourceA });
+
+        await GroupASelect(prefix).SelectOptionAsync(
+            new SelectOptionValue { Label = groupA });
+
+        await PortASelect(prefix).SelectOptionAsync(
+            new SelectOptionValue { Label = portA });
+
+        await ResourceBSelect(prefix).SelectOptionAsync(
+            new SelectOptionValue { Label = resourceB });
+
+        await GroupBSelect(prefix).SelectOptionAsync(
+            new SelectOptionValue { Label = groupB });
+
+        await PortBSelect(prefix).SelectOptionAsync(
+            new SelectOptionValue { Label = portB });
+
+        await SubmitConnection(prefix).ClickAsync();
+    }
+
+    // -------------------------------------------------
+    // Port Modal Fields
+    // -------------------------------------------------
+
+    public ILocator PortTypeSelect(string prefix)
+        => page.GetByTestId($"{prefix}-port-group-port-modal-type-input");
+
+    public ILocator PortSpeedSelect(string prefix)
+        => page.GetByTestId($"{prefix}-port-group-port-modal-speed-input");
+
+    public ILocator PortCountInput(string prefix)
+        => page.GetByTestId($"{prefix}-port-group-port-modal-count-input");
+    public ILocator PortSubmit(string prefix)
+        => page.GetByTestId($"{prefix}-port-group-port-modal-submit");
+
+    public ILocator PortCancel(string prefix)
+        => page.GetByTestId($"{prefix}-port-group-port-modal-cancel");
+
+    public async Task AddPortGroupAsync(
+        string prefix,
+        string type,
+        string speed,
+        int count) {
+        await AddButton(prefix).ClickAsync();
+
+        await Assertions.Expect(PortModal(prefix)).ToBeVisibleAsync();
+
+        await PortTypeSelect(prefix).SelectOptionAsync(
+            new SelectOptionValue { Label = type });
+
+        await PortSpeedSelect(prefix).FillAsync(speed.ToString());
+
+        await PortCountInput(prefix).FillAsync(count.ToString());
+
+        await PortSubmit(prefix).ClickAsync();
+    }
+}

+ 3 - 3
Tests/Api/InventoryEndpointTests.cs

@@ -94,7 +94,7 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
             new { Yaml = initial });
             new { Yaml = initial });
 
 
         var update = """
         var update = """
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Server
                      - kind: Server
                        name: server-update
                        name: server-update
@@ -264,7 +264,7 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
         await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
         await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
 
 
         var update = """
         var update = """
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                        - kind: Server
                        - kind: Server
                          name: nested-test
                          name: nested-test
@@ -355,7 +355,7 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
         await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
         await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
 
 
         var update = """
         var update = """
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                        - kind: Firewall
                        - kind: Firewall
                          name: polymorph-test
                          name: polymorph-test

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

@@ -35,12 +35,13 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap01' updated.\n", output);
         Assert.Equal("Access Point 'ap01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: AccessPoint
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
                        model: Unifi-U6-Lite
                        speed: 1
                        speed: 1
                        name: ap01
                        name: ap01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 
@@ -55,7 +56,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap02' updated.\n", output);
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: AccessPoint
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
                        model: Unifi-U6-Lite
@@ -65,6 +66,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
                        model: Aruba-AP-515
                        model: Aruba-AP-515
                        speed: 2.5
                        speed: 2.5
                        name: ap02
                        name: ap02
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 

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

@@ -0,0 +1,128 @@
+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, var _) = await ExecuteAsync(
+            "connections", "remove",
+            "sw01", "0", "0"
+        );
+
+        Assert.Contains("Connection removed", output);
+    }
+}

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

@@ -0,0 +1,140 @@
+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);
+    }
+}

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

@@ -0,0 +1,168 @@
+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

@@ -41,13 +41,14 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw01' updated.\n", output);
         Assert.Equal("Firewall 'fw01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Firewall
                      - kind: Firewall
                        model: Fortinet FG-60F
                        model: Fortinet FG-60F
                        managed: true
                        managed: true
                        poe: false
                        poe: false
                        name: fw01
                        name: fw01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 
@@ -64,7 +65,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw02' updated.\n", output);
         Assert.Equal("Firewall 'fw02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Firewall
                      - kind: Firewall
                        model: Fortinet FG-60F
                        model: Fortinet FG-60F
@@ -76,6 +77,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
                        managed: false
                        managed: false
                        poe: false
                        poe: false
                        name: fw02
                        name: fw02
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 

+ 4 - 2
Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs

@@ -41,13 +41,14 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt01' updated.\n", output);
         Assert.Equal("Router 'rt01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Router
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
                        model: Ubiquiti EdgeRouter 4
                        managed: true
                        managed: true
                        poe: false
                        poe: false
                        name: rt01
                        name: rt01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 
@@ -64,7 +65,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt02' updated.\n", output);
         Assert.Equal("Router 'rt02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Router
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
                        model: Ubiquiti EdgeRouter 4
@@ -76,6 +77,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: false
                        managed: false
                        poe: false
                        poe: false
                        name: rt02
                        name: rt02
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 

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

@@ -40,7 +40,7 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Server 'srv01' updated.\n", output);
         Assert.Equal("Server 'srv01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Server
                      - kind: Server
                        ram:
                        ram:
@@ -48,6 +48,7 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                          mts: 3200
                          mts: 3200
                        ipmi: true
                        ipmi: true
                        name: srv01
                        name: srv01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 

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

@@ -46,7 +46,7 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
         outputHelper.WriteLine(yaml);
         outputHelper.WriteLine(yaml);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: System
                      - kind: System
                        name: sys01
                        name: sys01
@@ -59,6 +59,7 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
                        name: svc01
                        name: svc01
                        runsOn:
                        runsOn:
                        - sys01
                        - sys01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 

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

@@ -42,13 +42,14 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw01' updated.\n", output);
         Assert.Equal("Switch 'sw01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Switch
                      - kind: Switch
                        model: Netgear GS108
                        model: Netgear GS108
                        managed: true
                        managed: true
                        poe: true
                        poe: true
                        name: sw01
                        name: sw01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 
@@ -66,7 +67,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw02' updated.\n", output);
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Switch
                      - kind: Switch
                        model: Netgear GS108
                        model: Netgear GS108
@@ -78,6 +79,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: false
                        managed: false
                        poe: false
                        poe: false
                        name: sw02
                        name: sw02
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 

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

@@ -46,7 +46,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         outputHelper.WriteLine(yaml);
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Server
                      - kind: Server
                        name: proxmox-node01
                        name: proxmox-node01
@@ -58,6 +58,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        name: sys01
                        name: sys01
                        runsOn:
                        runsOn:
                        - proxmox-node01
                        - proxmox-node01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 
@@ -157,7 +158,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         // Assert resulting YAML
         // Assert resulting YAML
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Server
                      - kind: Server
                        name: proxmox-node01
                        name: proxmox-node01
@@ -179,6 +180,7 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        runsOn:
                        runsOn:
                        - proxmox-node01
                        - proxmox-node01
                        - sys01
                        - sys01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
     }
     }

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

@@ -37,12 +37,13 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups01' updated.\n", output);
         Assert.Equal("UPS 'ups01' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Ups
                      - kind: Ups
                        model: APC-SmartUPS-1500
                        model: APC-SmartUPS-1500
                        va: 1500
                        va: 1500
                        name: ups01
                        name: ups01
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 
@@ -58,7 +59,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups02' updated.\n", output);
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
 
         Assert.Equal("""
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      resources:
                      - kind: Ups
                      - kind: Ups
                        model: APC-SmartUPS-1500
                        model: APC-SmartUPS-1500
@@ -68,6 +69,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
                        model: CyberPower-2200VA
                        model: CyberPower-2200VA
                        va: 2200
                        va: 2200
                        name: ups02
                        name: ups02
+                     connections: []
 
 
                      """, yaml);
                      """, yaml);
 
 

+ 34 - 0
Tests/TestConfigs/v3/01-server.yaml

@@ -0,0 +1,34 @@
+version: 3
+resources:
+  - kind: Server
+    name: example-server
+    tags:
+      - production
+      - compute
+    notes: Primary hypervisor host
+    runsOn:
+      - rack-a1
+      - rack-a2
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
+    cpus:
+      - model: AMD EPYC 7302P
+        cores: 16
+        threads: 32
+    drives:
+      - type: nvme
+        size: 1024
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 4000
+        vram: 16
+    ports:
+      - type: rj45
+        speed: 1
+        count: 2
+      - type: sfp+
+        speed: 10
+        count: 2

+ 17 - 0
Tests/TestConfigs/v3/02-firewall.yaml

@@ -0,0 +1,17 @@
+version: 3
+resources:
+  - kind: Firewall
+    name: example-firewall
+    model: Netgate-6100
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp+
+        speed: 10
+        count: 2
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 17 - 0
Tests/TestConfigs/v3/03-router.yaml

@@ -0,0 +1,17 @@
+version: 3
+resources:
+  - kind: Router
+    name: example-router
+    model: Ubiquiti-ER-4
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp
+        speed: 10
+        count: 1
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 17 - 0
Tests/TestConfigs/v3/04-switch.yaml

@@ -0,0 +1,17 @@
+version: 3
+resources:
+  - kind: Switch
+    name: example-switch
+    model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 12
+      - type: sfp+
+        speed: 10
+        count: 4
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 15 - 0
Tests/TestConfigs/v3/05-accesspoint.yaml

@@ -0,0 +1,15 @@
+version: 3
+resources:
+  - kind: AccessPoint
+    name: example-accesspoint
+    tags:
+      - wireless
+    model: UniFi-U6-Pro
+    speed: 2.5
+    runsOn:
+      - rack-a1
+      - rack-a2
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1

+ 11 - 0
Tests/TestConfigs/v3/06-ups.yaml

@@ -0,0 +1,11 @@
+version: 3
+resources:
+  - kind: Ups
+    name: example-ups
+    tags:
+      - power
+    model: APC-SmartUPS-2200
+    va: 2200
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 25 - 0
Tests/TestConfigs/v3/07-desktop.yaml

@@ -0,0 +1,25 @@
+version: 3
+resources:
+  - kind: Desktop
+    name: example-desktop
+    notes: Engineering workstation
+    ram:
+      size: 64
+      mts: 3600
+    cpus:
+      - model: Intel Core i9-13900K
+        cores: 24
+        threads: 32
+    drives:
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 4090
+        vram: 24
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 18 - 0
Tests/TestConfigs/v3/08-laptop.yaml

@@ -0,0 +1,18 @@
+version: 3
+resources:
+  - kind: Laptop
+    name: example-laptop
+    notes: Developer machine
+    ram:
+      size: 32
+      mts: 5200
+    cpus:
+      - model: Intel Core i7-1260P
+        cores: 12
+        threads: 16
+    drives:
+      - type: ssd
+        size: 1024
+    runsOn:
+      - rack-a1
+      - rack-a2

+ 12 - 0
Tests/TestConfigs/v3/09-service.yaml

@@ -0,0 +1,12 @@
+version: 3
+resources:
+  - kind: Service
+    name: example-service
+    runsOn:
+      - rack-a1
+      - rack-a2
+    network:
+      ip: 192.168.1.10
+      port: 8080
+      protocol: TCP
+      url: http://example.local:8080

+ 16 - 0
Tests/TestConfigs/v3/10-system.yaml

@@ -0,0 +1,16 @@
+version: 3
+resources:
+  - kind: System
+    name: example-system
+    notes: Virtual machine instance
+    runsOn:
+      - rack-a1
+      - rack-a2
+    type: VM
+    os: ubuntu-22.04
+    cores: 4
+    ram: 8
+    ip: 10.0.20.10
+    drives:
+      - size: 128
+      - size: 256

+ 522 - 0
Tests/TestConfigs/v3/11-demo-config.yaml

@@ -0,0 +1,522 @@
+version: 3
+resources:
+  - kind: Server
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
+    cpus:
+      - model: AMD EPYC 7302P
+        cores: 16
+        threads: 32
+    drives:
+      - type: ssd
+        size: 1024
+      - type: ssd
+        size: 1024
+    ports:
+      - type: rj45
+        speed: 1
+        count: 2
+      - type: sfp+
+        speed: 10
+        count: 2
+    name: proxmox-node01
+  - kind: Server
+    ram:
+      size: 96
+      mts: 2666
+    ipmi: true
+    cpus:
+      - model: Intel Xeon Silver 4210
+        cores: 10
+        threads: 20
+    drives:
+      - type: ssd
+        size: 1024
+      - type: hdd
+        size: 4096
+    ports:
+      - type: rj45
+        speed: 1
+        count: 2
+      - type: sfp+
+        speed: 10
+        count: 1
+    name: proxmox-node02
+  - kind: Server
+    ram:
+      size: 64
+      mts: 2666
+    ipmi: true
+    cpus:
+      - model: Intel Xeon E-2236
+        cores: 6
+        threads: 12
+    drives:
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+      - type: hdd
+        size: 8192
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+      - type: sfp+
+        speed: 10
+        count: 1
+    name: truenas-storage
+  - kind: Firewall
+    model: Netgate-6100
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp+
+        speed: 10
+        count: 2
+    name: pfsense-fw
+  - kind: Router
+    model: Ubiquiti-ER-4
+    managed: true
+    poe: false
+    ports:
+      - type: rj45
+        speed: 1
+        count: 4
+      - type: sfp
+        speed: 10
+        count: 1
+    name: core-router
+  - kind: Switch
+    model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 12
+      - type: rj45
+        speed: 2.5
+        count: 8
+      - type: sfp+
+        speed: 10
+        count: 4
+    name: core-switch
+  - kind: Switch
+    model: UniFi-USW-16-PoE
+    managed: true
+    poe: true
+    ports:
+      - type: rj45
+        speed: 1
+        count: 16
+      - type: sfp
+        speed: 1
+        count: 2
+    name: access-switch
+  - kind: AccessPoint
+    model: UniFi-U6-Pro
+    speed: 2.5
+    name: lounge-ap
+  - kind: Ups
+    model: APC-SmartUPS-2200
+    va: 2200
+    name: rack-ups
+  - kind: Desktop
+    ram:
+      size: 64
+      mts: 3600
+    cpus:
+      - model: AMD Ryzen 9 5900X
+        cores: 12
+        threads: 24
+    drives:
+      - type: ssd
+        size: 1024
+      - type: ssd
+        size: 2048
+    gpus:
+      - model: NVIDIA RTX 3080
+        vram: 10
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    name: workstation-linux
+  - kind: Desktop
+    ram:
+      size: 32
+      mts: 3200
+    cpus:
+      - model: Intel Core i7-12700K
+        cores: 12
+        threads: 20
+    drives:
+      - type: ssd
+        size: 1024
+    gpus:
+      - model: NVIDIA RTX 3070
+        vram: 8
+    ports:
+      - type: rj45
+        speed: 1
+        count: 1
+    name: gaming-pc
+  - kind: Laptop
+    ram:
+      size: 32
+      mts: 5200
+    cpus:
+      - model: Intel Core i7-1260P
+        cores: 12
+        threads: 16
+    drives:
+      - type: ssd
+        size: 1024
+    name: dev-laptop
+  - kind: Service
+    network:
+      ip: 192.168.0.10
+      port: 8123
+      protocol: TCP
+      url: http://homeassistant.lan:8123
+    name: home-assistant
+    runsOn:
+      - vm-home-assistant
+  - kind: Service
+    network:
+      ip: 192.168.0.20
+      port: 32400
+      protocol: TCP
+      url: http://plex.lan:32400
+    name: plex
+    runsOn:
+      - vm-media-server
+      - vm-home-assistant
+  - kind: Service
+    network:
+      ip: 192.168.0.21
+      port: 8096
+      protocol: TCP
+      url: http://jellyfin.lan:8096
+    name: jellyfin
+    runsOn:
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.22
+      port: 8080
+      protocol: TCP
+      url: http://immich.lan:8080
+    name: immich
+    runsOn:
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.30
+      port: 443
+      protocol: TCP
+      url: https://truenas.lan
+    name: truenas-webui
+    runsOn:
+      - truenas-core-os
+  - kind: Service
+    network:
+      ip: 192.168.0.31
+      port: 9000
+      protocol: TCP
+      url: http://minio.lan:9000
+    name: minio
+    runsOn:
+      - vm-media-server
+  - kind: Service
+    network:
+      ip: 192.168.0.40
+      port: 9090
+      protocol: TCP
+      url: http://prometheus.lan:9090
+    name: prometheus
+    runsOn:
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.41
+      port: 3000
+      protocol: TCP
+      url: http://grafana.lan:3000
+    name: grafana
+  - kind: Service
+    network:
+      ip: 192.168.0.42
+      port: 9093
+      protocol: TCP
+      url: http://alertmanager.lan:9093
+    name: alertmanager
+  - kind: Service
+    network:
+      ip: 192.168.0.50
+      port: 3001
+      protocol: TCP
+      url: http://git.lan:3001
+    name: gitea
+    runsOn:
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.51
+      port: 5000
+      protocol: TCP
+      url: http://registry.lan:5000
+    name: docker-registry
+    runsOn:
+      - vm-monitoring
+  - kind: Service
+    network:
+      ip: 192.168.0.52
+      port: 9000
+      protocol: TCP
+      url: http://portainer.lan:9000
+    name: portainer
+    runsOn:
+      - vm-monitoring
+      - vm-logging
+  - kind: Service
+    network:
+      ip: 192.168.0.53
+      port: 80
+      protocol: TCP
+      url: http://pihole.lan
+    name: pihole
+  - kind: Service
+    network:
+      ip: 192.168.0.1
+      port: 443
+      protocol: TCP
+      url: https://firewall.lan
+    name: firewall-webui
+    runsOn:
+      - firewall-os
+  - kind: Service
+    network:
+      ip: 192.168.0.254
+      port: 443
+      protocol: TCP
+      url: https://router.lan
+    name: router-webui
+    runsOn:
+      - router-os
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 16
+    ram: 128
+    ip: 10.0.20.10
+    drives:
+      - size: 1024
+      - size: 1024
+    name: proxmox-cluster-node01
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 10
+    ram: 96
+    drives:
+      - size: 1024
+      - size: 4096
+    name: proxmox-cluster-node02
+    runsOn:
+      - proxmox-node02
+  - kind: System
+    type: Baremetal
+    os: truenas
+    cores: 6
+    ram: 64
+    drives:
+      - size: 8192
+      - size: 8192
+      - size: 8192
+      - size: 8192
+    name: truenas-core-os
+    runsOn:
+      - truenas-storage
+  - kind: System
+    type: Baremetal
+    os: idrac
+    cores: 1
+    ram: 1
+    name: ipmi-proxmox-node01
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: Baremetal
+    os: ipmi
+    cores: 1
+    ram: 1
+    name: ipmi-proxmox-node02
+    runsOn:
+      - proxmox-node02
+  - kind: System
+    type: Baremetal
+    os: ipmi
+    cores: 1
+    ram: 1
+    name: ipmi-truenas-storage
+    runsOn:
+      - truenas-storage
+  - kind: System
+    type: Baremetal
+    os: pfsense
+    cores: 4
+    ram: 8
+    drives:
+      - size: 32
+    name: firewall-os
+    runsOn:
+      - pfsense-fw
+  - kind: System
+    type: Baremetal
+    os: edgeos
+    cores: 4
+    ram: 4
+    drives:
+      - size: 4
+    name: router-os
+    runsOn:
+      - core-router
+  - kind: System
+    type: Baremetal
+    os: unifi-os
+    cores: 2
+    ram: 2
+    drives:
+      - size: 8
+    name: unifi-core-switch-os
+    runsOn:
+      - core-switch
+  - kind: System
+    type: Baremetal
+    os: unifi-os
+    cores: 2
+    ram: 2
+    drives:
+      - size: 8
+    name: unifi-access-switch-os
+    runsOn:
+      - access-switch
+  - kind: System
+    type: Baremetal
+    os: unifi-firmware
+    cores: 2
+    ram: 1
+    drives:
+      - size: 4
+    name: unifi-lounge-ap-os
+    runsOn:
+      - lounge-ap
+  - kind: System
+    type: VM
+    os: hassos
+    cores: 2
+    ram: 4
+    drives:
+      - size: 64
+    name: vm-home-assistant
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: VM
+    os: ubuntu-22.04
+    cores: 4
+    ram: 8
+    drives:
+      - size: 500
+    name: vm-media-server
+    runsOn:
+      - proxmox-node02
+  - kind: System
+    type: VM
+    os: debian-12
+    cores: 2
+    ram: 4
+    drives:
+      - size: 64
+    name: vm-monitoring
+    runsOn:
+      - proxmox-node01
+  - kind: System
+    type: VM
+    os: test
+    cores: 1
+    ram: 1
+    name: test-system
+    runsOn:
+      - proxmox-node01
+  - kind: Service
+    name: test-service
+    network:
+      ip: 192.168.0.250
+      port: 8080
+      protocol: TCP
+    runsOn:
+      - test-system
+  - kind: Service
+    name: test-service-no-host
+    network:
+      ip: 192.168.0.251
+      port: 8080
+      protocol: TCP
+  - kind: Service
+    name: test-ha-service
+    network:
+      ip: 192.168.0.252
+      port: 8080
+      protocol: TCP
+    runsOn:
+      - test-system
+      - proxmox-cluster-node01
+  - kind: AccessPoint
+    name: lounge-ap
+    model: UniFi-U6-Pro
+    speed: 2.5
+    ports:
+      - type: rj45
+        speed: 2.5
+        count: 1
+connections:
+  - a:
+      resource: core-router
+      portGroup: 0
+      portIndex: 0
+    b:
+      resource: pfsense-fw
+      portGroup: 0
+      portIndex: 0
+
+  - a:
+      resource: pfsense-fw
+      portGroup: 1
+      portIndex: 0
+    b:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 0
+
+  - a:
+      resource: core-switch
+      portGroup: 2
+      portIndex: 1
+    b:
+      resource: access-switch
+      portGroup: 1
+      portIndex: 0
+    label: router-firewall
+    notes: internal uplink

+ 33 - 0
Tests/Tests.csproj

@@ -38,5 +38,38 @@
     <ItemGroup>
     <ItemGroup>
         <None Include="schemas\**\*.json" CopyToOutputDirectory="PreserveNewest"/>
         <None Include="schemas\**\*.json" CopyToOutputDirectory="PreserveNewest"/>
         <None Include="TestConfigs\**\*.yaml" CopyToOutputDirectory="PreserveNewest"/>
         <None Include="TestConfigs\**\*.yaml" CopyToOutputDirectory="PreserveNewest"/>
+        <None Update="TestConfigs\v3\01-server.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\02-firewall.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\03-router.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\04-switch.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\05-accesspoint.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\06-ups.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\07-desktop.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\08-laptop.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\09-service.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\10-system.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
+        <None Update="TestConfigs\v3\11-demo-config.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
     </ItemGroup>
     </ItemGroup>
 </Project>
 </Project>

+ 1 - 0
Tests/Yaml/SchemaTests.cs

@@ -64,6 +64,7 @@ public class SchemaConformanceTests {
     [Theory]
     [Theory]
     [InlineData(1)]
     [InlineData(1)]
     [InlineData(2)]
     [InlineData(2)]
+    [InlineData(3)]
     public void All_yaml_files_conform_to_schema(int version) {
     public void All_yaml_files_conform_to_schema(int version) {
         // Arrange
         // Arrange
         JsonSchema schema = LoadSchema(version);
         JsonSchema schema = LoadSchema(version);

+ 667 - 0
Tests/schemas/schema.v3.json

@@ -0,0 +1,667 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v3/schema.v3.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": [
+    "version",
+    "resources"
+  ],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 3
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
+    }
+  },
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": {
+        "type": "string"
+      }
+    },
+    "runsOn": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+    "resourceBase": {
+      "type": "object",
+      "required": [
+        "kind",
+        "name"
+      ],
+      "properties": {
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "runsOn": {
+          "$ref": "#/$defs/runsOn"
+        }
+      }
+    },
+    "resource": {
+      "oneOf": [
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
+      ]
+    },
+    "portReference": {
+      "type": "object",
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "connection": {
+      "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
+      }
+    },
+    "ram": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": [
+        "size"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
+        },
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
+      }
+    },
+    "server": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "desktop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "laptop": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "firewall": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "router": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "switch": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "ports"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "accessPoint": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "ups": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "service": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "network"
+          ],
+          "properties": {
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "system": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "required": [
+            "type",
+            "os",
+            "cores",
+            "ram"
+          ],
+          "properties": {
+            "kind": {
+              "const": "System"
+            },
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
+              ]
+            },
+            "ip": {
+              "type": "string"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}

+ 336 - 0
schemas/v3/schema.v3.json

@@ -0,0 +1,336 @@
+{
+  "$schema": "https://json-schema.org/draft/2020-12/schema",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v2/schema.v2.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": { "type": "integer", "const": 2 },
+    "resources": {
+      "type": "array",
+      "items": { "$ref": "#/$defs/resource" }
+    }
+  },
+
+  "$defs": {
+    "labels": {
+      "type": "object",
+      "additionalProperties": { "type": "string" }
+    },
+
+    "runsOn": {
+      "type": ["array", "null"],
+      "items": {
+        "type": "string",
+        "minLength": 1
+      }
+    },
+
+    "resourceBase": {
+      "type": "object",
+      "required": ["kind", "name"],
+      "properties": {
+        "kind": { "type": "string" },
+        "name": { "type": "string", "minLength": 1 },
+
+        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
+        "labels": { "$ref": "#/$defs/labels", "default": {} },
+        "notes": { "type": ["string", "null"] },
+
+        "runsOn": {
+          "type": ["array", "null"],
+          "items": { "type": "string" }
+        }
+      }
+    },
+
+    "resource": {
+      "oneOf": [
+        { "$ref": "#/$defs/server" },
+        { "$ref": "#/$defs/firewall" },
+        { "$ref": "#/$defs/router" },
+        { "$ref": "#/$defs/switch" },
+        { "$ref": "#/$defs/accessPoint" },
+        { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/desktop" },
+        { "$ref": "#/$defs/laptop" },
+        { "$ref": "#/$defs/service" },
+        { "$ref": "#/$defs/system" }
+      ]
+    },
+
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
+            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
+            "osfp", "xfp", "cx4", "mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "port": {
+      "type": "object",
+      "required": ["type", "speed", "count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "network": {
+      "type": "object",
+      "required": ["ip", "port", "protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
+        "url": { "type": "string", "format": "uri" }
+      }
+    },
+
+    "server": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Server" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "ipmi": { "type": "boolean" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "desktop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Desktop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "laptop": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Laptop" },
+
+            "ram": { "$ref": "#/$defs/ram" },
+            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "firewall": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Firewall" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "router": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Router" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "switch": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["ports"],
+          "properties": {
+            "kind": { "const": "Switch" },
+
+            "model": { "type": "string" },
+            "managed": { "type": "boolean" },
+            "poe": { "type": "boolean" },
+            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "accessPoint": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "AccessPoint" },
+
+            "model": { "type": "string" },
+            "speed": { "type": "number", "minimum": 0 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "ups": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Ups" },
+
+            "model": { "type": "string" },
+            "va": { "type": "integer", "minimum": 1 }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "service": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["network"],
+          "properties": {
+            "kind": { "const": "Service" },
+            "network": { "$ref": "#/$defs/network" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
+    "system": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "required": ["type", "os", "cores", "ram"],
+          "properties": {
+            "kind": { "const": "System" },
+
+            "type": {
+              "type": "string",
+              "enum": [
+                "baremetal", "Baremetal",
+                "hypervisor", "Hypervisor",
+                "vm", "VM",
+                "container", "embedded", "cloud", "other"
+              ]
+            },
+            "os": { "type": "string" },
+            "cores": { "type": "integer", "minimum": 1 },
+            "ram": { "type": "number", "minimum": 0 },
+            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    }
+  }
+}