Ver Fonte

Merge pull request #255 from Timmoth/staging

v1.3.0 release
Tim Jones há 3 semanas atrás
pai
commit
3a64240da5
100 ficheiros alterados com 5078 adições e 1252 exclusões
  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. 2 22
      README.md
  5. 4 12
      RackPeek.Domain/Api/UpsertInventoryUseCase.cs
  6. 10 0
      RackPeek.Domain/Git/GitStatus.cs
  7. 19 0
      RackPeek.Domain/Git/IGitRepository.cs
  8. 337 0
      RackPeek.Domain/Git/LibGit2GitRepository.cs
  9. 19 0
      RackPeek.Domain/Git/NullGitRepository.cs
  10. 36 0
      RackPeek.Domain/Git/UseCases/AddRemoteUseCase.cs
  11. 21 0
      RackPeek.Domain/Git/UseCases/CommitAllUseCase.cs
  12. 16 0
      RackPeek.Domain/Git/UseCases/InitRepoUseCase.cs
  13. 20 0
      RackPeek.Domain/Git/UseCases/PullUseCase.cs
  14. 27 0
      RackPeek.Domain/Git/UseCases/PushUseCase.cs
  15. 16 0
      RackPeek.Domain/Git/UseCases/RestoreAllUseCase.cs
  16. 9 0
      RackPeek.Domain/Persistence/IResourceCollection.cs
  17. 56 1
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  18. 98 6
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  19. 6 4
      RackPeek.Domain/RackPeek.Domain.csproj
  20. 5 1
      RackPeek.Domain/Resources/AccessPoints/AccessPoint.cs
  21. 74 0
      RackPeek.Domain/Resources/Connections/AddConnectionUseCase.cs
  22. 17 0
      RackPeek.Domain/Resources/Connections/Connection.cs
  23. 11 0
      RackPeek.Domain/Resources/Connections/ConnectionHelpers.cs
  24. 19 0
      RackPeek.Domain/Resources/Connections/GetConnectionForPortUseCase.cs
  25. 19 0
      RackPeek.Domain/Resources/Connections/GetConnectionsForResourceUseCase.cs
  26. 19 0
      RackPeek.Domain/Resources/Connections/RemoveConnectionUseCase.cs
  27. 1 1
      RackPeek.Domain/Resources/Desktops/DescribeDesktopUseCase.cs
  28. 2 2
      RackPeek.Domain/Resources/Desktops/Desktop.cs
  29. 3 3
      RackPeek.Domain/Resources/Desktops/DesktopHardwareReport.cs
  30. 1 1
      RackPeek.Domain/Resources/Servers/DescribeServerUseCase.cs
  31. 0 7
      RackPeek.Domain/Resources/Servers/INicResource.cs
  32. 2 2
      RackPeek.Domain/Resources/Servers/Server.cs
  33. 6 6
      RackPeek.Domain/Resources/Servers/ServerHardwareReport.cs
  34. 3 1
      RackPeek.Domain/RpkConstants.cs
  35. 33 4
      RackPeek.Domain/ServiceCollectionExtensions.cs
  36. 6 0
      RackPeek.Domain/UseCases/Hosts/SshExportOptions.cs
  37. 0 46
      RackPeek.Domain/UseCases/Nics/AddNicUseCase.cs
  38. 0 29
      RackPeek.Domain/UseCases/Nics/RemoveNicUseCase.cs
  39. 0 50
      RackPeek.Domain/UseCases/Nics/UpdateNicUseCase.cs
  40. 72 4
      RackPeek.Domain/UseCases/Ports/RemovePortUseCase.cs
  41. 13 0
      RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs
  42. 1 1
      RackPeek.Domain/UseCases/SSH/SshConfigExportUseCase.cs
  43. 7 1
      RackPeek.Domain/UseCases/SSH/SshConfigGenerator.cs
  44. 1 1
      RackPeek.Domain/UseCases/SSH/SshExportOptions.cs
  45. 2 0
      RackPeek.Web.Viewer/Program.cs
  46. 253 419
      RackPeek.Web.Viewer/wwwroot/config/config.yaml
  47. 667 0
      RackPeek.Web.Viewer/wwwroot/schemas/v3/schema.v3.json
  48. 3 1
      RackPeek.Web/Program.cs
  49. 667 0
      RackPeek.Web/wwwroot/schemas/v3/schema.v3.json
  50. 1 1
      RackPeek/RackPeek.csproj
  51. 7 0
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  52. 11 0
      Shared.Rcl/CliBootstrap.cs
  53. 83 0
      Shared.Rcl/Commands/Connections/ConnectionAddCommand.cs
  54. 50 0
      Shared.Rcl/Commands/Connections/ConnectionRemoveCommand.cs
  55. 1 1
      Shared.Rcl/Commands/Desktops/DesktopGetCommand.cs
  56. 2 2
      Shared.Rcl/Commands/Desktops/Nics/DesktopNicAddCommand.cs
  57. 2 2
      Shared.Rcl/Commands/Desktops/Nics/DesktopNicRemoveCommand.cs
  58. 2 2
      Shared.Rcl/Commands/Desktops/Nics/DesktopNicSetCommand.cs
  59. 1 1
      Shared.Rcl/Commands/Exporters/GenerateSshConfigCommand.cs
  60. 2 2
      Shared.Rcl/Commands/Servers/Nics/ServerNicAddCommand.cs
  61. 2 2
      Shared.Rcl/Commands/Servers/Nics/ServerNicRemoveCommand.cs
  62. 2 2
      Shared.Rcl/Commands/Servers/Nics/ServerNicUpdateCommand.cs
  63. 1 1
      Shared.Rcl/Components/CrumbLevel.razor
  64. 2 2
      Shared.Rcl/Components/ResourceBreadCrumbComponent.razor
  65. 1 1
      Shared.Rcl/Components/SshExport.razor
  66. 30 0
      Shared.Rcl/Connections/ConnectionsPage.razor
  67. 409 0
      Shared.Rcl/Connections/PortConnectionModal.razor
  68. 119 0
      Shared.Rcl/Connections/PortGroupVisualizer.razor
  69. 180 0
      Shared.Rcl/Connections/PortLayout.razor
  70. 2 1
      Shared.Rcl/ConsoleRunner.cs
  71. 5 94
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  72. 5 97
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  73. 70 7
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  74. 2 0
      Shared.Rcl/Hardware/HardwareTreePage.razor
  75. 171 0
      Shared.Rcl/Hardware/PortGroupEditor.razor
  76. 353 0
      Shared.Rcl/Layout/GitStatusIndicator.razor
  77. 62 54
      Shared.Rcl/Layout/MainLayout.razor
  78. 5 97
      Shared.Rcl/Routers/RouterCardComponent.razor
  79. 9 94
      Shared.Rcl/Servers/ServerCardComponent.razor
  80. 0 4
      Shared.Rcl/Services/ServiceDetailsPage.razor
  81. 17 2
      Shared.Rcl/SubnetBrowser.razor
  82. 5 98
      Shared.Rcl/Switches/SwitchCardComponent.razor
  83. 0 4
      Shared.Rcl/Systems/SystemsDetailsPage.razor
  84. 1 4
      Shared.Rcl/Systems/SystemsListPage.razor
  85. 1 0
      Shared.Rcl/wwwroot/raw_docs/docs-index.json
  86. 44 0
      Shared.Rcl/wwwroot/raw_docs/git-integration.md
  87. 98 0
      Tests.E2e/AccessPointCardTests.cs
  88. 2 1
      Tests.E2e/Infra/PlaywrightFixture.cs
  89. 35 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  90. 166 0
      Tests.E2e/PageObjectModels/PortsPom.cs
  91. 3 3
      Tests/Api/InventoryEndpointTests.cs
  92. 4 2
      Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs
  93. 128 0
      Tests/EndToEnd/ConnectionTests/ConnectionRemoveWorkflowTests.cs
  94. 140 0
      Tests/EndToEnd/ConnectionTests/ConnectionWorkflowTests.cs
  95. 168 0
      Tests/EndToEnd/ConnectionTests/PortConnectionWorkflowTests.cs
  96. 4 2
      Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs
  97. 4 2
      Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs
  98. 2 1
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  99. 2 1
      Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs
  100. 4 2
      Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs

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

@@ -2,7 +2,7 @@ name: Docker Nightly Publish (amd64)
 
 on:
   push:
-    branches: [ main ]
+    branches: [ staging ]
   workflow_dispatch:
 
 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:
   pull_request:
   workflow_dispatch:
 
 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:
       - name: Checkout
         uses: actions/checkout@v4
@@ -24,7 +72,7 @@ jobs:
           restore-keys: |
             ${{ runner.os }}-nuget-
 
-      - name: Cache Playwright browsers (Chromium)
+      - name: Cache Playwright browsers
         uses: actions/cache@v4
         with:
           path: ~/.cache/ms-playwright
@@ -38,8 +86,7 @@ jobs:
       - name: Build
         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
         run: |
           pwsh Tests.E2e/bin/Release/net*/playwright.ps1 install --with-deps chromium
@@ -52,4 +99,4 @@ jobs:
             .
 
       - name: Run E2E Tests
-        run: dotnet test Tests.E2e --configuration Release --verbosity normal
+        run: dotnet test Tests.E2e --configuration Release --verbosity normal

+ 2 - 22
README.md

@@ -1,28 +1,12 @@
 [![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png)
 
-![Version](https://img.shields.io/badge/Version-1.0.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
+![Version](https://img.shields.io/badge/Version-1.3.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
 [![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)
 
-```
-Announcing v1.0.0, officially out of beta.  
-
-Thanks to everyone who tried early versions, opened issues, suggested changes, or used it in their lab and shared feedback.  
-   
-Appreciate all the support.
-```
-
-RackPeek is a lightweight, opinionated CLI tool / webui for documenting and managing home lab and small-scale IT infrastructure.
+RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure.
 
 It helps you track hardware, services, networks, and their relationships in a clear, scriptable, and reusable way without enterprise bloat or proprietary lock-in or drowning in unnecessary metadata or process.
 
-## Roadmap
-- Proxmox config / auto system creation
-- docker-gen ingestion / auto service creation
-- Support for IoT and networked devices (amongst other new hardware types)
-- Enhanced networking and port mapping
-- Git integration (version-controlled, shared configuration)
-- Diagramming tools
-
 ### The roadmap for the next wave of features is actively being discussed, please make your voice heard! 
 
 [![DB Tech — Finally Document Your Home Lab the Easy Way (Docker Install)](https://img.shields.io/badge/DB%20Tech%20[video]-Finally%20Document%20Your%20Home%20Lab%20the%20Easy%20Way-blue?style=for-the-badge)](https://www.youtube.com/watch?v=RJtMO8kIsqU)
@@ -92,10 +76,6 @@ volumes:
   [**Versioning**](https://timmoth.github.io/RackPeek/docs/versioning)
 
 
-## Contribution Guide
-
-We are now gearing up for the full v1.0.0 release, so development focus is on stability / bug fixes / essential core missing features. Please raise any suggestions / bugs / feedback in the Github issues.
-
 ## Questionnaire
 
 We’re gathering feedback from homelabbers to validate direction and prioritize features.  

+ 4 - 12
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -1,9 +1,11 @@
+using System.Collections.Specialized;
 using System.ComponentModel.DataAnnotations;
 using System.Text.Json;
 using System.Text.Json.Serialization;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
 
@@ -49,24 +51,14 @@ public class UpsertInventoryUseCase(
                                rawJson,
                                _jsonOptions)
                            ?? throw new ValidationException("Invalid JSON structure.");
-            // Generate YAML only for persistence layer
-            ISerializer yamlSerializer = new SerializerBuilder()
-                .WithNamingConvention(CamelCaseNamingConvention.Instance)
-                .WithTypeConverter(new StorageSizeYamlConverter())
-                .WithTypeConverter(new NotesStringYamlConverter())
-                .ConfigureDefaultValuesHandling(
-                    DefaultValuesHandling.OmitNull |
-                    DefaultValuesHandling.OmitEmptyCollections)
-                .Build();
-
-            yamlInput = yamlSerializer.Serialize(incomingRoot);
+
+            yamlInput = YamlResourceCollection.SerializeRootAsync(incomingRoot);
         }
 
         if (incomingRoot.Resources == null)
             throw new ValidationException("Missing 'resources' section.");
 
         // 2️Compute Diff
-
         List<Resource>? incomingResources = incomingRoot.Resources;
         IReadOnlyList<Resource> currentResources = await repo.GetAllOfTypeAsync<Resource>();
 

+ 10 - 0
RackPeek.Domain/Git/GitStatus.cs

@@ -0,0 +1,10 @@
+namespace RackPeek.Domain.Git;
+
+public enum GitRepoStatus {
+    NotAvailable,
+    Clean,
+    Dirty
+}
+
+public record GitLogEntry(string Hash, string Message, string Author, string Date);
+public record GitSyncStatus(int Ahead, int Behind, bool HasRemote, string? Error = null);

+ 19 - 0
RackPeek.Domain/Git/IGitRepository.cs

@@ -0,0 +1,19 @@
+namespace RackPeek.Domain.Git;
+
+public interface IGitRepository {
+    bool IsAvailable { get; }
+    void Init();
+    GitRepoStatus GetStatus();
+    void StageAll();
+    void Commit(string message);
+    string GetDiff();
+    string[] GetChangedFiles();
+    void RestoreAll();
+    string GetCurrentBranch();
+    GitLogEntry[] GetLog(int count);
+    bool HasRemote();
+    GitSyncStatus FetchAndGetSyncStatus();
+    void Push();
+    void Pull();
+    void AddRemote(string name, string url);
+}

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

@@ -0,0 +1,337 @@
+using LibGit2Sharp;
+using LibGit2Sharp.Handlers;
+
+namespace RackPeek.Domain.Git;
+
+public interface IGitCredentialsProvider {
+    CredentialsHandler GetHandler();
+}
+
+public sealed class GitHubTokenCredentialsProvider(string username, string token) : IGitCredentialsProvider {
+    private readonly string _username = username ?? throw new ArgumentNullException(nameof(username));
+    private readonly string _token = token ?? throw new ArgumentNullException(nameof(token));
+
+    public CredentialsHandler GetHandler() {
+        return (_, _, _) => new UsernamePasswordCredentials {
+            Username = _username,
+            Password = _token
+        };
+    }
+}
+
+public sealed class LibGit2GitRepository(
+    string configDirectory,
+    IGitCredentialsProvider credentialsProvider) : IGitRepository {
+    private readonly CredentialsHandler _credentials = credentialsProvider.GetHandler();
+
+    private bool _isAvailable = Repository.IsValid(configDirectory);
+
+    public bool IsAvailable => _isAvailable;
+
+    public void Init() {
+        Repository.Init(configDirectory);
+
+        _isAvailable = true;
+    }
+
+    private Repository OpenRepo() => new(configDirectory);
+
+    private static Signature GetSignature(Repository repo) {
+        var name = repo.Config.Get<string>("user.name")?.Value ?? "RackPeek";
+        var email = repo.Config.Get<string>("user.email")?.Value ?? "rackpeek@local";
+
+        return new Signature(name, email, DateTimeOffset.Now);
+    }
+
+    private static Remote GetRemote(Repository repo)
+        => repo.Network.Remotes["origin"] ?? repo.Network.Remotes.First();
+
+    public GitRepoStatus GetStatus() {
+        if (!_isAvailable)
+            return GitRepoStatus.NotAvailable;
+
+        using Repository repo = OpenRepo();
+
+        return repo.RetrieveStatus().IsDirty
+            ? GitRepoStatus.Dirty
+            : GitRepoStatus.Clean;
+    }
+
+    public void StageAll() {
+        using Repository repo = OpenRepo();
+
+        var files = repo.RetrieveStatus()
+            .Where(e => e.State != FileStatus.Ignored)
+            .Select(e => e.FilePath)
+            .ToList();
+
+        if (files.Count == 0)
+            return;
+
+        Commands.Stage(repo, files);
+    }
+
+    public void Commit(string message) {
+        using Repository repo = OpenRepo();
+
+        Signature signature = GetSignature(repo);
+        repo.Commit(message, signature, signature);
+    }
+
+    public string GetDiff() {
+        using Repository repo = OpenRepo();
+
+        Tree? tree = repo.Head.Tip?.Tree;
+
+        Patch patch = repo.Diff.Compare<Patch>(
+            tree,
+            DiffTargets.Index | DiffTargets.WorkingDirectory);
+
+        return patch?.Content ?? string.Empty;
+    }
+    public string[] GetChangedFiles() {
+        using Repository repo = OpenRepo();
+
+        return repo.RetrieveStatus()
+            .Where(e => e.State != FileStatus.Ignored)
+            .Select(e => $"{GetPrefix(e.State)}  {e.FilePath}")
+            .ToArray();
+    }
+
+    private static string GetPrefix(FileStatus state) => state switch {
+        FileStatus.NewInWorkdir or FileStatus.NewInIndex => "A",
+        FileStatus.DeletedFromWorkdir or FileStatus.DeletedFromIndex => "D",
+        FileStatus.RenamedInWorkdir or FileStatus.RenamedInIndex => "R",
+        _ when state.HasFlag(FileStatus.ModifiedInWorkdir)
+          || state.HasFlag(FileStatus.ModifiedInIndex) => "M",
+        _ => "?"
+    };
+
+    public void RestoreAll() {
+        using Repository repo = OpenRepo();
+
+        repo.CheckoutPaths(
+            repo.Head.FriendlyName,
+            ["*"],
+            new CheckoutOptions { CheckoutModifiers = CheckoutModifiers.Force });
+
+        repo.RemoveUntrackedFiles();
+    }
+
+    public string GetCurrentBranch() {
+        using Repository repo = OpenRepo();
+        return repo.Head.FriendlyName;
+    }
+
+    public GitLogEntry[] GetLog(int count) {
+        using Repository repo = OpenRepo();
+
+        if (repo.Head.Tip is null)
+            return [];
+
+        return repo.Commits
+            .Take(count)
+            .Select(c => new GitLogEntry(
+                c.Sha[..7],
+                c.MessageShort,
+                c.Author.Name,
+                FormatRelativeDate(c.Author.When)))
+            .ToArray();
+    }
+
+    public bool HasRemote() {
+        using Repository repo = OpenRepo();
+        return repo.Network.Remotes.Any();
+    }
+
+    public GitSyncStatus FetchAndGetSyncStatus() {
+        using Repository repo = OpenRepo();
+
+        if (!repo.Network.Remotes.Any())
+            return new GitSyncStatus(0, 0, false);
+
+        Remote remote = GetRemote(repo);
+
+        Commands.Fetch(
+            repo,
+            remote.Name,
+            remote.FetchRefSpecs.Select(r => r.Specification),
+            new FetchOptions { CredentialsProvider = _credentials },
+            null);
+
+        // If the repo has no commits yet (unborn branch)
+        if (repo.Head.Tip == null)
+            return new GitSyncStatus(0, 0, true);
+
+        Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
+
+        if (remoteBranch?.Tip == null)
+            return new GitSyncStatus(repo.Commits.Count(), 0, true);
+
+        HistoryDivergence? divergence = repo.ObjectDatabase.CalculateHistoryDivergence(
+            repo.Head.Tip,
+            remoteBranch.Tip);
+
+        return new GitSyncStatus(
+            divergence.AheadBy ?? 0,
+            divergence.BehindBy ?? 0,
+            true);
+    }
+    public void Push() {
+        using Repository repo = OpenRepo();
+
+        Remote remote = GetRemote(repo);
+        var branch = repo.Head.FriendlyName;
+        var refSpec = $"refs/heads/{branch}:refs/heads/{branch}";
+
+        try {
+            repo.Network.Push(
+                remote,
+                refSpec,
+                new PushOptions { CredentialsProvider = _credentials });
+        }
+        catch (NonFastForwardException) {
+            PullInternal(repo);
+
+            repo.Network.Push(
+                remote,
+                refSpec,
+                new PushOptions { CredentialsProvider = _credentials });
+        }
+
+        if (repo.Head.TrackedBranch is null) {
+            repo.Branches.Update(repo.Head,
+                b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branch}");
+        }
+    }
+
+    public void Pull() {
+        using Repository repo = OpenRepo();
+        PullInternal(repo);
+    }
+
+    private void PullInternal(Repository repo) {
+        if (!repo.Network.Remotes.Any())
+            return;
+
+        Remote remote = GetRemote(repo);
+
+        Commands.Fetch(
+            repo,
+            remote.Name,
+            remote.FetchRefSpecs.Select(r => r.Specification),
+            new FetchOptions { CredentialsProvider = _credentials },
+            null);
+
+        Branch? remoteBranch = repo.Branches[$"{remote.Name}/{repo.Head.FriendlyName}"];
+
+        if (remoteBranch?.Tip == null)
+            return;
+
+        // hard reset to remote branch
+        repo.Reset(ResetMode.Hard, remoteBranch.Tip);
+
+        repo.Branches.Update(repo.Head,
+            b => b.TrackedBranch = remoteBranch.CanonicalName);
+    }
+    public void AddRemote(string name, string url) {
+        using Repository repo = OpenRepo();
+
+        if (repo.Network.Remotes[name] != null)
+            return;
+
+        repo.Network.Remotes.Add(name, url);
+
+        Remote remote = repo.Network.Remotes[name];
+
+        // fetch remote state
+        Commands.Fetch(
+            repo,
+            remote.Name,
+            remote.FetchRefSpecs.Select(r => r.Specification),
+            new FetchOptions { CredentialsProvider = _credentials },
+            null);
+
+        // detect if remote has a default branch
+        Branch? remoteMain =
+            repo.Branches[$"{remote.Name}/main"] ??
+            repo.Branches[$"{remote.Name}/master"];
+
+        var hasLocalFiles =
+            repo.RetrieveStatus()
+                .Any(e => e.State != FileStatus.Ignored);
+
+        // CASE 1: remote repo already has commits
+        if (remoteMain != null && remoteMain.Tip != null) {
+            Branch local = repo.CreateBranch(remoteMain.FriendlyName, remoteMain.Tip);
+            Commands.Checkout(repo, local);
+
+            repo.Branches.Update(local,
+                b => b.TrackedBranch = remoteMain.CanonicalName);
+
+            if (hasLocalFiles) {
+                // import existing config to a new branch
+                var importBranchName = $"rackpeek-{DateTime.UtcNow:yyyyMMddHHmmss}";
+
+                Branch importBranch = repo.CreateBranch(importBranchName);
+                Commands.Checkout(repo, importBranch);
+
+                Commands.Stage(repo, "*");
+
+                Signature sig = GetSignature(repo);
+
+                repo.Commit(
+                    "rackpeek: import existing config",
+                    sig,
+                    sig);
+
+                repo.Network.Push(
+                    remote,
+                    $"refs/heads/{importBranchName}:refs/heads/{importBranchName}",
+                    new PushOptions { CredentialsProvider = _credentials });
+
+                repo.Branches.Update(importBranch,
+                    b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{importBranchName}");
+            }
+
+            return;
+        }
+
+        // CASE 2: remote repo is empty
+        if (hasLocalFiles) {
+            var branchName = "main";
+
+            Branch branch = repo.CreateBranch(branchName);
+            Commands.Checkout(repo, branch);
+
+            Commands.Stage(repo, "*");
+
+            Signature sig = GetSignature(repo);
+
+            repo.Commit(
+                "rackpeek: initial config",
+                sig,
+                sig);
+
+            repo.Network.Push(
+                remote,
+                $"refs/heads/{branchName}:refs/heads/{branchName}",
+                new PushOptions { CredentialsProvider = _credentials });
+
+            repo.Branches.Update(branch,
+                b => b.TrackedBranch = $"refs/remotes/{remote.Name}/{branchName}");
+        }
+    }
+
+    private static string FormatRelativeDate(DateTimeOffset date) {
+        TimeSpan diff = DateTimeOffset.Now - date;
+
+        if (diff.TotalMinutes < 1) return "just now";
+        if (diff.TotalMinutes < 60) return $"{(int)diff.TotalMinutes} minutes ago";
+        if (diff.TotalHours < 24) return $"{(int)diff.TotalHours} hours ago";
+        if (diff.TotalDays < 30) return $"{(int)diff.TotalDays} days ago";
+        if (diff.TotalDays < 365) return $"{(int)(diff.TotalDays / 30)} months ago";
+
+        return $"{(int)(diff.TotalDays / 365)} years ago";
+    }
+}

+ 19 - 0
RackPeek.Domain/Git/NullGitRepository.cs

@@ -0,0 +1,19 @@
+namespace RackPeek.Domain.Git;
+
+public sealed class NullGitRepository : IGitRepository {
+    public bool IsAvailable => false;
+    public void Init() { }
+    public GitRepoStatus GetStatus() => GitRepoStatus.NotAvailable;
+    public void StageAll() { }
+    public void Commit(string message) { }
+    public string GetDiff() => string.Empty;
+    public string[] GetChangedFiles() => [];
+    public void RestoreAll() { }
+    public string GetCurrentBranch() => string.Empty;
+    public GitLogEntry[] GetLog(int count) => [];
+    public bool HasRemote() => false;
+    public GitSyncStatus FetchAndGetSyncStatus() => new(0, 0, false);
+    public void Push() { }
+    public void Pull() { }
+    public void AddRemote(string name, string url) { }
+}

+ 36 - 0
RackPeek.Domain/Git/UseCases/AddRemoteUseCase.cs

@@ -0,0 +1,36 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class AddRemoteUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync(string url) {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (string.IsNullOrWhiteSpace(url))
+            return Task.FromResult<string?>("URL is required.");
+
+        url = url.Trim();
+
+        if (!url.StartsWith("https://", StringComparison.OrdinalIgnoreCase))
+            return Task.FromResult<string?>("Only HTTPS URLs are supported.");
+
+        if (repo.HasRemote())
+            return Task.FromResult<string?>("Remote already configured.");
+
+        try {
+            repo.AddRemote("origin", url);
+
+            // fetch remote state
+            GitSyncStatus sync = repo.FetchAndGetSyncStatus();
+
+            // if remote already has commits, bring them locally
+            if (sync.Behind > 0) {
+                repo.Pull();
+            }
+
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Add remote failed: {ex.Message}");
+        }
+    }
+}

+ 21 - 0
RackPeek.Domain/Git/UseCases/CommitAllUseCase.cs

@@ -0,0 +1,21 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class CommitAllUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync(string message) {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        try {
+            repo.StageAll();
+
+            if (repo.GetStatus() != GitRepoStatus.Dirty)
+                return Task.FromResult<string?>(null);
+
+            repo.Commit(message);
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Commit failed: {ex.Message}");
+        }
+    }
+}

+ 16 - 0
RackPeek.Domain/Git/UseCases/InitRepoUseCase.cs

@@ -0,0 +1,16 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class InitRepoUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (repo.IsAvailable)
+            return Task.FromResult<string?>(null);
+
+        try {
+            repo.Init();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Init failed: {ex.Message}");
+        }
+    }
+}

+ 20 - 0
RackPeek.Domain/Git/UseCases/PullUseCase.cs

@@ -0,0 +1,20 @@
+using RackPeek.Domain;
+using RackPeek.Domain.Git;
+
+public class PullUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (!repo.HasRemote())
+            return Task.FromResult<string?>("No remote configured.");
+
+        try {
+            repo.Pull();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Pull failed: {ex.Message}");
+        }
+    }
+}

+ 27 - 0
RackPeek.Domain/Git/UseCases/PushUseCase.cs

@@ -0,0 +1,27 @@
+using RackPeek.Domain;
+using RackPeek.Domain.Git;
+
+public class PushUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        if (!repo.HasRemote())
+            return Task.FromResult<string?>("No remote configured.");
+
+        try {
+            try {
+                repo.Push();
+            }
+            catch {
+                repo.Pull();
+                repo.Push();
+            }
+
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Push failed: {ex.Message}");
+        }
+    }
+}

+ 16 - 0
RackPeek.Domain/Git/UseCases/RestoreAllUseCase.cs

@@ -0,0 +1,16 @@
+namespace RackPeek.Domain.Git.UseCases;
+
+public class RestoreAllUseCase(IGitRepository repo) : IUseCase {
+    public Task<string?> ExecuteAsync() {
+        if (!repo.IsAvailable)
+            return Task.FromResult<string?>("Git is not available.");
+
+        try {
+            repo.RestoreAll();
+            return Task.FromResult<string?>(null);
+        }
+        catch (Exception ex) {
+            return Task.FromResult<string?>($"Restore failed: {ex.Message}");
+        }
+    }
+}

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

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

+ 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>>
         {
             EnsureSchemaVersionExists,
-            ConvertScalarRunsOnToList
+            ConvertScalarRunsOnToList,
+            ConvertNicsToPortsV3
         };
 
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
@@ -110,5 +111,59 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         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
 }

+ 98 - 6
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -3,6 +3,7 @@ using System.Collections.Specialized;
 using System.Diagnostics;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Hardware;
@@ -21,6 +22,7 @@ namespace RackPeek.Domain.Persistence.Yaml;
 public class ResourceCollection {
     public readonly SemaphoreSlim FileLock = new(1, 1);
     public List<Resource> Resources { get; } = new();
+    public List<Connection> Connections { get; } = new();
 }
 
 public sealed class YamlResourceCollection(
@@ -135,7 +137,8 @@ public sealed class YamlResourceCollection(
 
             var rootToSave = new YamlRoot {
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
-                Resources = resourceCollection.Resources
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
             };
 
             await SaveRootAsync(rootToSave);
@@ -191,6 +194,11 @@ public sealed class YamlResourceCollection(
 
         if (root.Resources != null)
             resourceCollection.Resources.AddRange(root.Resources);
+
+        resourceCollection.Connections.Clear();
+
+        if (root.Connections != null)
+            resourceCollection.Connections.AddRange(root.Connections);
     }
 
     public Task AddAsync(Resource resource) {
@@ -218,6 +226,55 @@ public sealed class YamlResourceCollection(
             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(
         SystemResource system,
         Dictionary<string, SystemResource> systemsByName,
@@ -279,7 +336,8 @@ public sealed class YamlResourceCollection(
             // Always write current schema version when app writes the file.
             var root = new YamlRoot {
                 Version = _currentSchemaVersion,
-                Resources = resourceCollection.Resources
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
             };
 
             await SaveRootAsync(root);
@@ -300,6 +358,11 @@ public sealed class YamlResourceCollection(
     }
 
     private async Task SaveRootAsync(YamlRoot? root) {
+        var contents = SerializeRootAsync(root);
+        await fileStore.WriteAllTextAsync(filePath, contents);
+    }
+
+    public static string SerializeRootAsync(YamlRoot? root) {
         ISerializer serializer = new SerializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithTypeConverter(new StorageSizeYamlConverter())
@@ -312,15 +375,18 @@ public sealed class YamlResourceCollection(
 
         // Preserve ordering: version first, then resources
         Debug.Assert(root != null, nameof(root) + " != null");
+
         var payload = new OrderedDictionary {
             ["version"] = root.Version,
-            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
+            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList(),
+            ["connections"] = root.Connections ?? new List<Connection>()
         };
 
-        await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
+        return serializer.Serialize(payload);
     }
 
-    private string GetKind(Resource resource) {
+
+    private static string GetKind(Resource resource) {
         return resource switch {
             Server => "Server",
             Switch => "Switch",
@@ -336,7 +402,7 @@ public sealed class YamlResourceCollection(
         };
     }
 
-    private OrderedDictionary SerializeResource(Resource resource) {
+    public static OrderedDictionary SerializeResource(Resource resource) {
         var map = new OrderedDictionary {
             ["kind"] = GetKind(resource)
         };
@@ -362,9 +428,35 @@ public sealed class YamlResourceCollection(
 
         return map;
     }
+
+    private static bool PortsMatch(PortReference a, PortReference b) {
+        return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase)
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+
+    private async Task UpdateConnectionsWithLockAsync(Action<List<Connection>> action) {
+        await resourceCollection.FileLock.WaitAsync();
+        try {
+            action(resourceCollection.Connections);
+
+            var root = new YamlRoot {
+                Version = _currentSchemaVersion,
+                Resources = resourceCollection.Resources,
+                Connections = resourceCollection.Connections
+            };
+
+            await SaveRootAsync(root);
+        }
+        finally {
+            resourceCollection.FileLock.Release();
+        }
+    }
 }
 
 public class YamlRoot {
     public int Version { get; set; }
     public List<Resource>? Resources { get; set; }
+
+    public List<Connection>? Connections { get; set; }
 }

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

@@ -7,10 +7,12 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="DocMigrator.Yaml" Version="10.0.3"/>
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3"/>
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3"/>
-        <PackageReference Include="YamlDotNet" Version="16.3.0"/>
+        <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
+        <PackageReference Include="LibGit2Sharp" Version="0.31.0" />
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.3" />
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.3" />
+        <PackageReference Include="YamlDotNet" Version="16.3.0" />
     </ItemGroup>
 
 </Project>

+ 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;
 
-public class AccessPoint : Hardware.Hardware {
+public class AccessPoint : Hardware.Hardware, IPortResource {
     public const string KindLabel = "AccessPoint";
     public string? Model { 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,
             ramSummary,
             desktop.Drives?.Count ?? 0,
-            desktop.Nics?.Count ?? 0,
+            desktop.Ports?.Count ?? 0,
             desktop.Gpus?.Count ?? 0,
             desktop.Labels
         );

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

@@ -3,12 +3,12 @@ using RackPeek.Domain.Resources.SubResources;
 
 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 Ram? Ram { get; set; }
     public string? Model { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { 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")
                 .Sum(d => d.Size) ?? 0;
 
-            var nicSummary = desktop.Nics == null
+            var nicSummary = desktop.Ports == null
                 ? "Unknown"
                 : string.Join(", ",
-                    desktop.Nics
+                    desktop.Ports
                         .GroupBy(n => n.Speed ?? 0)
                         .OrderBy(g => g.Key)
                         .Select(g => {
-                            var count = g.Sum(n => n.Ports ?? 0);
+                            var count = g.Sum(n => n.Count ?? 0);
                             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.Ram?.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
         );
     }

+ 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;
 
-public class Server : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, INicResource {
+public class Server : Hardware.Hardware, ICpuResource, IDriveResource, IGpuResource, IPortResource {
     public const string KindLabel = "Server";
     public Ram? Ram { get; set; }
     public bool? Ipmi { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { 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,
     string GpuSummary,
     bool Ipmi,
-    IReadOnlyList<Nic> Nics
+    IReadOnlyList<Port> Ports
 ) {
     public string NicSummary =>
         string.Join(", ",
-            (Nics ?? [])
+            (Ports ?? [])
             .SelectMany(n => {
-                var ports = n.Ports ?? 1;
+                var ports = n.Count ?? 1;
                 var speed = n.Speed ?? 0;
                 return Enumerable.Repeat(speed, ports);
             })
@@ -64,8 +64,8 @@ public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseC
                 .Where(d => d.Type == "hdd")
                 .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;
 
@@ -95,7 +95,7 @@ public class ServerHardwareReportUseCase(IResourceCollection repository) : IUseC
                 totalGpuVram,
                 gpuSummary,
                 server.Ipmi ?? false,
-                server.Nics ?? new List<Nic>()
+                server.Ports ?? new List<Port>()
             );
         }).ToList();
 

+ 3 - 1
RackPeek.Domain/RpkConstants.cs

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

+ 33 - 4
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -1,7 +1,10 @@
 using System.Reflection;
+using Microsoft.Extensions.Configuration;
 using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
@@ -10,7 +13,6 @@ using RackPeek.Domain.UseCases.Cpus;
 using RackPeek.Domain.UseCases.Drives;
 using RackPeek.Domain.UseCases.Gpus;
 using RackPeek.Domain.UseCases.Labels;
-using RackPeek.Domain.UseCases.Nics;
 using RackPeek.Domain.UseCases.Ports;
 using RackPeek.Domain.UseCases.Tags;
 
@@ -20,6 +22,31 @@ public interface IResourceUseCase<T> where T : Resource {
 }
 
 public static class ServiceCollectionExtensions {
+
+    public static IServiceCollection AddGitServices(
+        this IServiceCollection services,
+        IConfiguration config,
+        string? yamlPath = null) {
+        var gitToken = config["GIT_TOKEN"];
+        if (!string.IsNullOrEmpty(gitToken) && !string.IsNullOrWhiteSpace(yamlPath)) {
+            var gitUsername = config["GIT_USERNAME"] ?? "git";
+
+            services.AddSingleton<IGitCredentialsProvider>(
+                _ => new GitHubTokenCredentialsProvider(gitUsername, gitToken));
+
+            services.AddSingleton<IGitRepository>(sp => {
+                IGitCredentialsProvider creds = sp.GetRequiredService<IGitCredentialsProvider>();
+                return new LibGit2GitRepository(yamlPath, creds);
+            });
+            RpkConstants.HasGitServices = true;
+        }
+        else {
+            RpkConstants.HasGitServices = false;
+            services.AddSingleton<IGitRepository, NullGitRepository>();
+        }
+
+        return services;
+    }
     public static IServiceCollection AddResourceUseCases(
         this IServiceCollection services,
         Assembly assembly) {
@@ -71,9 +98,11 @@ public static class ServiceCollectionExtensions {
         services.AddScoped(typeof(IRemovePortUseCase<>), typeof(RemovePortUseCase<>));
         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))
             ?.GetTypes()

+ 6 - 0
RackPeek.Domain/UseCases/Hosts/SshExportOptions.cs

@@ -1,6 +1,7 @@
 using System.Text;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.UseCases.Hosts;
 
@@ -66,6 +67,11 @@ public static class HostsFileGenerator {
     }
 
     private static string? GetAddress(Resource r) {
+        if (r is SystemResource { Ip: not null } system &&
+            !string.IsNullOrWhiteSpace(system!.Ip)) {
+            return system.Ip;
+        }
+
         if (r.Labels.TryGetValue("ip", out var ip) && !string.IsNullOrWhiteSpace(ip))
             return ip;
 

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

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

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

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

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

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

@@ -1,7 +1,8 @@
 using System.Text;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.SystemResources;
 
-namespace RackPeek.Domain.UseCases.Ssh;
+namespace RackPeek.Domain.UseCases.SSH;
 
 public static class SshConfigGenerator {
     public static SshExportResult ToSshConfig(
@@ -58,6 +59,11 @@ public static class SshConfigGenerator {
     }
 
     private static string? GetAddress(Resource r) {
+        if (r is SystemResource { Ip: not null } system &&
+            !string.IsNullOrWhiteSpace(system!.Ip)) {
+            return system.Ip;
+        }
+
         if (r.Labels.TryGetValue("ip", out var ip) && !string.IsNullOrWhiteSpace(ip))
             return ip;
 

+ 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 {
     /// <summary>

+ 2 - 0
RackPeek.Web.Viewer/Program.cs

@@ -2,6 +2,7 @@ using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Components.Web;
 using Microsoft.AspNetCore.Components.WebAssembly.Hosting;
 using RackPeek.Domain;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl;
@@ -24,6 +25,7 @@ public class Program {
             };
         });
 
+        builder.Services.AddGitServices(builder.Configuration);
 
         builder.Services.AddScoped<ITextFileStore, WasmTextFileStore>();
 

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

@@ -1,513 +1,347 @@
+version: 3
 resources:
-  # ------------------------
-  # Servers
-  # ------------------------
   - kind: Server
-    name: proxmox-node01
+    ram:
+      size: 128
+      mts: 3200
+    ipmi: true
     cpus:
       - model: AMD EPYC 7302P
         cores: 16
         threads: 32
-    ram:
-      size: 128gb
-      mts: 3200
     drives:
       - type: ssd
-        size: 1tb
+        size: 1024
       - type: ssd
-        size: 1tb
-    nics:
+        size: 1024
+    ports:
       - type: rj45
-        speed: 1gb
-        ports: 2
+        speed: 1
+        count: 2
       - 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
-    name: proxmox-node02
+    ram:
+      size: 96
+      mts: 2666
+    ipmi: true
     cpus:
       - model: Intel Xeon Silver 4210
         cores: 10
         threads: 20
-    ram:
-      size: 96gb
-      mts: 2666
     drives:
       - type: ssd
-        size: 1tb
+        size: 1024
       - type: hdd
-        size: 4tb
-    nics:
+        size: 4096
+    ports:
       - type: rj45
-        speed: 1gb
-        ports: 2
+        speed: 1
+        count: 2
       - 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
-    name: truenas-storage
+    ram:
+      size: 64
+      mts: 2666
+    ipmi: true
     cpus:
       - model: Intel Xeon E-2236
         cores: 6
         threads: 12
-    ram:
-      size: 64gb
-      mts: 2666
     drives:
       - type: hdd
-        size: 8tb
+        size: 8192
       - type: hdd
-        size: 8tb
+        size: 8192
       - type: hdd
-        size: 8tb
+        size: 8192
       - type: hdd
-        size: 8tb
-    nics:
+        size: 8192
+    ports:
       - type: rj45
-        speed: 1gb
-        ports: 1
+        speed: 1
+        count: 1
       - 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
-    name: pfsense-fw
     model: Netgate-6100
+    managed: true
+    poe: false
     ports:
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 4
       - type: sfp+
-        speed: 10gb
+        speed: 10
         count: 2
-    managed: true
-    poe: false
-
+    name: pfsense-fw
+    tags:
+      - infra
+    labels:
+      install-date: 2022-03-10
   - kind: Router
-    name: core-router
     model: Ubiquiti-ER-4
+    managed: true
+    poe: false
     ports:
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 4
       - type: sfp
-        speed: 10gb
+        speed: 10
         count: 1
-    managed: true
-    poe: false
-
+    name: core-router
+    tags:
+      - infra
+    labels:
+      install-date: 2022-03-01
   - kind: Switch
-    name: core-switch
     model: UniFi-USW-Enterprise-24
+    managed: true
+    poe: true
     ports:
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 12
       - type: rj45
-        speed: 2.5gb
+        speed: 2.5
         count: 8
       - type: sfp+
-        speed: 10gb
+        speed: 10
         count: 4
-    managed: true
-    poe: true
-
+    name: core-switch
+    tags:
+      - infra
+    labels:
+      rack-unit: U15
   - kind: Switch
-    name: access-switch
     model: UniFi-USW-16-PoE
+    managed: true
+    poe: true
     ports:
       - type: rj45
-        speed: 1gb
+        speed: 1
         count: 16
       - type: sfp
-        speed: 1gb
+        speed: 1
         count: 2
-    managed: true
-    poe: true
-
+    name: access-switch
+    tags:
+      - infra
+    labels:
+      rack-unit: U16
   - kind: AccessPoint
-    name: lounge-ap
     model: UniFi-U6-Pro
-    speed: 2.5gb
-
-  # ------------------------
-  # Power
-  # ------------------------
-  - kind: Ups
-    name: rack-ups
-    model: APC-SmartUPS-2200
-    va: 2200
-
-  # ------------------------
-  # Desktops
-  # ------------------------
-  - kind: Desktop
-    name: workstation-linux
-    cpus:
-      - model: AMD Ryzen 9 5900X
-        cores: 12
-        threads: 24
-    ram:
-      size: 64gb
-      mts: 3600
-    drives:
-      - type: ssd
-        size: 1tb
-      - type: ssd
-        size: 2tb
-    nics:
-      - type: rj45
-        speed: 1gb
-        ports: 1
-    gpus:
-      - model: NVIDIA RTX 3080
-        vram: 10gb
-
-  - kind: Desktop
-    name: gaming-pc
-    cpus:
-      - model: Intel Core i7-12700K
-        cores: 12
-        threads: 20
-    ram:
-      size: 32gb
-      mts: 3200
-    drives:
-      - type: ssd
-        size: 1tb
-    nics:
+    speed: 2.5
+    ports:
       - 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
-    name: home-assistant
     network:
-      ip: 192.168.0.10
       port: 8123
       protocol: TCP
       url: http://homeassistant.lan:8123
-    runsOn: vm-home-assistant
-
-  # --------------------------------------------------
-  # Media & Photos
-  # --------------------------------------------------
+    name: home-assistant
+    labels:
+      service-at: home
+    runsOn:
+      - vm-home-assistant
   - kind: Service
-    name: plex
     network:
-      ip: 192.168.0.20
       port: 32400
       protocol: TCP
       url: http://plex.lan:32400
-    runsOn: vm-media-server
-
+    name: plex
+    tags:
+      - media
+    runsOn:
+      - vm-media-server
   - kind: Service
-    name: jellyfin
     network:
-      ip: 192.168.0.21
       port: 8096
       protocol: TCP
       url: http://jellyfin.lan:8096
-    runsOn: vm-media-server
-
+    name: jellyfin
+    tags:
+      - media
+    runsOn:
+      - vm-media-server
   - kind: Service
-    name: immich
     network:
-      ip: 192.168.0.22
       port: 8080
       protocol: TCP
       url: http://immich.lan:8080
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Storage & Backup
-  # --------------------------------------------------
-  - kind: Service
-    name: truenas-webui
-    network:
-      ip: 192.168.0.30
-      port: 443
-      protocol: TCP
-      url: https://truenas.lan
-    runsOn: truenas-core-os
-
-  - kind: Service
-    name: minio
-    network:
-      ip: 192.168.0.31
-      port: 9000
-      protocol: TCP
-      url: http://minio.lan:9000
-    runsOn: vm-media-server
-
-  # --------------------------------------------------
-  # Monitoring & Ops
-  # --------------------------------------------------
-  - kind: Service
-    name: prometheus
-    network:
-      ip: 192.168.0.40
-      port: 9090
-      protocol: TCP
-      url: http://prometheus.lan:9090
-    runsOn: vm-monitoring
-
+    name: immich
+    tags:
+      - media
+    runsOn:
+      - vm-media-server
   - kind: Service
-    name: grafana
     network:
-      ip: 192.168.0.41
       port: 3000
       protocol: TCP
       url: http://grafana.lan:3000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: alertmanager
-    network:
-      ip: 192.168.0.42
-      port: 9093
-      protocol: TCP
-      url: http://alertmanager.lan:9093
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Dev & Internal Tools
-  # --------------------------------------------------
-  - kind: Service
-    name: gitea
-    network:
-      ip: 192.168.0.50
-      port: 3001
-      protocol: TCP
-      url: http://git.lan:3001
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: docker-registry
-    network:
-      ip: 192.168.0.51
-      port: 5000
-      protocol: TCP
-      url: http://registry.lan:5000
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: portainer
-    network:
-      ip: 192.168.0.52
-      port: 9000
-      protocol: TCP
-      url: http://portainer.lan:9000
-    runsOn: vm-monitoring
-
-  # --------------------------------------------------
-  # Network Services
-  # --------------------------------------------------
-  - kind: Service
-    name: pihole
-    network:
-      ip: 192.168.0.53
-      port: 80
-      protocol: TCP
-      url: http://pihole.lan
-    runsOn: vm-monitoring
-
-  - kind: Service
-    name: firewall-webui
-    network:
-      ip: 192.168.0.1
-      port: 443
-      protocol: TCP
-      url: https://firewall.lan
-    runsOn: firewall-os
-
+    name: grafana
+    tags:
+      - containers
+    runsOn:
+      - ctr-grafana
   - kind: Service
-    name: router-webui
     network:
-      ip: 192.168.0.254
-      port: 443
+      port: 9090
       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
+    }
+  }
+}

+ 3 - 1
RackPeek.Web/Program.cs

@@ -2,6 +2,7 @@ using System.Text.Json.Serialization;
 using Microsoft.AspNetCore.Components;
 using Microsoft.AspNetCore.Hosting.StaticWebAssets;
 using RackPeek.Domain;
+using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Web.Api;
@@ -53,9 +54,10 @@ public class Program {
             };
         });
 
+        builder.Services.AddGitServices(builder.Configuration, yamlPath);
+
         var resources = new ResourceCollection();
         builder.Services.AddSingleton(resources);
-
         builder.Services.AddScoped<RackPeekConfigMigrationDeserializer>();
         builder.Services.AddScoped<IResourceYamlMigrationService, ResourceYamlMigrationService>();
 

+ 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>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
-        <AssemblyVersion>1.2.0</AssemblyVersion>
+        <AssemblyVersion>1.3.0</AssemblyVersion>
     </PropertyGroup>
 
     <ItemGroup>

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

@@ -1,4 +1,5 @@
 @using RackPeek.Domain.Resources.AccessPoints
+@using Shared.Rcl.Hardware
 @inject UpdateAccessPointUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<AccessPoint> DeleteUseCase
 @inject IRenameResourceUseCase<AccessPoint> RenameUseCase
@@ -104,6 +105,12 @@
             }
         </div>
 
+        <!-- NICs -->
+        <PortGroupEditor T="AccessPoint"
+                         Resource="AccessPoint"
+                         OnResourceChanged="r => AccessPoint = r"
+                         TestIdPrefix="accesspoint-ports"/>
+
         <ResourceTagEditor Resource="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.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
+using Shared.Rcl.Commands.Connections;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops.Cpus;
 using Shared.Rcl.Commands.Desktops.Drive;
@@ -577,6 +578,16 @@ public static class CliBootstrap {
                 hosts.AddCommand<GenerateHostsFileCommand>("export")
                     .WithDescription("Generate a /etc/hosts compatible file.");
             });
+
+            config.AddBranch("connections", connections => {
+                connections.SetDescription("Manage physical or logical port connections.");
+
+                connections.AddCommand<ConnectionAddCommand>("add")
+                    .WithDescription("Create a connection between two ports.");
+
+                connections.AddCommand<ConnectionRemoveCommand>("remove")
+                    .WithDescription("Remove the connection from a specific port.");
+            });
         });
     }
 

+ 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.Ram == null ? "None" : $"{d.Ram.Size}GB",
                 (d.Drives?.Count ?? 0).ToString(),
-                (d.Nics?.Count ?? 0).ToString(),
+                (d.Ports?.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 Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -32,7 +32,7 @@ public class DesktopNicAddCommand(IServiceProvider provider)
         DesktopNicAddSettings settings,
         CancellationToken cancellationToken) {
         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);
 

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

@@ -1,7 +1,7 @@
 using System.ComponentModel;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -24,7 +24,7 @@ public class DesktopNicRemoveCommand(IServiceProvider provider)
         DesktopNicRemoveSettings settings,
         CancellationToken cancellationToken) {
         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);
 

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

@@ -1,7 +1,7 @@
 using System.ComponentModel;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
-using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Ports;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -36,7 +36,7 @@ public class DesktopNicSetCommand(IServiceProvider provider)
         DesktopNicSetSettings settings,
         CancellationToken cancellationToken) {
         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);
 

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

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

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

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

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

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

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

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

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

@@ -41,5 +41,5 @@
 </div>
 
 @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>
 
 @code {
-    [Parameter][EditorRequired] public ResourceType ResourceType { get; set; }
+    [Parameter] [EditorRequired] public ResourceType ResourceType { get; set; }
 
     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();
 

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

@@ -1,5 +1,5 @@
 @page "/ssh/export"
-@using RackPeek.Domain.UseCases.Ssh
+@using RackPeek.Domain.UseCases.SSH
 @inject SshConfigExportUseCase SshUseCase
 
 <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 Spectre.Console;
 using Spectre.Console.Cli;
@@ -28,7 +29,7 @@ public class ConsoleEmulator : IConsoleEmulator {
 
     internal static string[] ParseArguments(string input) {
         var args = new List<string>();
-        var current = new System.Text.StringBuilder();
+        var current = new StringBuilder();
         char? quote = null;
 
         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.Drives
 @using RackPeek.Domain.UseCases.Gpus
-@using RackPeek.Domain.UseCases.Nics
+@using Shared.Rcl.Hardware
 @inject IGetResourceByNameUseCase<Desktop> GetByNameUseCase
 @inject UpdateDesktopUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<Desktop> DeleteUseCase
@@ -13,9 +13,6 @@
 @inject IAddDriveUseCase<Desktop> AddDriveUseCase
 @inject IUpdateDriveUseCase<Desktop> UpdateDriveUseCase
 @inject IRemoveDriveUseCase<Desktop> RemoveDriveUseCase
-@inject IAddNicUseCase<Desktop> AddNicUseCase
-@inject IUpdateNicUseCase<Desktop> UpdateNicUseCase
-@inject IRemoveNicUseCase<Desktop> RemoveNicUseCase
 @inject IAddGpuUseCase<Desktop> AddGpuUseCase
 @inject IUpdateGpuUseCase<Desktop> UpdateGpuUseCase
 @inject IRemoveGpuUseCase<Desktop> RemoveGpuUseCase
@@ -152,33 +149,10 @@
         </div>
 
         <!-- NICs -->
-        <div data-testid="desktop-nic-section">
-            <div class="flex items-center justify-between mb-1 group">
-                <div class="text-zinc-400">
-                    NICs
-                    <button data-testid="add-nic-button"
-                            class="hover:text-emerald-400 transition"
-                            @onclick="OpenAddNic">
-                        +
-                    </button>
-                </div>
-            </div>
-
-            @if (Desktop.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 -->
         <div data-testid="desktop-gpu-section">
@@ -297,15 +271,6 @@
           OnDelete="HandleGpuDelete"
           TestIdPrefix="desktop"/>
 
-
-<NicModal
-    IsOpen="@_nicModalOpen"
-    IsOpenChanged="v => _nicModalOpen = v"
-    Value="@_editingNic"
-    OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"
-    TestIdPrefix="desktop-nic"/>
-
 @code {
     [Parameter] [EditorRequired] public Desktop Desktop { get; set; } = default!;
 
@@ -421,60 +386,6 @@
 
     #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
 
     bool _gpuModalOpen;

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

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

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

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

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

@@ -9,6 +9,8 @@
     Hardware
 </h1>
 
+
+
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6"
      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;
+    }
+
+}

+ 353 - 0
Shared.Rcl/Layout/GitStatusIndicator.razor

@@ -0,0 +1,353 @@
+@using RackPeek.Domain.Git
+@using RackPeek.Domain.Git.UseCases
+@using RackPeek.Domain.Persistence
+@inject InitRepoUseCase InitRepo
+@inject CommitAllUseCase CommitAll
+@inject RestoreAllUseCase RestoreAll
+@inject PushUseCase PushUseCase
+@inject PullUseCase PullUseCase
+@inject AddRemoteUseCase AddRemoteUseCase
+@inject IGitRepository GitRepo
+@inject IResourceCollection Resources
+@implements IDisposable
+
+<div class="flex items-center gap-3 text-xs">
+
+    @if (_status == GitRepoStatus.Clean)
+    {
+        <span class="flex items-center gap-1 text-zinc-400">
+            <span class="w-2 h-2 rounded-full bg-emerald-400"></span>
+            Saved
+        </span>
+    }
+    else if (_status == GitRepoStatus.Dirty)
+    {
+        <span class="flex items-center gap-1 text-zinc-400">
+            <span class="w-2 h-2 rounded-full bg-amber-400 animate-pulse"></span>
+        </span>
+
+        <span class="text-zinc-600">·</span>
+
+        <button class="hover:text-emerald-400"
+                disabled="@_isBusy"
+                @onclick="CommitAsync">
+            @(_isCommitting ? "Saving…" : "Save")
+        </button>
+
+        <span class="text-zinc-600">·</span>
+
+        <button class="hover:text-red-400"
+                disabled="@_isBusy"
+                @onclick="DiscardAsync">
+            @(_isRestoring ? "Discarding…" : "Discard")
+        </button>
+    }
+
+    @if (!_hasRemote)
+    {
+        <span class="text-zinc-600">·</span>
+
+        @if (_showAddRemote)
+        {
+            <input type="text"
+                   class="px-2 py-1 text-xs rounded bg-zinc-800 border border-zinc-700 text-zinc-200 w-56"
+                   placeholder="https://github.com/user/repo.git"
+                   @bind="_remoteUrl"
+                   @bind:event="oninput" />
+
+            <button class="hover:text-emerald-400"
+                    disabled="@(string.IsNullOrWhiteSpace(_remoteUrl))"
+                    @onclick="AddRemoteAsync">
+                Add
+            </button>
+
+            <button class="hover:text-zinc-400"
+                    @onclick="CancelAddRemote">
+                Cancel
+            </button>
+        }
+        else
+        {
+            <button class="text-zinc-400 hover:text-emerald-400"
+                    @onclick="() => _showAddRemote = true">
+                Add Remote
+            </button>
+        }
+    }
+    else
+    {
+        <span class="text-zinc-600">·</span>
+
+        <button class="text-zinc-400 hover:text-white"
+                disabled="@(_isSyncing || _isFetching)"
+                @onclick="ToggleSyncAsync">
+
+            @if (_isFetching)
+            {
+                <span>Checking…</span>
+            }
+            else
+            {
+                <span>
+                    Sync
+                    @if (_syncStatus.Ahead > 0)
+                    {
+                        <span class="text-emerald-400"> ↑@_syncStatus.Ahead</span>
+                    }
+                    @if (_syncStatus.Behind > 0)
+                    {
+                        <span class="text-blue-400"> ↓@_syncStatus.Behind</span>
+                    }
+                </span>
+            }
+        </button>
+
+        @if (_syncStatus.Ahead > 0)
+        {
+            <span class="text-zinc-600">·</span>
+
+            <button class="hover:text-emerald-400"
+                    disabled="@_isSyncing"
+                    @onclick="PushAsync">
+                @(_isPushing ? "Pushing…" : "Push")
+            </button>
+        }
+
+        @if (_syncStatus.Behind > 0)
+        {
+            <span class="text-zinc-600">·</span>
+
+            <button class="hover:text-blue-400"
+                    disabled="@_isSyncing"
+                    @onclick="PullAsync">
+                @(_isPulling ? "Pulling…" : "Pull")
+            </button>
+        }
+    }
+
+    @if (_errorMessage is not null)
+    {
+        <span class="text-red-400">@_errorMessage</span>
+    }
+</div>
+
+@code {
+
+    private GitRepoStatus _status = GitRepoStatus.NotAvailable;
+
+    private bool _isCommitting;
+    private bool _isRestoring;
+    private bool _showAddRemote;
+    private bool _hasRemote;
+
+    private bool _isFetching;
+    private bool _isPushing;
+    private bool _isPulling;
+
+    private string? _errorMessage;
+    private string _remoteUrl = "";
+
+    private PeriodicTimer? _timer;
+    private CancellationTokenSource? _cts;
+
+    private GitSyncStatus _syncStatus = new(0, 0, false);
+
+    private bool _isBusy => _isCommitting || _isRestoring || _isSyncing;
+    private bool _isSyncing => _isPushing || _isPulling || _isFetching;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _status = await Task.Run(() => GitRepo.GetStatus());
+
+        if (_status == GitRepoStatus.NotAvailable)
+            return;
+
+        _hasRemote = await Task.Run(() => GitRepo.HasRemote());
+
+        _cts = new CancellationTokenSource();
+        _timer = new PeriodicTimer(TimeSpan.FromSeconds(15));
+
+        _ = PollStatusAsync(_cts.Token);
+    }
+
+    private async Task PollStatusAsync(CancellationToken ct)
+    {
+        try
+        {
+            while (_timer != null && await _timer.WaitForNextTickAsync(ct))
+            {
+                if (_isBusy)
+                    continue;
+
+                var newStatus = await Task.Run(() => GitRepo.GetStatus(), ct);
+
+                if (newStatus != _status)
+                {
+                    _status = newStatus;
+                    await InvokeAsync(StateHasChanged);
+                }
+            }
+        }
+        catch (OperationCanceledException) {}
+    }
+
+    private void CancelAddRemote()
+    {
+        _showAddRemote = false;
+        _remoteUrl = "";
+    }
+
+    private async Task AddRemoteAsync()
+    {
+        _errorMessage = null;
+
+        try
+        {
+            var error = await AddRemoteUseCase.ExecuteAsync(_remoteUrl);
+
+            if (error != null)
+            {
+                _errorMessage = error;
+                return;
+            }
+
+            _hasRemote = true;
+            _showAddRemote = false;
+            _remoteUrl = "";
+
+            _syncStatus = await Task.Run(() => GitRepo.FetchAndGetSyncStatus());
+
+            if (_syncStatus.Behind > 0)
+                await PullAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Remote error: {ex.Message}";
+        }
+    }
+
+    private async Task CommitAsync()
+    {
+        _errorMessage = null;
+        _isCommitting = true;
+
+        try
+        {
+            var error = await CommitAll.ExecuteAsync(
+                $"rackpeek: save config {DateTime.UtcNow:yyyy-MM-dd HH:mm:ss} UTC");
+
+            if (error != null)
+                _errorMessage = error;
+
+            _status = await Task.Run(() => GitRepo.GetStatus());
+
+            await Resources.LoadAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Commit error: {ex.Message}";
+        }
+        finally
+        {
+            _isCommitting = false;
+        }
+    }
+
+    private async Task ToggleSyncAsync()
+    {
+        _isFetching = true;
+
+        try
+        {
+            _syncStatus = await Task.Run(() => GitRepo.FetchAndGetSyncStatus());
+        }
+        finally
+        {
+            _isFetching = false;
+        }
+    }
+
+    private async Task PushAsync()
+    {
+        _errorMessage = null;
+        _isPushing = true;
+
+        try
+        {
+            var error = await PushUseCase.ExecuteAsync();
+
+            if (error != null)
+                _errorMessage = error;
+
+            _syncStatus = await Task.Run(() => GitRepo.FetchAndGetSyncStatus());
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Push error: {ex.Message}";
+        }
+        finally
+        {
+            _isPushing = false;
+        }
+    }
+
+    private async Task PullAsync()
+    {
+        _errorMessage = null;
+        _isPulling = true;
+
+        try
+        {
+            var error = await PullUseCase.ExecuteAsync();
+
+            if (error != null)
+                _errorMessage = error;
+
+            _syncStatus = await Task.Run(() => GitRepo.FetchAndGetSyncStatus());
+            _status = await Task.Run(() => GitRepo.GetStatus());
+
+            await Resources.LoadAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Pull error: {ex.Message}";
+        }
+        finally
+        {
+            _isPulling = false;
+        }
+    }
+
+    private async Task DiscardAsync()
+    {
+        _errorMessage = null;
+        _isRestoring = true;
+
+        try
+        {
+            var error = await RestoreAll.ExecuteAsync();
+
+            if (error != null)
+                _errorMessage = error;
+
+            _status = await Task.Run(() => GitRepo.GetStatus());
+
+            await Resources.LoadAsync();
+        }
+        catch (Exception ex)
+        {
+            _errorMessage = $"Discard error: {ex.Message}";
+        }
+        finally
+        {
+            _isRestoring = false;
+        }
+    }
+
+    public void Dispose()
+    {
+        _cts?.Cancel();
+        _cts?.Dispose();
+        _timer?.Dispose();
+    }
+}

+ 62 - 54
Shared.Rcl/Layout/MainLayout.razor

@@ -26,60 +26,68 @@
             </div>
         </NavLink>
 
-        <nav class="space-x-6 text-sm" data-testid="main-nav">
-
-            <NavLink href=""
-                     Match="NavLinkMatch.All"
-                     data-testid="nav-home"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold">
-                Home
-            </NavLink>
-
-            <NavLink href="cli"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-cli">
-                CLI
-            </NavLink>
-
-            <NavLink href="yaml"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-yaml">
-                Yaml
-            </NavLink>
-
-            <NavLink href="hardware/tree"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-hardware">
-                Hardware
-            </NavLink>
-
-            <NavLink href="systems/list"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-systems">
-                Systems
-            </NavLink>
-
-            <NavLink href="services/list"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-services">
-                Services
-            </NavLink>
-
-            <NavLink href="docs"
-                     class="hover:text-emerald-400"
-                     activeClass="text-emerald-400 font-semibold"
-                     data-testid="nav-docs">
-                Docs
-            </NavLink>
-
-
-        </nav>
+        <div class="flex items-center gap-6">
+            @if (RpkConstants.HasGitServices)
+            {
+                <GitStatusIndicator/>
+            }
+
+            <nav class="space-x-6 text-sm" data-testid="main-nav">
+
+                <NavLink href=""
+                         Match="NavLinkMatch.All"
+                         data-testid="nav-home"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold">
+                    Home
+                </NavLink>
+
+                <NavLink href="cli"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-cli">
+                    CLI
+                </NavLink>
+
+                <NavLink href="yaml"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-yaml">
+                    Yaml
+                </NavLink>
+
+                <NavLink href="hardware/tree"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-hardware">
+                    Hardware
+                </NavLink>
+
+                <NavLink href="systems/list"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-systems">
+                    Systems
+                </NavLink>
+
+                <NavLink href="services/list"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-services">
+                    Services
+                </NavLink>
+
+                <NavLink href="docs"
+                         class="hover:text-emerald-400"
+                         activeClass="text-emerald-400 font-semibold"
+                         data-testid="nav-docs">
+                    Docs
+                </NavLink>
+
+
+
+            </nav>
+        </div>
     </header>
 
     <main class="p-6" data-testid="page-content">

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

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

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

@@ -3,7 +3,8 @@
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
-@using RackPeek.Domain.UseCases.Nics
+@using RackPeek.Domain.UseCases.Ports
+@using Shared.Rcl.Hardware
 @inject IAddCpuUseCase<Server> AddCpuUseCase
 @inject IRemoveCpuUseCase<Server> RemoveCpuUseCase
 @inject IUpdateCpuUseCase<Server> UpdateCpuUseCase
@@ -12,9 +13,9 @@
 @inject IUpdateDriveUseCase<Server> UpdateDriveUseCase
 @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 IUpdateGpuUseCase<Server> UpdateGpuUseCase
@@ -168,36 +169,10 @@
             }
         </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 class="flex items-center justify-between mb-1 group">
@@ -287,13 +262,6 @@
     TestIdPrefix="server-drive"/>
 
 
-<NicModal
-    IsOpen="@_nicModalOpen"
-    IsOpenChanged="v => _nicModalOpen = v"
-    Value="@_editingNic"
-    OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"
-    TestIdPrefix="server-nic"/>
 
 <GpuModal
     IsOpen="@_gpuModalOpen"
@@ -452,59 +420,6 @@
 
     #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
 

+ 0 - 4
Shared.Rcl/Services/ServiceDetailsPage.razor

@@ -22,10 +22,6 @@
     {
         @if (_service != null)
         {
-            <h1 class="text-lg text-zinc-100 mb-6">
-                @_service.Name (@_service.Kind)
-            </h1>
-
             <ServiceCardComponent Service="_service" OnDeleted="OnDeleted"/>
         }
     }

+ 17 - 2
Shared.Rcl/SubnetBrowser.razor

@@ -61,7 +61,7 @@
         <div class="space-y-6"
              data-testid="subnet-browser-list">
 
-            @foreach (var subnetGroup in _grouped.OrderBy(x => x.Key))
+            @foreach (var subnetGroup in _grouped.OrderBy(x => IpToSortable(x.Key.Replace(".x", ".0"))))  
             {
                 <div data-testid=@($"subnet-group-{subnetGroup.Key.Replace('.', '-')}")>
 
@@ -90,7 +90,7 @@
                     </div>
 
                     <ul class="ml-2 border-l border-zinc-800 pl-4 space-y-3">
-                        @foreach (var ipGroup in subnetGroup.Value.OrderBy(x => x.Key))
+                        @foreach (var ipGroup in subnetGroup.Value.OrderBy(x => IpToSortable(x.Key)))
                         {
                             <li>
 
@@ -304,5 +304,20 @@
 
         return -1;
     }
+    
+    private static uint IpToSortable(string ip)
+    {
+        var parts = ip.Split('.')
+            .Select(p => byte.TryParse(p, out var b) ? b : (byte)0)
+            .ToArray();
+
+        if (parts.Length != 4)
+            return 0;
+
+        return ((uint)parts[0] << 24)
+               | ((uint)parts[1] << 16)
+               | ((uint)parts[2] << 8)
+               | parts[3];
+    }
 
 }

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

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

+ 0 - 4
Shared.Rcl/Systems/SystemsDetailsPage.razor

@@ -27,10 +27,6 @@
     {
         @if (_system != null)
         {
-            <h1 class="text-lg text-zinc-100 mb-6">
-                @_system.Name (@_system.Kind)
-            </h1>
-
             <SystemCardComponent System="_system" OnSave="UpdateSystem" OnDeleted="OnDeleted"/>
         }
 

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

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

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

@@ -2,6 +2,7 @@
   "overview.md",
   "resource-levels.md",
   "install-guide.md",
+  "git-integration.md",
   "ansible-generator-guide.md",
   "docker-gen-guide.md",
   "cli-commands.md",

+ 44 - 0
Shared.Rcl/wwwroot/raw_docs/git-integration.md

@@ -0,0 +1,44 @@
+# Git Integration Guide
+
+RackPeek can automatically save and sync its configuration using Git.
+To enable this you need a GitHub Personal Access Token with permission to push to the repository that will store your config.
+
+Create a fine-grained access token on GitHub. Select the repository that will contain your RackPeek config and grant **Contents: Read and Write** access. Copy the token when it is created.
+
+Provide the token to the container using the `GIT_TOKEN` environment variable. You should also provide your GitHub username with `GIT_USERNAME`.
+
+Example using Docker Compose:
+
+```yaml
+version: "3.9"
+
+services:
+  rackpeek:
+    image: aptacode/rackpeek:latest
+    container_name: rackpeek
+    ports:
+      - "8080:8080"
+    volumes:
+      - rackpeek-config:/app/config
+    environment:
+      - GIT_TOKEN=your_token_here
+      - GIT_USERNAME=your_github_username
+    restart: unless-stopped
+
+volumes:
+  rackpeek-config:
+```
+
+Example using the Docker CLI:
+
+```bash
+docker run -d \
+  --name rackpeek \
+  -p 8080:8080 \
+  -v rackpeek-config:/app/config \
+  -e GIT_TOKEN=your_token_here \
+  -e GIT_USERNAME=your_github_username \
+  aptacode/rackpeek:latest
+```
+
+Open RackPeek in the browser, enable Git when prompted, then add the repository remote URL. RackPeek will commit and sync configuration changes automatically.

+ 98 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -295,4 +295,102 @@ public class AccessPointCardTests(
             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 {
             Headless = true,
+            SlowMo = 400,
             //Headless = false,
-            SlowMo = 500,
+            //SlowMo = 1500,
             Args = new[]
             {
                 "--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 TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
+    public PortsPom Ports => new(page);
 
+    private const string _portsPrefix = "accesspoint-ports";
     // Modals
     public ILocator DeleteConfirmModal => page.GetByTestId("AccessPoint-confirm-modal");
     public ILocator DeleteConfirmButton => page.GetByTestId("AccessPoint-confirm-modal-confirm");
@@ -153,4 +155,37 @@ public class AccessPointCardPom(IPage page) {
 
     private static string Sanitize(string value)
         => 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 });
 
         var update = """
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Server
                        name: server-update
@@ -264,7 +264,7 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
         await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
 
         var update = """
-                     version: 2
+                     version: 3
                      resources:
                        - kind: Server
                          name: nested-test
@@ -355,7 +355,7 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
         await client.PostAsJsonAsync("/api/inventory", new { yaml = initial });
 
         var update = """
-                     version: 2
+                     version: 3
                      resources:
                        - kind: Firewall
                          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("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
                        speed: 1
                        name: ap01
+                     connections: []
 
                      """, yaml);
 
@@ -55,7 +56,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
@@ -65,6 +66,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
                        model: Aruba-AP-515
                        speed: 2.5
                        name: ap02
+                     connections: []
 
                      """, 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("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Firewall
                        model: Fortinet FG-60F
                        managed: true
                        poe: false
                        name: fw01
+                     connections: []
 
                      """, yaml);
 
@@ -64,7 +65,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
         Assert.Equal("Firewall 'fw02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Firewall
                        model: Fortinet FG-60F
@@ -76,6 +77,7 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
                        managed: false
                        poe: false
                        name: fw02
+                     connections: []
 
                      """, 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("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
                        managed: true
                        poe: false
                        name: rt01
+                     connections: []
 
                      """, yaml);
 
@@ -64,7 +65,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Router 'rt02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Router
                        model: Ubiquiti EdgeRouter 4
@@ -76,6 +77,7 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: false
                        poe: false
                        name: rt02
+                     connections: []
 
                      """, 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("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Server
                        ram:
@@ -48,6 +48,7 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                          mts: 3200
                        ipmi: true
                        name: srv01
+                     connections: []
 
                      """, yaml);
 

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

@@ -46,7 +46,7 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
         outputHelper.WriteLine(yaml);
 
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: System
                        name: sys01
@@ -59,6 +59,7 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
                        name: svc01
                        runsOn:
                        - sys01
+                     connections: []
 
                      """, 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("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Switch
                        model: Netgear GS108
                        managed: true
                        poe: true
                        name: sw01
+                     connections: []
 
                      """, yaml);
 
@@ -66,7 +67,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
         Assert.Equal("""
-                     version: 2
+                     version: 3
                      resources:
                      - kind: Switch
                        model: Netgear GS108
@@ -78,6 +79,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
                        managed: false
                        poe: false
                        name: sw02
+                     connections: []
 
                      """, yaml);
 

Alguns ficheiros não foram mostrados porque muitos ficheiros mudaram neste diff