Просмотр исходного кода

Merge pull request #280 from Timmoth/staging

Released v1.3.1 - stability / bug fixes.
Tim Jones 2 месяцев назад
Родитель
Сommit
a22371504b
79 измененных файлов с 2380 добавлено и 348 удалено
  1. 90 0
      AGENTS.md
  2. 5 0
      README.md
  3. 1 1
      RackPeek.Domain/RpkConstants.cs
  4. 9 0
      RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs
  5. 8 0
      RackPeek.Domain/UseCases/DeleteResourceUseCase.cs
  6. 35 8
      RackPeek.Domain/UseCases/RenameResourceUseCase.cs
  7. 2 0
      RackPeek.Web/Api/InventoryEndpoints.cs
  8. 1 1
      RackPeek/RackPeek.csproj
  9. 9 2
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  10. 45 0
      Shared.Rcl/CliBootstrap.cs
  11. 4 4
      Shared.Rcl/Commands/AccessPoints/AccessPointDescribeCommand.cs
  12. 3 2
      Shared.Rcl/Commands/AccessPoints/AccessPointGetCommand.cs
  13. 3 2
      Shared.Rcl/Commands/AccessPoints/AccessPointReportCommand.cs
  14. 29 0
      Shared.Rcl/Commands/AccessPoints/Rename/AccessPointRenameCommand.cs
  15. 5 4
      Shared.Rcl/Commands/Desktops/DesktopDescribeCommand.cs
  16. 3 2
      Shared.Rcl/Commands/Desktops/DesktopGetCommand.cs
  17. 5 4
      Shared.Rcl/Commands/Desktops/DesktopReportCommand.cs
  18. 29 0
      Shared.Rcl/Commands/Desktops/Rename/DesktopRenameCommand.cs
  19. 5 4
      Shared.Rcl/Commands/Firewalls/FirewallDescribeCommand.cs
  20. 4 3
      Shared.Rcl/Commands/Firewalls/FirewallGetCommand.cs
  21. 4 3
      Shared.Rcl/Commands/Firewalls/FirewallReportCommand.cs
  22. 29 0
      Shared.Rcl/Commands/Firewalls/Rename/FirewallRenameCommand.cs
  23. 7 6
      Shared.Rcl/Commands/GetTotalSummaryCommand.cs
  24. 2 1
      Shared.Rcl/Commands/Laptops/LaptopGetCommand.cs
  25. 4 3
      Shared.Rcl/Commands/Laptops/LaptopReportCommand.cs
  26. 29 0
      Shared.Rcl/Commands/Laptops/Rename/LaptopRenameCommand.cs
  27. 7 0
      Shared.Rcl/Commands/MarkupExtensions.cs
  28. 29 0
      Shared.Rcl/Commands/Routers/Rename/RouterRenameCommand.cs
  29. 4 4
      Shared.Rcl/Commands/Routers/RouterDescribeCommand.cs
  30. 4 3
      Shared.Rcl/Commands/Routers/RouterGetCommand.cs
  31. 4 3
      Shared.Rcl/Commands/Routers/RouterReportCommand.cs
  32. 29 0
      Shared.Rcl/Commands/Servers/Rename/ServerRenameCommand.cs
  33. 4 3
      Shared.Rcl/Commands/Servers/ServerDescribeCommand.cs
  34. 3 2
      Shared.Rcl/Commands/Servers/ServerGetCommand.cs
  35. 5 4
      Shared.Rcl/Commands/Servers/ServerReportCommand.cs
  36. 29 0
      Shared.Rcl/Commands/Services/Rename/ServiceRenameCommand.cs
  37. 7 6
      Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs
  38. 6 5
      Shared.Rcl/Commands/Services/ServiceGetCommand.cs
  39. 6 5
      Shared.Rcl/Commands/Services/ServiceReportCommand.cs
  40. 29 0
      Shared.Rcl/Commands/Switches/Rename/SwitchRenameCommand.cs
  41. 4 4
      Shared.Rcl/Commands/Switches/SwitchDescribeCommand.cs
  42. 4 3
      Shared.Rcl/Commands/Switches/SwitchGetCommand.cs
  43. 4 3
      Shared.Rcl/Commands/Switches/SwitchReportCommand.cs
  44. 29 0
      Shared.Rcl/Commands/Systems/Rename/SystemRenameCommand.cs
  45. 6 5
      Shared.Rcl/Commands/Systems/SystemDescribeCommand.cs
  46. 5 4
      Shared.Rcl/Commands/Systems/SystemGetCommand.cs
  47. 30 0
      Shared.Rcl/Commands/Ups/Rename/UpsRenameCommand.cs
  48. 4 3
      Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs
  49. 3 2
      Shared.Rcl/Commands/Ups/UpsGetCommand.cs
  50. 9 2
      Shared.Rcl/Components/SshExport.razor
  51. 20 16
      Shared.Rcl/Connections/PortLayout.razor
  52. 9 1
      Shared.Rcl/Services/ServiceCardComponent.razor
  53. 9 2
      Shared.Rcl/Ups/UpsCardComponent.razor
  54. 110 28
      Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md
  55. 14 0
      Shared.Rcl/wwwroot/raw_docs/git-integration.md
  56. 5 0
      Shared.Rcl/wwwroot/raw_docs/install-guide.md
  57. 2 2
      Tests.E2e/AccessPointCardTests.cs
  58. 12 0
      Tests/EndToEnd/AccessPointTests/AccessPointCommandTests.cs
  59. 16 26
      Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs
  60. 533 0
      Tests/EndToEnd/CliCommandsWorkflowTests.cs
  61. 170 0
      Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs
  62. 190 0
      Tests/EndToEnd/DeleteResourceTests.cs
  63. 11 0
      Tests/EndToEnd/DesktopTests/DesktopCommandTests.cs
  64. 8 9
      Tests/EndToEnd/DesktopTests/DesktopWorkflowTests.cs
  65. 11 0
      Tests/EndToEnd/FirewallTests/FirewallCommandTests.cs
  66. 19 26
      Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs
  67. 410 0
      Tests/EndToEnd/GenerateAnsibleInventoryTests.cs
  68. 11 0
      Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs
  69. 11 0
      Tests/EndToEnd/RouterTests/RouterCommandTests.cs
  70. 13 18
      Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs
  71. 25 0
      Tests/EndToEnd/ServerTests/ServerCommandTests.cs
  72. 20 24
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  73. 11 0
      Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs
  74. 35 41
      Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs
  75. 11 0
      Tests/EndToEnd/SwitchTests/SwitchCommandTests.cs
  76. 19 26
      Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs
  77. 11 0
      Tests/EndToEnd/SystemTests/SystemCommandTests.cs
  78. 18 18
      Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs
  79. 12 0
      Tests/EndToEnd/UpsTests/UpsCommandTests.cs

+ 90 - 0
AGENTS.md

@@ -0,0 +1,90 @@
+# RackPeek — Agent Quick Reference
+
+## Commands
+
+**Build & Test**
+```bash
+just build              # dotnet build RackPeek.sln
+just test-all           # CLI + E2E tests (rebuilds Web image)
+just ci                 # alias for test-all (matches CI checklist)
+just test-cli           # dotnet test Tests/Tests.csproj
+just test-e2e           # requires just build-web first
+```
+
+**Run Locally**
+```bash
+just run-docker         # builds and starts Docker container on :8080
+just rpk [args]         # run CLI directly from debug build
+```
+
+**E2E Setup** (first time only)
+```bash
+just e2e-setup          # installs Playwright CLI + browsers
+```
+
+**Demo**
+```bash
+just build-cli-demo     # VHS CLI demo (needs: vhs, imagemagick, chrome)
+just build-web-demo     # Web UI demo (needs: Chrome, ImageMagick)
+```
+
+**Release**
+```bash
+just docker-push <ver>  # multi-arch Docker push (e.g., just docker-push 1.3.0)
+```
+
+## Workflow
+
+1. **CI order**: `format → cli-tests → webui-tests`
+2. **PR checklist**:
+   - Linked GitHub issue
+   - Approach validated with maintainers
+   - Small, focused PR
+   - CLI tests passing locally
+   - E2E tests passing locally
+   - YAML migration defined if persisting changes
+
+3. **Draft PR** until:
+   - All tests pass locally
+   - Scope complete
+   - Debug code removed
+
+## Architecture
+
+**Solution structure**:
+- `RackPeek/` — CLI application
+- `RackPeek.Domain/` — shared domain models
+- `RackPeek.Web/` — Web UI (Blazor)
+- `RackPeek.Web.Viewer/` — Web UI viewer
+- `Shared.Rcl/` — shared Blazor components
+- `Tests/` — CLI unit tests
+- `Tests.E2e/` — Playwright E2E tests
+
+**Key files**:
+- `justfile` — developer workflow commands
+- `.github/workflows/test.yml` — CI pipeline
+- `RackPeek.sln` — solution root
+- `docs/development/` — dev guides (dev-cheat-sheet.md, testing-guidelines.md)
+
+## Gotchas
+
+- **E2E tests require Docker image**: run `just build-web` before `just test-e2e`
+- **Playwright browsers** installed via `just e2e-setup` (first time)
+- **CI runs on `ubuntu-latest`** (CLI tests) and `ubuntu-24.04` (WebUI)
+- **Docker image tag**: `rackpeek:ci` used locally, `aptacode/rackpeek` on registry
+- **Format check**: `dotnet format --verify-no-changes` (CI step 1)
+- **Debugging E2E**: Set `Headless = false, SlowMo = 500` in `PlaywrightFixture.cs`, revert before commit
+- **YAML changes**: Always define migration if modifying persisted schema
+
+## Testing
+
+- **CLI tests**: `dotnet test Tests/Tests.csproj` (fast, no Docker)
+- **E2E tests**: `dotnet test Tests.E2e` (requires Docker image, Playwright browsers)
+- **Full suite**: `just ci` or `just test-all`
+
+## Docs References
+
+- `docs/development/contribution-guidelines.md` — PR process
+- `docs/development/dev-cheat-sheet.md` — build/release details
+- `docs/development/testing-guidelines.md` — testing principles
+- `README.md` — overview and Docker usage

+ 5 - 0
README.md

@@ -52,6 +52,11 @@ services:
     volumes:
     volumes:
       - rackpeek-config:/app/config
       - rackpeek-config:/app/config
     restart: unless-stopped
     restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -s http://localhost:8080 | grep -q 'rackpeek' || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
 
 
 volumes:
 volumes:
   rackpeek-config:
   rackpeek-config:

+ 1 - 1
RackPeek.Domain/RpkConstants.cs

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

+ 9 - 0
RackPeek.Domain/UseCases/Ansible/AnsibleInventoryGenerator.cs

@@ -192,6 +192,15 @@ public static class AnsibleInventoryGenerator {
             if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v))
             if (string.IsNullOrWhiteSpace(k) || string.IsNullOrWhiteSpace(v))
                 continue;
                 continue;
 
 
+            // Custom host variables via ansible_var_*
+            if (k.StartsWith("ansible_var_", StringComparison.OrdinalIgnoreCase)) {
+                var varName = k.Substring("ansible_var_".Length);
+                if (!string.IsNullOrWhiteSpace(varName))
+                    vars[varName] = v;
+                continue;
+            }
+
+            // Standard ansible_* variables
             if (k.StartsWith("ansible_", StringComparison.OrdinalIgnoreCase))
             if (k.StartsWith("ansible_", StringComparison.OrdinalIgnoreCase))
                 vars[k] = v;
                 vars[k] = v;
         }
         }

+ 8 - 0
RackPeek.Domain/UseCases/DeleteResourceUseCase.cs

@@ -1,6 +1,7 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 
 
 namespace RackPeek.Domain.UseCases;
 namespace RackPeek.Domain.UseCases;
 
 
@@ -24,6 +25,13 @@ public class DeleteResourceUseCase<T>(IResourceCollection repo) : IDeleteResourc
             await repo.UpdateAsync(resource);
             await repo.UpdateAsync(resource);
         }
         }
 
 
+        IReadOnlyList<Connection> connections = await repo.GetConnectionsAsync();
+        foreach (Connection connection in connections) {
+            if (connection.A.Resource == name || connection.B.Resource == name) {
+                await repo.RemoveConnectionAsync(connection);
+            }
+        }
+
         await repo.DeleteAsync(name);
         await repo.DeleteAsync(name);
     }
     }
 }
 }

+ 35 - 8
RackPeek.Domain/UseCases/RenameResourceUseCase.cs

@@ -1,20 +1,21 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 
 
 namespace RackPeek.Domain.UseCases;
 namespace RackPeek.Domain.UseCases;
 
 
 public interface IRenameResourceUseCase<T> : IResourceUseCase<T>
 public interface IRenameResourceUseCase<T> : IResourceUseCase<T>
     where T : Resource {
     where T : Resource {
-    public Task ExecuteAsync(string originalName, string newName);
+    Task ExecuteAsync(string originalName, string newName);
 }
 }
 
 
 public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourceUseCase<T> where T : Resource {
 public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourceUseCase<T> where T : Resource {
     public async Task ExecuteAsync(string originalName, string newName) {
     public async Task ExecuteAsync(string originalName, string newName) {
-        originalName = Normalize.SystemName(originalName);
+        originalName = Normalize.HardwareName(originalName);
         ThrowIfInvalid.ResourceName(originalName);
         ThrowIfInvalid.ResourceName(originalName);
 
 
-        newName = Normalize.SystemName(newName);
+        newName = Normalize.HardwareName(newName);
         ThrowIfInvalid.ResourceName(newName);
         ThrowIfInvalid.ResourceName(newName);
 
 
         Resource? existingResource = await repo.GetByNameAsync(newName);
         Resource? existingResource = await repo.GetByNameAsync(newName);
@@ -22,15 +23,41 @@ public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourc
             throw new ConflictException($"{existingResource.Kind} resource '{newName}' already exists.");
             throw new ConflictException($"{existingResource.Kind} resource '{newName}' already exists.");
 
 
         Resource? original = await repo.GetByNameAsync(originalName);
         Resource? original = await repo.GetByNameAsync(originalName);
-        if (original == null) throw new NotFoundException($"Resource '{originalName}' not found.");
+        if (original == null)
+            throw new NotFoundException($"Resource '{originalName}' not found.");
 
 
         original.Name = newName;
         original.Name = newName;
         await repo.UpdateAsync(original);
         await repo.UpdateAsync(original);
 
 
-        IReadOnlyList<Resource> children = await repo.GetDependantsAsync(originalName);
-        foreach (Resource child in children) {
-            child.RunsOn = child.RunsOn.ConvertAll<string>(p => p == originalName ? newName : p);
-            await repo.UpdateAsync(child);
+        IReadOnlyList<Resource> allResources = await repo.GetAllOfTypeAsync<Resource>();
+
+        foreach (Resource resource in allResources) {
+            if (resource.RunsOn.Contains(originalName)) {
+                resource.RunsOn = resource.RunsOn
+                    .ConvertAll(p => p == originalName ? newName : p);
+
+                await repo.UpdateAsync(resource);
+            }
+        }
+
+        IReadOnlyList<Connection> connections = await repo.GetConnectionsAsync();
+        foreach (Connection connection in connections) {
+            var updated = false;
+
+            if (connection.A.Resource == originalName) {
+                connection.A.Resource = newName;
+                updated = true;
+            }
+
+            if (connection.B.Resource == originalName) {
+                connection.B.Resource = newName;
+                updated = true;
+            }
+
+            if (updated) {
+                await repo.RemoveConnectionAsync(connection);
+                await repo.AddConnectionAsync(connection);
+            }
         }
         }
     }
     }
 }
 }

+ 2 - 0
RackPeek.Web/Api/InventoryEndpoints.cs

@@ -5,6 +5,8 @@ namespace RackPeek.Web.Api;
 
 
 public static class InventoryEndpoints {
 public static class InventoryEndpoints {
     public static void MapInventoryApi(this WebApplication app) {
     public static void MapInventoryApi(this WebApplication app) {
+        app.MapGet("/health", () => Results.Content("rackpeek", "text/plain"))
+            .DisableAntiforgery();
         app.MapPost("/api/inventory",
         app.MapPost("/api/inventory",
                 async (ImportYamlRequest request,
                 async (ImportYamlRequest request,
                     UpsertInventoryUseCase useCase) => {
                     UpsertInventoryUseCase useCase) => {

+ 1 - 1
RackPeek/RackPeek.csproj

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

+ 9 - 2
Shared.Rcl/AccessPoints/AccessPointCardComponent.razor

@@ -93,8 +93,15 @@
                 <input type="number"
                 <input type="number"
                        step="0.1"
                        step="0.1"
                        data-testid="accesspoint-speed-input"
                        data-testid="accesspoint-speed-input"
-                       class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
-                       @bind="_edit.Speed"/>
+                        class="w-full px-3 py-2 rounded-md
+                            bg-zinc-800 text-zinc-100
+                            border border-zinc-600
+                            placeholder-zinc-500
+                            focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
+                            hover:border-zinc-400
+                            transition-colors duration-150
+                            cursor-text"
+                                                  @bind="_edit.Speed"/>
             }
             }
             else if (AccessPoint.Speed is not null)
             else if (AccessPoint.Speed is not null)
             {
             {

+ 45 - 0
Shared.Rcl/CliBootstrap.cs

@@ -8,6 +8,7 @@ using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
 using Shared.Rcl.Commands.AccessPoints.Labels;
+using Shared.Rcl.Commands.AccessPoints.Rename;
 using Shared.Rcl.Commands.Connections;
 using Shared.Rcl.Commands.Connections;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops.Cpus;
 using Shared.Rcl.Commands.Desktops.Cpus;
@@ -15,33 +16,42 @@ using Shared.Rcl.Commands.Desktops.Drive;
 using Shared.Rcl.Commands.Desktops.Gpus;
 using Shared.Rcl.Commands.Desktops.Gpus;
 using Shared.Rcl.Commands.Desktops.Labels;
 using Shared.Rcl.Commands.Desktops.Labels;
 using Shared.Rcl.Commands.Desktops.Nics;
 using Shared.Rcl.Commands.Desktops.Nics;
+using Shared.Rcl.Commands.Desktops.Rename;
 using Shared.Rcl.Commands.Exporters;
 using Shared.Rcl.Commands.Exporters;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls.Labels;
 using Shared.Rcl.Commands.Firewalls.Labels;
 using Shared.Rcl.Commands.Firewalls.Ports;
 using Shared.Rcl.Commands.Firewalls.Ports;
+using Shared.Rcl.Commands.Firewalls.Rename;
 using Shared.Rcl.Commands.Laptops;
 using Shared.Rcl.Commands.Laptops;
 using Shared.Rcl.Commands.Laptops.Cpus;
 using Shared.Rcl.Commands.Laptops.Cpus;
 using Shared.Rcl.Commands.Laptops.Drive;
 using Shared.Rcl.Commands.Laptops.Drive;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Laptops.Labels;
 using Shared.Rcl.Commands.Laptops.Labels;
+using Shared.Rcl.Commands.Laptops.Rename;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers.Labels;
 using Shared.Rcl.Commands.Routers.Labels;
 using Shared.Rcl.Commands.Routers.Ports;
 using Shared.Rcl.Commands.Routers.Ports;
+using Shared.Rcl.Commands.Routers.Rename;
 using Shared.Rcl.Commands.Servers;
 using Shared.Rcl.Commands.Servers;
 using Shared.Rcl.Commands.Servers.Cpus;
 using Shared.Rcl.Commands.Servers.Cpus;
 using Shared.Rcl.Commands.Servers.Drives;
 using Shared.Rcl.Commands.Servers.Drives;
 using Shared.Rcl.Commands.Servers.Gpus;
 using Shared.Rcl.Commands.Servers.Gpus;
 using Shared.Rcl.Commands.Servers.Labels;
 using Shared.Rcl.Commands.Servers.Labels;
 using Shared.Rcl.Commands.Servers.Nics;
 using Shared.Rcl.Commands.Servers.Nics;
+using Shared.Rcl.Commands.Servers.Rename;
 using Shared.Rcl.Commands.Services;
 using Shared.Rcl.Commands.Services;
 using Shared.Rcl.Commands.Services.Labels;
 using Shared.Rcl.Commands.Services.Labels;
+using Shared.Rcl.Commands.Services.Rename;
 using Shared.Rcl.Commands.Switches;
 using Shared.Rcl.Commands.Switches;
 using Shared.Rcl.Commands.Switches.Labels;
 using Shared.Rcl.Commands.Switches.Labels;
 using Shared.Rcl.Commands.Switches.Ports;
 using Shared.Rcl.Commands.Switches.Ports;
+using Shared.Rcl.Commands.Switches.Rename;
 using Shared.Rcl.Commands.Systems;
 using Shared.Rcl.Commands.Systems;
 using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Labels;
+using Shared.Rcl.Commands.Systems.Rename;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
 using Shared.Rcl.Commands.Ups.Labels;
+using Shared.Rcl.Commands.Ups.Rename;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -134,6 +144,9 @@ public static class CliBootstrap {
 
 
                 server.AddCommand<ServerDeleteCommand>("del").WithDescription("Delete a server from the inventory.");
                 server.AddCommand<ServerDeleteCommand>("del").WithDescription("Delete a server from the inventory.");
 
 
+                server.AddCommand<ServerRenameCommand>("rename")
+                    .WithDescription("Rename a server to a new name.");
+
                 server.AddCommand<ServerTreeCommand>("tree")
                 server.AddCommand<ServerTreeCommand>("tree")
                     .WithDescription("Display the dependency tree of a server.");
                     .WithDescription("Display the dependency tree of a server.");
 
 
@@ -216,6 +229,10 @@ public static class CliBootstrap {
                 switches.AddCommand<SwitchSetCommand>("set").WithDescription("Update properties of a switch.");
                 switches.AddCommand<SwitchSetCommand>("set").WithDescription("Update properties of a switch.");
 
 
                 switches.AddCommand<SwitchDeleteCommand>("del").WithDescription("Delete a switch from the inventory.");
                 switches.AddCommand<SwitchDeleteCommand>("del").WithDescription("Delete a switch from the inventory.");
+
+                switches.AddCommand<SwitchRenameCommand>("rename")
+                    .WithDescription("Rename a switch to a new name.");
+
                 switches.AddBranch("port", port => {
                 switches.AddBranch("port", port => {
                     port.SetDescription("Manage ports on a network switch.");
                     port.SetDescription("Manage ports on a network switch.");
 
 
@@ -257,6 +274,10 @@ public static class CliBootstrap {
                 routers.AddCommand<RouterSetCommand>("set").WithDescription("Update properties of a router.");
                 routers.AddCommand<RouterSetCommand>("set").WithDescription("Update properties of a router.");
 
 
                 routers.AddCommand<RouterDeleteCommand>("del").WithDescription("Delete a router from the inventory.");
                 routers.AddCommand<RouterDeleteCommand>("del").WithDescription("Delete a router from the inventory.");
+
+                routers.AddCommand<RouterRenameCommand>("rename")
+                    .WithDescription("Rename a router to a new name.");
+
                 routers.AddBranch("port", port => {
                 routers.AddBranch("port", port => {
                     port.SetDescription("Manage ports on a router.");
                     port.SetDescription("Manage ports on a router.");
 
 
@@ -298,6 +319,10 @@ public static class CliBootstrap {
 
 
                 firewalls.AddCommand<FirewallDeleteCommand>("del")
                 firewalls.AddCommand<FirewallDeleteCommand>("del")
                     .WithDescription("Delete a firewall from the inventory.");
                     .WithDescription("Delete a firewall from the inventory.");
+
+                firewalls.AddCommand<FirewallRenameCommand>("rename")
+                    .WithDescription("Rename a firewall to a new name.");
+
                 firewalls.AddBranch("port", port => {
                 firewalls.AddBranch("port", port => {
                     port.SetDescription("Manage ports on a firewall.");
                     port.SetDescription("Manage ports on a firewall.");
 
 
@@ -338,6 +363,9 @@ public static class CliBootstrap {
 
 
                 system.AddCommand<SystemDeleteCommand>("del").WithDescription("Delete a system from the inventory.");
                 system.AddCommand<SystemDeleteCommand>("del").WithDescription("Delete a system from the inventory.");
 
 
+                system.AddCommand<SystemRenameCommand>("rename")
+                    .WithDescription("Rename a system to a new name.");
+
                 system.AddCommand<SystemTreeCommand>("tree")
                 system.AddCommand<SystemTreeCommand>("tree")
                     .WithDescription("Display the dependency tree for a system.");
                     .WithDescription("Display the dependency tree for a system.");
 
 
@@ -371,6 +399,9 @@ public static class CliBootstrap {
 
 
                 ap.AddCommand<AccessPointDeleteCommand>("del").WithDescription("Delete an access point.");
                 ap.AddCommand<AccessPointDeleteCommand>("del").WithDescription("Delete an access point.");
 
 
+                ap.AddCommand<AccessPointRenameCommand>("rename")
+                    .WithDescription("Rename an access point to a new name.");
+
                 ap.AddBranch("label", label => {
                 ap.AddBranch("label", label => {
                     label.SetDescription("Manage labels on an access point.");
                     label.SetDescription("Manage labels on an access point.");
                     label.AddCommand<AccessPointLabelAddCommand>("add")
                     label.AddCommand<AccessPointLabelAddCommand>("add")
@@ -402,6 +433,9 @@ public static class CliBootstrap {
 
 
                 ups.AddCommand<UpsDeleteCommand>("del").WithDescription("Delete a UPS unit.");
                 ups.AddCommand<UpsDeleteCommand>("del").WithDescription("Delete a UPS unit.");
 
 
+                ups.AddCommand<UpsRenameCommand>("rename")
+                    .WithDescription("Rename a UPS unit to a new name.");
+
                 ups.AddBranch("label", label => {
                 ups.AddBranch("label", label => {
                     label.SetDescription("Manage labels on a UPS unit.");
                     label.SetDescription("Manage labels on a UPS unit.");
                     label.AddCommand<UpsLabelAddCommand>("add").WithDescription("Add a label to a UPS unit.");
                     label.AddCommand<UpsLabelAddCommand>("add").WithDescription("Add a label to a UPS unit.");
@@ -425,6 +459,10 @@ public static class CliBootstrap {
                 desktops.AddCommand<DesktopSetCommand>("set").WithDescription("Update properties of a desktop.");
                 desktops.AddCommand<DesktopSetCommand>("set").WithDescription("Update properties of a desktop.");
                 desktops.AddCommand<DesktopDeleteCommand>("del")
                 desktops.AddCommand<DesktopDeleteCommand>("del")
                     .WithDescription("Delete a desktop from the inventory.");
                     .WithDescription("Delete a desktop from the inventory.");
+
+                desktops.AddCommand<DesktopRenameCommand>("rename")
+                    .WithDescription("Rename a desktop to a new name.");
+
                 desktops.AddCommand<DesktopReportCommand>("summary")
                 desktops.AddCommand<DesktopReportCommand>("summary")
                     .WithDescription("Show a summarized hardware report for all desktops.");
                     .WithDescription("Show a summarized hardware report for all desktops.");
                 desktops.AddCommand<DesktopTreeCommand>("tree")
                 desktops.AddCommand<DesktopTreeCommand>("tree")
@@ -485,6 +523,10 @@ public static class CliBootstrap {
                     .WithDescription("Show detailed information about a Laptop.");
                     .WithDescription("Show detailed information about a Laptop.");
                 laptops.AddCommand<LaptopSetCommand>("set").WithDescription("Update properties of a laptop.");
                 laptops.AddCommand<LaptopSetCommand>("set").WithDescription("Update properties of a laptop.");
                 laptops.AddCommand<LaptopDeleteCommand>("del").WithDescription("Delete a Laptop from the inventory.");
                 laptops.AddCommand<LaptopDeleteCommand>("del").WithDescription("Delete a Laptop from the inventory.");
+
+                laptops.AddCommand<LaptopRenameCommand>("rename")
+                    .WithDescription("Rename a Laptop to a new name.");
+
                 laptops.AddCommand<LaptopReportCommand>("summary")
                 laptops.AddCommand<LaptopReportCommand>("summary")
                     .WithDescription("Show a summarized hardware report for all Laptops.");
                     .WithDescription("Show a summarized hardware report for all Laptops.");
                 laptops.AddCommand<LaptopTreeCommand>("tree")
                 laptops.AddCommand<LaptopTreeCommand>("tree")
@@ -544,6 +586,9 @@ public static class CliBootstrap {
 
 
                 service.AddCommand<ServiceDeleteCommand>("del").WithDescription("Delete a service.");
                 service.AddCommand<ServiceDeleteCommand>("del").WithDescription("Delete a service.");
 
 
+                service.AddCommand<ServiceRenameCommand>("rename")
+                    .WithDescription("Rename a service to a new name.");
+
                 service.AddCommand<ServiceSubnetsCommand>("subnets")
                 service.AddCommand<ServiceSubnetsCommand>("subnets")
                     .WithDescription("List subnets associated with a service, optionally filtered by CIDR.");
                     .WithDescription("List subnets associated with a service, optionally filtered by CIDR.");
 
 

+ 4 - 4
Shared.Rcl/Commands/AccessPoints/AccessPointDescribeCommand.cs

@@ -23,12 +23,12 @@ public class AccessPointDescribeCommand(
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap());
             .AddColumn(new GridColumn().NoWrap());
 
 
-        grid.AddRow("Name:", ap.Name);
-        grid.AddRow("Model:", ap.Model ?? "Unknown");
-        grid.AddRow("Speed (Gbps):", ap.Speed?.ToString() ?? "Unknown");
+        grid.AddRow("Name:", ap.Name.EscapeMarkup());
+        grid.AddRow("Model:", (ap.Model ?? "Unknown").EscapeMarkup());
+        grid.AddRow("Speed (Gbps):", (ap.Speed?.ToString() ?? "Unknown").EscapeMarkup());
 
 
         if (ap.Labels.Count > 0)
         if (ap.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", ap.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", ap.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)

+ 3 - 2
Shared.Rcl/Commands/AccessPoints/AccessPointGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.AccessPoints;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -30,8 +31,8 @@ public class AccessPointGetCommand(
 
 
         foreach (AccessPointHardwareRow ap in report.AccessPoints)
         foreach (AccessPointHardwareRow ap in report.AccessPoints)
             table.AddRow(
             table.AddRow(
-                ap.Name,
-                ap.Model,
+                ap.Name.EscapeMarkup(),
+                ap.Model.EscapeMarkup(),
                 ap.SpeedGb.ToString()
                 ap.SpeedGb.ToString()
             );
             );
 
 

+ 3 - 2
Shared.Rcl/Commands/AccessPoints/AccessPointReportCommand.cs

@@ -1,6 +1,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 using RackPeek.Domain.Resources.AccessPoints;
 using RackPeek.Domain.Resources.AccessPoints;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -32,8 +33,8 @@ public class AccessPointReportCommand(
 
 
         foreach (AccessPointHardwareRow ap in report.AccessPoints)
         foreach (AccessPointHardwareRow ap in report.AccessPoints)
             table.AddRow(
             table.AddRow(
-                ap.Name,
-                ap.Model,
+                ap.Name.EscapeMarkup(),
+                ap.Model.EscapeMarkup(),
                 $"{ap.SpeedGb}"
                 $"{ap.SpeedGb}"
             );
             );
 
 

+ 29 - 0
Shared.Rcl/Commands/AccessPoints/Rename/AccessPointRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.AccessPoints.Rename;
+
+public class AccessPointRenameSettings : AccessPointNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class AccessPointRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<AccessPointRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        AccessPointRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<AccessPoint> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<AccessPoint>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]AccessPoint '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 5 - 4
Shared.Rcl/Commands/Desktops/DesktopDescribeCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Desktops;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -18,16 +19,16 @@ public class DesktopDescribeCommand(IServiceProvider provider)
 
 
         Grid grid = new Grid().AddColumn().AddColumn();
         Grid grid = new Grid().AddColumn().AddColumn();
 
 
-        grid.AddRow("Name:", result.Name);
-        grid.AddRow("Model:", result.Model ?? "Unknown");
+        grid.AddRow("Name:", result.Name.EscapeMarkup());
+        grid.AddRow("Model:", (result.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("CPUs:", result.CpuCount.ToString());
         grid.AddRow("CPUs:", result.CpuCount.ToString());
-        grid.AddRow("RAM:", result.RamSummary ?? "None");
+        grid.AddRow("RAM:", (result.RamSummary ?? "None").EscapeMarkup());
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("NICs:", result.NicCount.ToString());
         grid.AddRow("NICs:", result.NicCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
 
 
         if (result.Labels.Count > 0)
         if (result.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(new Panel(grid).Header("Desktop").Border(BoxBorder.Rounded));
         AnsiConsole.Write(new Panel(grid).Header("Desktop").Border(BoxBorder.Rounded));
 
 

+ 3 - 2
Shared.Rcl/Commands/Desktops/DesktopGetCommand.cs

@@ -1,6 +1,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.UseCases;
 using RackPeek.Domain.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -34,8 +35,8 @@ public class DesktopGetCommand(IServiceProvider provider)
 
 
         foreach (Desktop d in desktops)
         foreach (Desktop d in desktops)
             table.AddRow(
             table.AddRow(
-                d.Name,
-                d.Model ?? "Unknown",
+                d.Name.EscapeMarkup(),
+                (d.Model ?? "Unknown").EscapeMarkup(),
                 (d.Cpus?.Count ?? 0).ToString(),
                 (d.Cpus?.Count ?? 0).ToString(),
                 d.Ram == null ? "None" : $"{d.Ram.Size}GB",
                 d.Ram == null ? "None" : $"{d.Ram.Size}GB",
                 (d.Drives?.Count ?? 0).ToString(),
                 (d.Drives?.Count ?? 0).ToString(),

+ 5 - 4
Shared.Rcl/Commands/Desktops/DesktopReportCommand.cs

@@ -1,6 +1,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using Microsoft.Extensions.Logging;
 using RackPeek.Domain.Resources.Desktops;
 using RackPeek.Domain.Resources.Desktops;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -35,13 +36,13 @@ public class DesktopReportCommand(
 
 
         foreach (DesktopHardwareRow d in report.Desktops)
         foreach (DesktopHardwareRow d in report.Desktops)
             table.AddRow(
             table.AddRow(
-                d.Name,
-                d.CpuSummary,
+                d.Name.EscapeMarkup(),
+                d.CpuSummary.EscapeMarkup(),
                 $"{d.TotalCores}/{d.TotalThreads}",
                 $"{d.TotalCores}/{d.TotalThreads}",
                 $"{d.RamGb} GB",
                 $"{d.RamGb} GB",
                 $"{d.TotalStorageGb} GB (SSD {d.SsdStorageGb} / HDD {d.HddStorageGb})",
                 $"{d.TotalStorageGb} GB (SSD {d.SsdStorageGb} / HDD {d.HddStorageGb})",
-                d.NicSummary,
-                d.GpuSummary
+                d.NicSummary.EscapeMarkup(),
+                d.GpuSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 29 - 0
Shared.Rcl/Commands/Desktops/Rename/DesktopRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Desktops.Rename;
+
+public class DesktopRenameSettings : DesktopNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class DesktopRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<DesktopRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        DesktopRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<Desktop> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<Desktop>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Desktop '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 5 - 4
Shared.Rcl/Commands/Firewalls/FirewallDescribeCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Firewalls;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -21,16 +22,16 @@ public class FirewallDescribeCommand(
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap());
             .AddColumn(new GridColumn().NoWrap());
 
 
-        grid.AddRow("Name:", sw.Name);
-        grid.AddRow("Model:", sw.Model ?? "Unknown");
+        grid.AddRow("Name:", sw.Name.EscapeMarkup());
+        grid.AddRow("Model:", (sw.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("Managed:", sw.Managed.HasValue ? sw.Managed.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Managed:", sw.Managed.HasValue ? sw.Managed.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Total Ports:", sw.TotalPorts.ToString());
         grid.AddRow("Total Ports:", sw.TotalPorts.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
-        grid.AddRow("Ports:", sw.PortSummary);
+        grid.AddRow("Ports:", sw.PortSummary.EscapeMarkup());
 
 
         if (sw.Labels.Count > 0)
         if (sw.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)

+ 4 - 3
Shared.Rcl/Commands/Firewalls/FirewallGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Firewalls;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -33,12 +34,12 @@ public class FirewallGetCommand(
 
 
         foreach (FirewallHardwareRow s in report.Firewalls)
         foreach (FirewallHardwareRow s in report.Firewalls)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Model ?? "Unknown",
+                s.Name.EscapeMarkup(),
+                (s.Model ?? "Unknown").EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 s.TotalPorts.ToString(),
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 4 - 3
Shared.Rcl/Commands/Firewalls/FirewallReportCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Firewalls;
 using RackPeek.Domain.Resources.Firewalls;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -32,13 +33,13 @@ public class FirewallReportCommand(
 
 
         foreach (FirewallHardwareRow s in report.Firewalls)
         foreach (FirewallHardwareRow s in report.Firewalls)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Model,
+                s.Name.EscapeMarkup(),
+                s.Model.EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 s.TotalPorts.ToString(),
                 $"{s.MaxPortSpeedGb}G",
                 $"{s.MaxPortSpeedGb}G",
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 29 - 0
Shared.Rcl/Commands/Firewalls/Rename/FirewallRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Firewalls.Rename;
+
+public class FirewallRenameSettings : FirewallNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class FirewallRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<FirewallRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        FirewallRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<Firewall> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<Firewall>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Firewall '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 7 - 6
Shared.Rcl/Commands/GetTotalSummaryCommand.cs

@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services.UseCases;
 using RackPeek.Domain.Resources.Services.UseCases;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -46,7 +47,7 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             $"[bold]Hardware[/] ({hardwareSummary.TotalHardware})");
             $"[bold]Hardware[/] ({hardwareSummary.TotalHardware})");
 
 
         foreach ((var kind, var count) in hardwareSummary.HardwareByKind.OrderByDescending(h => h.Value).ThenBy(h => h.Key))
         foreach ((var kind, var count) in hardwareSummary.HardwareByKind.OrderByDescending(h => h.Value).ThenBy(h => h.Key))
-            hardwareNode.AddNode($"{kind}: {count}");
+            hardwareNode.AddNode($"{kind.EscapeMarkup()}: {count}");
 
 
         TreeNode systemsNode = tree.AddNode(
         TreeNode systemsNode = tree.AddNode(
             $"[bold]Systems[/] ({systemSummary.TotalSystems})");
             $"[bold]Systems[/] ({systemSummary.TotalSystems})");
@@ -55,13 +56,13 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             TreeNode typesNode = systemsNode.AddNode("[bold]Types[/]");
             TreeNode typesNode = systemsNode.AddNode("[bold]Types[/]");
             foreach ((var type, var count) in systemSummary.SystemsByType.OrderByDescending(h => h.Value)
             foreach ((var type, var count) in systemSummary.SystemsByType.OrderByDescending(h => h.Value)
                          .ThenBy(h => h.Key))
                          .ThenBy(h => h.Key))
-                typesNode.AddNode($"{type}: {count}");
+                typesNode.AddNode($"{type.EscapeMarkup()}: {count}");
         }
         }
 
 
         if (systemSummary.SystemsByOs.Count > 0) {
         if (systemSummary.SystemsByOs.Count > 0) {
             TreeNode osNode = systemsNode.AddNode("[bold]Operating Systems[/]");
             TreeNode osNode = systemsNode.AddNode("[bold]Operating Systems[/]");
             foreach ((var os, var count) in systemSummary.SystemsByOs.OrderByDescending(h => h.Value).ThenBy(h => h.Key))
             foreach ((var os, var count) in systemSummary.SystemsByOs.OrderByDescending(h => h.Value).ThenBy(h => h.Key))
-                osNode.AddNode($"{os}: {count}");
+                osNode.AddNode($"{os.EscapeMarkup()}: {count}");
         }
         }
 
 
         TreeNode servicesNode = tree.AddNode(
         TreeNode servicesNode = tree.AddNode(
@@ -105,10 +106,10 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             .AddColumn("Count");
             .AddColumn("Count");
 
 
         foreach ((var type, var count) in systemSummary.SystemsByType)
         foreach ((var type, var count) in systemSummary.SystemsByType)
-            table.AddRow("Type", type, count.ToString());
+            table.AddRow("Type", type.EscapeMarkup(), count.ToString());
 
 
         foreach ((var os, var count) in systemSummary.SystemsByOs)
         foreach ((var os, var count) in systemSummary.SystemsByOs)
-            table.AddRow("OS", os, count.ToString());
+            table.AddRow("OS", os.EscapeMarkup(), count.ToString());
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);
     }
     }
@@ -124,7 +125,7 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             .AddColumn("Count");
             .AddColumn("Count");
 
 
         foreach ((var kind, var count) in hardwareSummary.HardwareByKind)
         foreach ((var kind, var count) in hardwareSummary.HardwareByKind)
-            table.AddRow(kind, count.ToString());
+            table.AddRow(kind.EscapeMarkup(), count.ToString());
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);
     }
     }

+ 2 - 1
Shared.Rcl/Commands/Laptops/LaptopGetCommand.cs

@@ -1,6 +1,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Laptops;
 using RackPeek.Domain.Resources.Laptops;
 using RackPeek.Domain.UseCases;
 using RackPeek.Domain.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -32,7 +33,7 @@ public class LaptopGetCommand(IServiceProvider provider)
 
 
         foreach (Laptop d in laptops)
         foreach (Laptop d in laptops)
             table.AddRow(
             table.AddRow(
-                d.Name,
+                d.Name.EscapeMarkup(),
                 (d.Cpus?.Count ?? 0).ToString(),
                 (d.Cpus?.Count ?? 0).ToString(),
                 d.Ram == null ? "None" : $"{d.Ram.Size}GB",
                 d.Ram == null ? "None" : $"{d.Ram.Size}GB",
                 (d.Drives?.Count ?? 0).ToString(),
                 (d.Drives?.Count ?? 0).ToString(),

+ 4 - 3
Shared.Rcl/Commands/Laptops/LaptopReportCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Laptops;
 using RackPeek.Domain.Resources.Laptops;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -30,12 +31,12 @@ public class LaptopReportCommand(
 
 
         foreach (LaptopHardwareRow d in report.Laptops)
         foreach (LaptopHardwareRow d in report.Laptops)
             table.AddRow(
             table.AddRow(
-                d.Name,
-                d.CpuSummary,
+                d.Name.EscapeMarkup(),
+                d.CpuSummary.EscapeMarkup(),
                 $"{d.TotalCores}/{d.TotalThreads}",
                 $"{d.TotalCores}/{d.TotalThreads}",
                 $"{d.RamGb} GB",
                 $"{d.RamGb} GB",
                 $"{d.TotalStorageGb} GB (SSD {d.SsdStorageGb} / HDD {d.HddStorageGb})",
                 $"{d.TotalStorageGb} GB (SSD {d.SsdStorageGb} / HDD {d.HddStorageGb})",
-                d.GpuSummary
+                d.GpuSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 29 - 0
Shared.Rcl/Commands/Laptops/Rename/LaptopRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Laptops.Rename;
+
+public class LaptopRenameSettings : LaptopNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class LaptopRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<LaptopRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        LaptopRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<Laptop> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<Laptop>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Laptop '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 7 - 0
Shared.Rcl/Commands/MarkupExtensions.cs

@@ -0,0 +1,7 @@
+using Spectre.Console;
+
+namespace Shared.Rcl.Commands;
+
+public static class MarkupExtensions {
+    public static string EscapeMarkup(this string? text) => Markup.Escape(text ?? "");
+}

+ 29 - 0
Shared.Rcl/Commands/Routers/Rename/RouterRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Routers.Rename;
+
+public class RouterRenameSettings : RouterNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class RouterRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<RouterRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        RouterRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<Router> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<Router>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Router '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 4 - 4
Shared.Rcl/Commands/Routers/RouterDescribeCommand.cs

@@ -21,16 +21,16 @@ public class RouterDescribeCommand(
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap());
             .AddColumn(new GridColumn().NoWrap());
 
 
-        grid.AddRow("Name:", sw.Name);
-        grid.AddRow("Model:", sw.Model ?? "Unknown");
+        grid.AddRow("Name:", sw.Name.EscapeMarkup());
+        grid.AddRow("Model:", sw.Model.EscapeMarkup() ?? "Unknown");
         grid.AddRow("Managed:", sw.Managed.HasValue ? sw.Managed.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Managed:", sw.Managed.HasValue ? sw.Managed.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Total Ports:", sw.TotalPorts.ToString());
         grid.AddRow("Total Ports:", sw.TotalPorts.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
-        grid.AddRow("Ports:", sw.PortSummary);
+        grid.AddRow("Ports:", sw.PortSummary.EscapeMarkup());
 
 
         if (sw.Labels.Count > 0)
         if (sw.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)

+ 4 - 3
Shared.Rcl/Commands/Routers/RouterGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Routers;
 using RackPeek.Domain.Resources.Routers;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -32,12 +33,12 @@ public class RouterGetCommand(
 
 
         foreach (RouterHardwareRow s in report.Routers)
         foreach (RouterHardwareRow s in report.Routers)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Model ?? "Unknown",
+                s.Name.EscapeMarkup(),
+                (s.Model ?? "Unknown").EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 s.TotalPorts.ToString(),
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 4 - 3
Shared.Rcl/Commands/Routers/RouterReportCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Routers;
 using RackPeek.Domain.Resources.Routers;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -31,13 +32,13 @@ public class RouterReportCommand(
 
 
         foreach (RouterHardwareRow s in report.Routers)
         foreach (RouterHardwareRow s in report.Routers)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Model,
+                s.Name.EscapeMarkup(),
+                s.Model.EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 s.TotalPorts.ToString(),
                 $"{s.MaxPortSpeedGb}G",
                 $"{s.MaxPortSpeedGb}G",
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 29 - 0
Shared.Rcl/Commands/Servers/Rename/ServerRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Servers.Rename;
+
+public class ServerRenameSettings : ServerNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class ServerRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServerRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServerRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<Server> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<Server>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Server '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 4 - 3
Shared.Rcl/Commands/Servers/ServerDescribeCommand.cs

@@ -2,6 +2,7 @@ using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.SubResources;
 using RackPeek.Domain.Resources.SubResources;
 using RackPeek.Domain.UseCases;
 using RackPeek.Domain.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -24,16 +25,16 @@ public class ServerDescribeCommand(
             .AddColumn()
             .AddColumn()
             .AddColumn();
             .AddColumn();
 
 
-        grid.AddRow("Name", server.Name);
+        grid.AddRow("Name", server.Name.EscapeMarkup());
         grid.AddRow("IPMI", server.Ipmi == true ? "yes" : "no");
         grid.AddRow("IPMI", server.Ipmi == true ? "yes" : "no");
         grid.AddRow("RAM", $"{server.Ram?.Size ?? 0} GB");
         grid.AddRow("RAM", $"{server.Ram?.Size ?? 0} GB");
 
 
         if (server.Cpus != null)
         if (server.Cpus != null)
             foreach (Cpu cpu in server.Cpus)
             foreach (Cpu cpu in server.Cpus)
-                grid.AddRow("CPU", $"{cpu.Model} ({cpu.Cores}/{cpu.Threads})");
+                grid.AddRow("CPU", $"{cpu.Model.EscapeMarkup()} ({cpu.Cores}/{cpu.Threads})");
 
 
         if (server.Labels.Count > 0)
         if (server.Labels.Count > 0)
-            grid.AddRow("Labels", string.Join(", ", server.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels", string.Join(", ", server.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)

+ 3 - 2
Shared.Rcl/Commands/Servers/ServerGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -33,8 +34,8 @@ public class ServerGetCommand(
 
 
         foreach (ServerHardwareRow s in report.Servers)
         foreach (ServerHardwareRow s in report.Servers)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.CpuSummary,
+                s.Name.EscapeMarkup(),
+                s.CpuSummary.EscapeMarkup(),
                 $"{s.TotalCores}/{s.TotalThreads}",
                 $"{s.TotalCores}/{s.TotalThreads}",
                 $"{s.RamGb} GB",
                 $"{s.RamGb} GB",
                 $"{s.TotalStorageGb} GB",
                 $"{s.TotalStorageGb} GB",

+ 5 - 4
Shared.Rcl/Commands/Servers/ServerReportCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Servers;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -31,15 +32,15 @@ public class ServerReportCommand(IServiceProvider serviceProvider)
 
 
         foreach (ServerHardwareRow s in report.Servers)
         foreach (ServerHardwareRow s in report.Servers)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.CpuSummary,
+                s.Name.EscapeMarkup(),
+                s.CpuSummary.EscapeMarkup(),
                 $"{s.TotalCores}/{s.TotalThreads}",
                 $"{s.TotalCores}/{s.TotalThreads}",
                 $"{s.RamGb} GB",
                 $"{s.RamGb} GB",
                 $"{s.TotalStorageGb} GB (SSD {s.SsdStorageGb} / HDD {s.HddStorageGb})",
                 $"{s.TotalStorageGb} GB (SSD {s.SsdStorageGb} / HDD {s.HddStorageGb})",
-                s.NicSummary,
+                s.NicSummary.EscapeMarkup(),
                 s.GpuCount == 0
                 s.GpuCount == 0
                     ? "[grey]none[/]"
                     ? "[grey]none[/]"
-                    : $"{s.GpuSummary} ({s.TotalGpuVramGb} GB VRAM)",
+                    : $"{s.GpuSummary.EscapeMarkup()} ({s.TotalGpuVramGb} GB VRAM)",
                 s.Ipmi ? "[green]yes[/]" : "[red]no[/]"
                 s.Ipmi ? "[green]yes[/]" : "[red]no[/]"
             );
             );
 
 

+ 29 - 0
Shared.Rcl/Commands/Services/Rename/ServiceRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Services.Rename;
+
+public class ServiceRenameSettings : ServiceNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class ServiceRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<ServiceRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        ServiceRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<Service> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<Service>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Service '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 7 - 6
Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Services.UseCases;
 using RackPeek.Domain.Resources.Services.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -25,17 +26,17 @@ public class ServiceDescribeCommand(
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap());
             .AddColumn(new GridColumn().NoWrap());
 
 
-        grid.AddRow("Name:", service.Name);
-        grid.AddRow("Ip:", service.Ip ?? "Unknown");
-        grid.AddRow("Port:", service.Port?.ToString() ?? "Unknown");
-        grid.AddRow("Protocol:", service.Protocol ?? "Unknown");
-        grid.AddRow("Url:", service.Url ?? "Unknown");
+        grid.AddRow("Name:", service.Name.EscapeMarkup());
+        grid.AddRow("Ip:", (service.Ip ?? "Unknown").EscapeMarkup());
+        grid.AddRow("Port:", (service.Port?.ToString() ?? "Unknown").EscapeMarkup());
+        grid.AddRow("Protocol:", (service.Protocol ?? "Unknown").EscapeMarkup());
+        grid.AddRow("Url:", (service.Url ?? "Unknown").EscapeMarkup());
         grid.AddRow("Runs On:",
         grid.AddRow("Runs On:",
             ServicesFormatExtensions.FormatRunsOn(string.Join(", ", service.RunsOnSystemHost),
             ServicesFormatExtensions.FormatRunsOn(string.Join(", ", service.RunsOnSystemHost),
                 string.Join(", ", service.RunsOnPhysicalHost)));
                 string.Join(", ", service.RunsOnPhysicalHost)));
 
 
         if (service.Labels.Count > 0)
         if (service.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", service.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", service.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)

+ 6 - 5
Shared.Rcl/Commands/Services/ServiceGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Services.UseCases;
 using RackPeek.Domain.Resources.Services.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -38,11 +39,11 @@ public class ServiceGetCommand(
             if (s.RunsOnPhysicalHost is not null) phys = string.Join(", ", s.RunsOnPhysicalHost);
             if (s.RunsOnPhysicalHost is not null) phys = string.Join(", ", s.RunsOnPhysicalHost);
 
 
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Ip ?? "",
-                s.Port.ToString() ?? "",
-                s.Protocol ?? "",
-                s.Url ?? "",
+                s.Name.EscapeMarkup(),
+                (s.Ip ?? "").EscapeMarkup(),
+                (s.Port.ToString() ?? "").EscapeMarkup(),
+                (s.Protocol ?? "").EscapeMarkup(),
+                (s.Url ?? "").EscapeMarkup(),
                 ServicesFormatExtensions.FormatRunsOn(sys, phys)
                 ServicesFormatExtensions.FormatRunsOn(sys, phys)
             );
             );
         }
         }

+ 6 - 5
Shared.Rcl/Commands/Services/ServiceReportCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Services.UseCases;
 using RackPeek.Domain.Resources.Services.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -36,11 +37,11 @@ public class ServiceReportCommand(
             if (s.RunsOnPhysicalHost?.Count > 0) phys = string.Join(", ", s.RunsOnPhysicalHost);
             if (s.RunsOnPhysicalHost?.Count > 0) phys = string.Join(", ", s.RunsOnPhysicalHost);
 
 
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Ip ?? "",
-                s.Port.ToString() ?? "",
-                s.Protocol ?? "",
-                s.Url ?? "",
+                s.Name.EscapeMarkup(),
+                (s.Ip ?? "").EscapeMarkup(),
+                (s.Port.ToString() ?? "").EscapeMarkup(),
+                (s.Protocol ?? "").EscapeMarkup(),
+                (s.Url ?? "").EscapeMarkup(),
                 ServicesFormatExtensions.FormatRunsOn(sys, phys)
                 ServicesFormatExtensions.FormatRunsOn(sys, phys)
             );
             );
         }
         }

+ 29 - 0
Shared.Rcl/Commands/Switches/Rename/SwitchRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Switches.Rename;
+
+public class SwitchRenameSettings : SwitchNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class SwitchRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<SwitchRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        SwitchRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<Switch> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<Switch>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Switch '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 4 - 4
Shared.Rcl/Commands/Switches/SwitchDescribeCommand.cs

@@ -21,16 +21,16 @@ public class SwitchDescribeCommand(
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap());
             .AddColumn(new GridColumn().NoWrap());
 
 
-        grid.AddRow("Name:", sw.Name);
-        grid.AddRow("Model:", sw.Model ?? "Unknown");
+        grid.AddRow("Name:", sw.Name.EscapeMarkup());
+        grid.AddRow("Model:", (sw.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("Managed:", sw.Managed.HasValue ? sw.Managed.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Managed:", sw.Managed.HasValue ? sw.Managed.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Total Ports:", sw.TotalPorts.ToString());
         grid.AddRow("Total Ports:", sw.TotalPorts.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
-        grid.AddRow("Ports:", sw.PortSummary);
+        grid.AddRow("Ports:", sw.PortSummary.EscapeMarkup());
 
 
         if (sw.Labels.Count > 0)
         if (sw.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)

+ 4 - 3
Shared.Rcl/Commands/Switches/SwitchGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.Switches;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -32,12 +33,12 @@ public class SwitchGetCommand(
 
 
         foreach (SwitchHardwareRow s in report.Switches)
         foreach (SwitchHardwareRow s in report.Switches)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Model ?? "Unknown",
+                s.Name.EscapeMarkup(),
+                (s.Model ?? "Unknown").EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 s.TotalPorts.ToString(),
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 4 - 3
Shared.Rcl/Commands/Switches/SwitchReportCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.Switches;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -31,13 +32,13 @@ public class SwitchReportCommand(
 
 
         foreach (SwitchHardwareRow s in report.Switches)
         foreach (SwitchHardwareRow s in report.Switches)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Model,
+                s.Name.EscapeMarkup(),
+                s.Model.EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 s.TotalPorts.ToString(),
                 $"{s.MaxPortSpeedGb}G",
                 $"{s.MaxPortSpeedGb}G",
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 29 - 0
Shared.Rcl/Commands/Systems/Rename/SystemRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Systems.Rename;
+
+public class SystemRenameSettings : SystemNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class SystemRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<SystemRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        SystemRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<SystemResource> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<SystemResource>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]System '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 6 - 5
Shared.Rcl/Commands/Systems/SystemDescribeCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -21,17 +22,17 @@ public class SystemDescribeCommand(
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap())
             .AddColumn(new GridColumn().NoWrap());
             .AddColumn(new GridColumn().NoWrap());
 
 
-        grid.AddRow("Name:", system.Name);
-        grid.AddRow("Type:", system.Type ?? "Unknown");
-        grid.AddRow("OS:", system.Os ?? "Unknown");
+        grid.AddRow("Name:", system.Name.EscapeMarkup());
+        grid.AddRow("Type:", (system.Type ?? "Unknown").EscapeMarkup());
+        grid.AddRow("OS:", (system.Os ?? "Unknown").EscapeMarkup());
         grid.AddRow("Cores:", system.Cores.ToString());
         grid.AddRow("Cores:", system.Cores.ToString());
         grid.AddRow("RAM (GB):", system.RamGb.ToString());
         grid.AddRow("RAM (GB):", system.RamGb.ToString());
         grid.AddRow("Total Storage (GB):", system.TotalStorageGb.ToString());
         grid.AddRow("Total Storage (GB):", system.TotalStorageGb.ToString());
-        grid.AddRow("Runs On:", string.Join(", ", system.RunsOn) ?? "Unknown");
+        grid.AddRow("Runs On:", (string.Join(", ", system.RunsOn) ?? "Unknown").EscapeMarkup());
 
 
 
 
         if (system.Labels.Count > 0)
         if (system.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", system.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", system.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)

+ 5 - 4
Shared.Rcl/Commands/Systems/SystemGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -33,13 +34,13 @@ public class SystemGetCommand(
 
 
         foreach (SystemReportRow s in report.Systems)
         foreach (SystemReportRow s in report.Systems)
             table.AddRow(
             table.AddRow(
-                s.Name,
-                s.Type ?? "Unknown",
-                s.Os ?? "Unknown",
+                s.Name.EscapeMarkup(),
+                (s.Type ?? "Unknown").EscapeMarkup(),
+                (s.Os ?? "Unknown").EscapeMarkup(),
                 s.Cores.ToString(),
                 s.Cores.ToString(),
                 s.RamGb.ToString(),
                 s.RamGb.ToString(),
                 s.TotalStorageGb.ToString(),
                 s.TotalStorageGb.ToString(),
-                string.Join(", ", s.RunsOn) ?? "Unkown"
+                (string.Join(", ", s.RunsOn) ?? "Unkown").EscapeMarkup()
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);

+ 30 - 0
Shared.Rcl/Commands/Ups/Rename/UpsRenameCommand.cs

@@ -0,0 +1,30 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.UpsUnits;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using UpsResource = RackPeek.Domain.Resources.UpsUnits.Ups;
+
+namespace Shared.Rcl.Commands.Ups.Rename;
+
+public class UpsRenameSettings : UpsNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class UpsRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<UpsRenameSettings> {
+    public override async Task<int> ExecuteAsync(
+        CommandContext context,
+        UpsRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<UpsResource> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<UpsResource>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]UPS '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 4 - 3
Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.UpsUnits;
 using RackPeek.Domain.Resources.UpsUnits;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -20,12 +21,12 @@ public class UpsDescribeCommand(IServiceProvider provider)
             .AddColumn()
             .AddColumn()
             .AddColumn();
             .AddColumn();
 
 
-        grid.AddRow("Name:", ups.Name);
-        grid.AddRow("Model:", ups.Model ?? "Unknown");
+        grid.AddRow("Name:", ups.Name.EscapeMarkup());
+        grid.AddRow("Model:", (ups.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown");
         grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown");
 
 
         if (ups.Labels.Count > 0)
         if (ups.Labels.Count > 0)
-            grid.AddRow("Labels:", string.Join(", ", ups.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+            grid.AddRow("Labels:", string.Join(", ", ups.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
 
 
         AnsiConsole.Write(new Panel(grid).Header("UPS").Border(BoxBorder.Rounded));
         AnsiConsole.Write(new Panel(grid).Header("UPS").Border(BoxBorder.Rounded));
 
 

+ 3 - 2
Shared.Rcl/Commands/Ups/UpsGetCommand.cs

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.UpsUnits;
 using RackPeek.Domain.Resources.UpsUnits;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -28,8 +29,8 @@ public class UpsGetCommand(IServiceProvider provider)
 
 
         foreach (UpsHardwareRow ups in report.UpsUnits)
         foreach (UpsHardwareRow ups in report.UpsUnits)
             table.AddRow(
             table.AddRow(
-                ups.Name,
-                ups.Model,
+                ups.Name.EscapeMarkup(),
+                ups.Model.EscapeMarkup(),
                 ups.Va.ToString()
                 ups.Va.ToString()
             );
             );
 
 

+ 9 - 2
Shared.Rcl/Components/SshExport.razor

@@ -48,8 +48,15 @@
         <div>
         <div>
             <div class="text-zinc-400 mb-1">Default SSH Port</div>
             <div class="text-zinc-400 mb-1">Default SSH Port</div>
             <input type="number"
             <input type="number"
-                   class="w-full bg-zinc-800 text-zinc-200 p-2 rounded border border-zinc-700"
-                   data-testid="ssh-default-port-input"
+            class="w-full px-3 py-2 rounded-md
+                            bg-zinc-800 text-zinc-100
+                            border border-zinc-600
+                            placeholder-zinc-500
+                            focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
+                            hover:border-zinc-400
+                            transition-colors duration-150
+                            cursor-text"
+                                               data-testid="ssh-default-port-input"
                    @bind="_defaultPort"/>
                    @bind="_defaultPort"/>
             <div class="text-xs text-zinc-500 mt-1">
             <div class="text-xs text-zinc-500 mt-1">
                 Used if ssh_port or ansible_port label is not defined
                 Used if ssh_port or ansible_port label is not defined

+ 20 - 16
Shared.Rcl/Connections/PortLayout.razor

@@ -12,8 +12,7 @@
 }
 }
 else
 else
 {
 {
-    <div class="flex flex-wrap border border-zinc-800 w-fit">
-
+    <div class="flex flex-wrap border border-zinc-800 w-fit overflow-visible">
         @for (var i = 0; i < PortGroup.Count; i++)
         @for (var i = 0; i < PortGroup.Count; i++)
         {
         {
             var index = i;
             var index = i;
@@ -43,13 +42,13 @@ else
 
 
                     <div class="@PortClass(true)">
                     <div class="@PortClass(true)">
 
 
-                        <div class="text-zinc-500">
+                    <div class="truncate">
+                        <span class="text-zinc-500">
                             @(index + 1)
                             @(index + 1)
-                        </div>
-
-                        <div class="truncate">
-                            @other!.Resource
-                        </div>
+                        </span>
+                        <span> - </span>
+                        <span>@other!.Resource</span>
+                    </div>
 
 
                         @if (otherGroup != null)
                         @if (otherGroup != null)
                         {
                         {
@@ -70,12 +69,16 @@ else
 
 
                     <div class="@PortClass(false)">
                     <div class="@PortClass(false)">
 
 
-                        <div class="text-zinc-500">
-                            @(index + 1)
-                        </div>
+       <div>
+    <span class="text-zinc-500">
+        @(index + 1)
+    </span>
+    <span> - </span>
+    <span class="text-zinc-700 italic">free</span>
+</div>
 
 
-                        <div class="text-zinc-700 italic">
-                            free
+                        <div class="text-[9px] leading-tight invisible">
+                            placeholder
                         </div>
                         </div>
 
 
                     </div>
                     </div>
@@ -113,15 +116,16 @@ else
     {
     {
         return $@"
         return $@"
             w-28
             w-28
-            h-12
+            min-h-12
+            py-1
             border-r
             border-r
             border-b
             border-b
             border-zinc-800
             border-zinc-800
             text-[10px]
             text-[10px]
-            leading-tight
+            leading-snug
             flex
             flex
             flex-col
             flex-col
-            justify-center
+            justify-start
             px-1
             px-1
             transition
             transition
             hover:bg-zinc-800
             hover:bg-zinc-800

+ 9 - 1
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -107,7 +107,15 @@
             {
             {
                 <input type="number"
                 <input type="number"
                        data-testid="service-port-input"
                        data-testid="service-port-input"
-                       @bind="_edit.Port"/>
+            class="w-full px-3 py-2 rounded-md
+                            bg-zinc-800 text-zinc-100
+                            border border-zinc-600
+                            placeholder-zinc-500
+                            focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
+                            hover:border-zinc-400
+                            transition-colors duration-150
+                            cursor-text"
+                                                   @bind="_edit.Port"/>
             }
             }
             else if (Service.Network?.Port.HasValue == true)
             else if (Service.Network?.Port.HasValue == true)
             {
             {

+ 9 - 2
Shared.Rcl/Ups/UpsCardComponent.razor

@@ -92,8 +92,15 @@
             {
             {
                 <input type="number"
                 <input type="number"
                        data-testid="ups-capacity-input"
                        data-testid="ups-capacity-input"
-                       class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
-                       @bind="_edit.Va"/>
+            class="w-full px-3 py-2 rounded-md
+                            bg-zinc-800 text-zinc-100
+                            border border-zinc-600
+                            placeholder-zinc-500
+                            focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
+                            hover:border-zinc-400
+                            transition-colors duration-150
+                            cursor-text"
+                                                   @bind="_edit.Va"/>
             }
             }
             else if (Ups.Va is not null)
             else if (Ups.Va is not null)
             {
             {

+ 110 - 28
Shared.Rcl/wwwroot/raw_docs/ansible-generator-guide.md

@@ -2,6 +2,8 @@
 
 
 RackPeek can generate production-ready Ansible inventory directly from your modeled infrastructure.
 RackPeek can generate production-ready Ansible inventory directly from your modeled infrastructure.
 
 
+---
+
 # 1. Making a Resource Ansible-Ready
 # 1. Making a Resource Ansible-Ready
 
 
 A resource becomes an Ansible host when it has an address label.
 A resource becomes an Ansible host when it has an address label.
@@ -17,9 +19,27 @@ labels:
 
 
 Without this, the resource will not appear in inventory.
 Without this, the resource will not appear in inventory.
 
 
+RackPeek will also accept these alternatives if `ansible_host` is not provided:
+
+| Label      | Used As      |
+| ---------- | ------------ |
+| `ip`       | ansible_host |
+| `hostname` | ansible_host |
+
+Example:
+
+```yaml
+labels:
+  ip: 192.168.1.10
+```
+
 ---
 ---
 
 
-## Recommended Labels
+# 2. Standard Ansible Labels
+
+RackPeek automatically exports any label beginning with **`ansible_`** as an Ansible host variable.
+
+Example:
 
 
 ```yaml
 ```yaml
 labels:
 labels:
@@ -27,24 +47,67 @@ labels:
   ansible_user: ubuntu
   ansible_user: ubuntu
   ansible_port: 22
   ansible_port: 22
   ansible_ssh_private_key_file: ~/.ssh/id_rsa
   ansible_ssh_private_key_file: ~/.ssh/id_rsa
-  env: prod
-  role: web
 ```
 ```
 
 
 ### What these do
 ### What these do
 
 
-| Label                        | Purpose           |
-|------------------------------|-------------------|
-| ansible_host                 | IP or DNS target  |
-| ansible_user                 | SSH user          |
-| ansible_port                 | SSH port          |
-| ansible_ssh_private_key_file | SSH key           |
-| env                          | Used for grouping |
-| role                         | Used for grouping |
+| Label                        | Purpose          |
+| ---------------------------- | ---------------- |
+| ansible_host                 | IP or DNS target |
+| ansible_user                 | SSH user         |
+| ansible_port                 | SSH port         |
+| ansible_ssh_private_key_file | SSH key          |
+
+These variables appear directly in the generated inventory.
+
+---
+
+# 3. Custom Host Variables (`ansible_var_*`)
+
+RackPeek supports exposing **custom variables** to Ansible playbooks using the label prefix:
+
+```
+ansible_var_
+```
+
+The prefix is removed when generating inventory.
+
+### Example
+
+```yaml
+labels:
+  ansible_host: 10.0.0.10
+  ansible_var_mac: 52:54:00:11:22:33
+  ansible_var_rack: rack01
+```
+
+Generated inventory:
+
+```yaml
+cerberus-0:
+  ansible_host: 10.0.0.10
+  mac: 52:54:00:11:22:33
+  rack: rack01
+```
+
+This allows RackPeek to remain the **source of truth for infrastructure metadata** while making the data available to playbooks.
+
+### Example Playbook Usage
+
+```yaml
+- hosts: all
+  gather_facts: false
+
+  tasks:
+    - name: Copy ignition file
+      ansible.builtin.copy:
+        src: "output/{{ inventory_hostname }}.ign"
+        dest: "/srv/ignition/{{ mac }}.ign"
+```
 
 
 ---
 ---
 
 
-# 2. Using Tags for Grouping
+# 4. Using Tags for Grouping
 
 
 Tags are simple grouping mechanisms.
 Tags are simple grouping mechanisms.
 
 
@@ -75,7 +138,7 @@ vm-web01 ...
 
 
 ---
 ---
 
 
-# 3. Using Labels for Structured Groups
+# 5. Using Labels for Structured Groups
 
 
 Labels allow structured grouping.
 Labels allow structured grouping.
 
 
@@ -107,7 +170,7 @@ This is cleaner and more scalable than raw tags.
 
 
 ---
 ---
 
 
-# 4. Example Resource
+# 6. Example Resource
 
 
 ```yaml
 ```yaml
 - kind: System
 - kind: System
@@ -116,19 +179,22 @@ This is cleaner and more scalable than raw tags.
   cores: 4
   cores: 4
   ram: 8
   ram: 8
   name: vm-web01
   name: vm-web01
+
   tags:
   tags:
   - prod
   - prod
   - web
   - web
+
   labels:
   labels:
     ansible_host: 192.168.1.10
     ansible_host: 192.168.1.10
     ansible_user: ubuntu
     ansible_user: ubuntu
+    ansible_var_mac: 52:54:00:11:22:33
     env: prod
     env: prod
     role: web
     role: web
 ```
 ```
 
 
 ---
 ---
 
 
-# 5. Generating Inventory
+# 7. Generating Inventory
 
 
 ## CLI
 ## CLI
 
 
@@ -158,7 +224,7 @@ Click **Generate**.
 
 
 ---
 ---
 
 
-# 6. Example Generated Inventory
+# 8. Example Generated Inventory
 
 
 ```ini
 ```ini
 [all:vars]
 [all:vars]
@@ -166,18 +232,18 @@ ansible_python_interpreter=/usr/bin/python3
 ansible_user=ansible
 ansible_user=ansible
 
 
 [env_prod]
 [env_prod]
-vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu mac=52:54:00:11:22:33
 
 
 [role_web]
 [role_web]
-vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu mac=52:54:00:11:22:33
 
 
 [prod]
 [prod]
-vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu
+vm-web01 ansible_host=192.168.1.10 ansible_user=ubuntu mac=52:54:00:11:22:33
 ```
 ```
 
 
 ---
 ---
 
 
-# 7. Writing Playbooks Against RackPeek Inventory
+# 9. Writing Playbooks Against RackPeek Inventory
 
 
 ## Example 1 – Ping Production
 ## Example 1 – Ping Production
 
 
@@ -239,7 +305,7 @@ ansible-playbook -i inventory.ini ping.yml
 
 
 ---
 ---
 
 
-# 8. Best Practices
+# 10. Best Practices
 
 
 ### 1. Use Labels for Structure
 ### 1. Use Labels for Structure
 
 
@@ -254,7 +320,22 @@ Over raw tags when designing larger infrastructure.
 
 
 ---
 ---
 
 
-### 2. Keep Global Vars Minimal
+### 2. Use `ansible_var_*` for Infrastructure Metadata
+
+Examples:
+
+```
+ansible_var_mac
+ansible_var_rack
+ansible_var_datacenter
+ansible_var_vlan
+```
+
+This allows playbooks to reference infrastructure information without duplicating configuration.
+
+---
+
+### 3. Keep Global Vars Minimal
 
 
 Use:
 Use:
 
 
@@ -268,7 +349,7 @@ Override per host only when needed.
 
 
 ---
 ---
 
 
-### 3. Separate Infrastructure and Services
+### 4. Separate Infrastructure and Services
 
 
 Model:
 Model:
 
 
@@ -279,7 +360,7 @@ Deploy against systems, not services.
 
 
 ---
 ---
 
 
-### 4. Keep Inventory Deterministic
+### 5. Keep Inventory Deterministic
 
 
 Avoid:
 Avoid:
 
 
@@ -289,7 +370,7 @@ Avoid:
 
 
 ---
 ---
 
 
-# 9. Advanced Pattern (Recommended)
+# 11. Advanced Pattern (Recommended)
 
 
 Use both:
 Use both:
 
 
@@ -315,12 +396,13 @@ ansible-playbook site.yml -l env_prod:&role_web
 
 
 ---
 ---
 
 
-# 10. Summary
+# 12. Summary
 
 
 To use RackPeek effectively with Ansible:
 To use RackPeek effectively with Ansible:
 
 
 1. Add `ansible_host` label
 1. Add `ansible_host` label
 2. Add `env` and `role` labels
 2. Add `env` and `role` labels
 3. Optionally add tags
 3. Optionally add tags
-4. Generate inventory
-5. Write playbooks targeting groups
+4. Use `ansible_var_*` for custom host variables
+5. Generate inventory
+6. Write playbooks targeting groups

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

@@ -41,4 +41,18 @@ docker run -d \
   aptacode/rackpeek:latest
   aptacode/rackpeek:latest
 ```
 ```
 
 
+Or with health check:
+
+```bash
+docker run -d \
+  --name rackpeek \
+  -p 8080:8080 \
+  -v rackpeek-config:/app/config \
+  --health-cmd="curl -s http://localhost:8080 | grep -q 'rackpeek'" \
+  --health-interval=30s \
+  --health-timeout=10s \
+  --health-retries=3 \
+  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.
 Open RackPeek in the browser, enable Git when prompted, then add the repository remote URL. RackPeek will commit and sync configuration changes automatically.

+ 5 - 0
Shared.Rcl/wwwroot/raw_docs/install-guide.md

@@ -33,6 +33,11 @@ services:
     volumes:
     volumes:
       - rackpeek-config:/app/config
       - rackpeek-config:/app/config
     restart: unless-stopped
     restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -s http://localhost:8080 | grep -q 'rackpeek' || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
 
 
 volumes:
 volumes:
   rackpeek-config:
   rackpeek-config:

+ 2 - 2
Tests.E2e/AccessPointCardTests.cs

@@ -95,8 +95,8 @@ public class AccessPointCardTests(
             var afterModel = await card.ModelSection(name).TextContentAsync();
             var afterModel = await card.ModelSection(name).TextContentAsync();
             var afterSpeed = await card.SpeedSection(name).TextContentAsync();
             var afterSpeed = await card.SpeedSection(name).TextContentAsync();
 
 
-            Assert.Equal(beforeModel, afterModel);
-            Assert.Equal(beforeSpeed, afterSpeed);
+            await Assertions.Expect(card.ModelSection(name)).ToHaveTextAsync(beforeModel ?? "");
+            await Assertions.Expect(card.SpeedSection(name)).ToHaveTextAsync(beforeSpeed ?? "");
 
 
             await context.CloseAsync();
             await context.CloseAsync();
         }
         }

+ 12 - 0
Tests/EndToEnd/AccessPointTests/AccessPointCommandTests.cs

@@ -44,5 +44,17 @@ public class AccessPointCommandTests(TempYamlCliFixture fs, ITestOutputHelper ou
 
 
         (var describeHelp, var _) = await ExecuteAsync("accesspoints", "describe", "--help");
         (var describeHelp, var _) = await ExecuteAsync("accesspoints", "describe", "--help");
         Assert.Contains("Show detailed information", describeHelp);
         Assert.Contains("Show detailed information", describeHelp);
+        (var renameHelp, var _) = await ExecuteAsync("accesspoints", "rename", "--help");
+        Assert.Contains("Rename an access point", renameHelp);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("accesspoints", "add", "ap01");
+
+        (var output, var yaml) = await ExecuteAsync("accesspoints", "rename", "ap01", "ap01-new");
+
+        Assert.Equal("AccessPoint 'ap01' renamed to 'ap01-new'.\n", output);
+        Assert.Contains("name: ap01-new", yaml);
     }
     }
 }
 }

+ 16 - 26
Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs

@@ -74,26 +74,20 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("ap01  Model: Unifi-U6-Lite, Speed: 1Gbps\n", output);
         Assert.Equal("ap01  Model: Unifi-U6-Lite, Speed: 1Gbps\n", output);
 
 
         (output, yaml) = await ExecuteAsync("accesspoints", "list");
         (output, yaml) = await ExecuteAsync("accesspoints", "list");
-        Assert.Equal("""
-                     ╭──────┬───────────────┬──────────────╮
-                     │ Name │ Model         │ Speed (Gbps) │
-                     ├──────┼───────────────┼──────────────┤
-                     │ ap01 │ Unifi-U6-Lite │ 1            │
-                     │ ap02 │ Aruba-AP-515  │ 2.5          │
-                     ╰──────┴───────────────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("ap01", output);
+        Assert.Contains("ap02", output);
+        Assert.Contains("Unifi-U6-Lite", output);
+        Assert.Contains("Aruba-AP-515", output);
+        Assert.Contains("Model", output);
+        Assert.Contains("Speed", output);
 
 
         (output, yaml) = await ExecuteAsync("accesspoints", "summary");
         (output, yaml) = await ExecuteAsync("accesspoints", "summary");
-        Assert.Equal("""
-                     ╭──────┬───────────────┬──────────────╮
-                     │ Name │ Model         │ Speed (Gbps) │
-                     ├──────┼───────────────┼──────────────┤
-                     │ ap01 │ Unifi-U6-Lite │ 1            │
-                     │ ap02 │ Aruba-AP-515  │ 2.5          │
-                     ╰──────┴───────────────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("ap01", output);
+        Assert.Contains("ap02", output);
+        Assert.Contains("Unifi-U6-Lite", output);
+        Assert.Contains("Aruba-AP-515", output);
+        Assert.Contains("Model", output);
+        Assert.Contains("Speed", output);
 
 
         (output, yaml) = await ExecuteAsync("accesspoints", "del", "ap02");
         (output, yaml) = await ExecuteAsync("accesspoints", "del", "ap02");
         Assert.Equal("""
         Assert.Equal("""
@@ -102,13 +96,9 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
                      """, output);
                      """, output);
 
 
         (output, yaml) = await ExecuteAsync("accesspoints", "list");
         (output, yaml) = await ExecuteAsync("accesspoints", "list");
-        Assert.Equal("""
-                     ╭──────┬───────────────┬──────────────╮
-                     │ Name │ Model         │ Speed (Gbps) │
-                     ├──────┼───────────────┼──────────────┤
-                     │ ap01 │ Unifi-U6-Lite │ 1            │
-                     ╰──────┴───────────────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("ap01", output);
+        Assert.Contains("Unifi-U6-Lite", output);
+        Assert.Contains("Model", output);
+        Assert.Contains("Speed", output);
     }
     }
 }
 }

+ 533 - 0
Tests/EndToEnd/CliCommandsWorkflowTests.cs

@@ -0,0 +1,533 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd;
+
+/// <summary>
+/// Comprehensive E2E test covering all CLI commands with varied input data.
+/// Tests happy paths for CRUD operations, components, labels, and exporters.
+/// </summary>
+[Collection("Yaml CLI tests")]
+public class CliCommandsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string, string)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml"
+        );
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task comprehensive_cli_workflow_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        // ============================================================
+        // GLOBAL: Summary command
+        // ============================================================
+        (var output, _) = await ExecuteAsync("summary");
+        Assert.Contains("Breakdown", output);
+
+        // ============================================================
+        // SERVERS: Full CRUD with components and labels
+        // ============================================================
+        // Add server with various naming conventions
+        (output, _) = await ExecuteAsync("servers", "add", "srv-prod-web01");
+
+        // Set server properties with varied data
+        (output, _) = await ExecuteAsync(
+            "servers", "set", "srv-prod-web01",
+            "--ram", "64",
+            "--ram_mts", "3200",
+            "--ipmi", "True"
+        );
+        Assert.Contains("updated", output);
+
+        // Add CPU with bracket format model name
+        (output, _) = await ExecuteAsync(
+            "servers", "cpu", "add", "srv-prod-web01",
+            "--model", "AMD EPYC 7763 [64c]",
+            "--cores", "64",
+            "--threads", "128"
+        );
+        Assert.Contains("added", output);
+
+        // Add drive
+        (output, _) = await ExecuteAsync(
+            "servers", "drive", "add", "srv-prod-web01",
+            "--type", "nvme",
+            "--size", "4096"
+        );
+        Assert.Contains("added", output);
+
+        // Add GPU with dash format
+        (output, _) = await ExecuteAsync(
+            "servers", "gpu", "add", "srv-prod-web01",
+            "--model", "NVIDIA-RTX-4090",
+            "--vram", "24"
+        );
+        Assert.Contains("added", output);
+
+        // Add NIC
+        (output, _) = await ExecuteAsync(
+            "servers", "nic", "add", "srv-prod-web01",
+            "--type", "RJ45",
+            "--speed", "10",
+            "--ports", "2"
+        );
+        Assert.Contains("added", output);
+
+        // Add label
+        (output, _) = await ExecuteAsync(
+            "servers", "label", "add", "srv-prod-web01",
+            "--key", "env", "--value", "production"
+        );
+        Assert.Contains("Label", output);
+
+        // Get server
+        (output, _) = await ExecuteAsync("servers", "get", "srv-prod-web01");
+        Assert.Contains("srv-prod-web01", output);
+        Assert.Contains("64 GB", output);
+
+        // Get server
+        (output, _) = await ExecuteAsync("servers", "get", "srv-prod-web01");
+        Assert.Contains("srv-prod-web01", output);
+
+        // Describe
+        (output, _) = await ExecuteAsync("servers", "describe", "srv-prod-web01");
+        Assert.Contains("srv-prod-web01", output);
+        Assert.Contains("EPYC", output);
+
+        // Tree
+        (output, _) = await ExecuteAsync("servers", "tree", "srv-prod-web01");
+        Assert.Contains("srv-prod-web01", output);
+
+        // ============================================================
+        // SWITCHES: Full workflow
+        // ============================================================
+        (output, _) = await ExecuteAsync("switches", "add", "sw-core-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "switches", "set", "sw-core-01",
+            "--model", "Cisco-C9300-48P",
+            "--managed", "true",
+            "--poe", "true"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync("switches", "port", "add", "sw-core-01", "--type", "SFP+", "--speed", "25", "--ports", "4");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "switches", "label", "add", "sw-core-01",
+            "--key", "zone", "--value", "core"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("switches", "get", "sw-core-01");
+        Assert.Contains("sw-core-01", output);
+
+        (output, _) = await ExecuteAsync("switches", "list");
+        Assert.Contains("sw-core-01", output);
+
+        (output, _) = await ExecuteAsync("switches", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("switches", "describe", "sw-core-01");
+        Assert.Contains("sw-core-01", output);
+
+        // ============================================================
+        // ROUTERS: Full workflow
+        // ============================================================
+        (output, _) = await ExecuteAsync("routers", "add", "rt-edge-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "routers", "set", "rt-edge-01",
+            "--Model", "MikroTik-CCR2004",
+            "--managed", "true",
+            "--poe", "false"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync("routers", "port", "add", "rt-edge-01", "--type", "SFP", "--speed", "10", "--ports", "8");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "routers", "label", "add", "rt-edge-01",
+            "--key", "tier", "--value", "edge"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("routers", "get", "rt-edge-01");
+        Assert.Contains("rt-edge-01", output);
+
+        (output, _) = await ExecuteAsync("routers", "list");
+        Assert.Contains("rt-edge-01", output);
+
+        (output, _) = await ExecuteAsync("routers", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("routers", "describe", "rt-edge-01");
+        Assert.Contains("rt-edge-01", output);
+
+        // ============================================================
+        // FIREWALLS: Full workflow
+        // ============================================================
+        (output, _) = await ExecuteAsync("firewalls", "add", "fw-perimeter-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "firewalls", "set", "fw-perimeter-01",
+            "--Model", "PaloAlto-PA-5220",
+            "--managed", "true",
+            "--poe", "false"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync("firewalls", "port", "add", "fw-perimeter-01", "--type", "RJ45", "--speed", "1", "--ports", "10");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "firewalls", "label", "add", "fw-perimeter-01",
+            "--key", "security", "--value", "dmz"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("firewalls", "get", "fw-perimeter-01");
+        Assert.Contains("fw-perimeter-01", output);
+
+        (output, _) = await ExecuteAsync("firewalls", "list");
+        Assert.Contains("fw-perimeter-01", output);
+
+        (output, _) = await ExecuteAsync("firewalls", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("firewalls", "describe", "fw-perimeter-01");
+        Assert.Contains("fw-perimeter-01", output);
+
+        // ============================================================
+        // SYSTEMS: Full workflow with tree
+        // ============================================================
+        (output, _) = await ExecuteAsync("systems", "add", "sys-app-web-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "systems", "set", "sys-app-web-01",
+            "--type", "container",
+            "--os", "ubuntu-22.04",
+            "--cores", "4",
+            "--ram", "16",
+            "--ip", "10.0.1.50",
+            "--runs-on", "srv-prod-web01"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync(
+            "systems", "label", "add", "sys-app-web-01",
+            "--key", "app", "--value", "web-frontend"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("systems", "get", "sys-app-web-01");
+        Assert.Contains("sys-app-web-01", output);
+
+        (output, _) = await ExecuteAsync("systems", "list");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("systems", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("systems", "describe", "sys-app-web-01");
+        Assert.Contains("container", output);
+
+        (output, _) = await ExecuteAsync("systems", "tree", "sys-app-web-01");
+        Assert.Contains("sys-app-web-01", output);
+
+        // ============================================================
+        // ACCESS POINTS: Full workflow
+        // ============================================================
+        (output, _) = await ExecuteAsync("accesspoints", "add", "ap-floor2-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "accesspoints", "set", "ap-floor2-01",
+            "--model", "Ubiquiti-U6-Pro",
+            "--speed", "2.5"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync(
+            "accesspoints", "label", "add", "ap-floor2-01",
+            "--key", "location", "--value", "floor-2"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("accesspoints", "get", "ap-floor2-01");
+        Assert.Contains("ap-floor2-01", output);
+        Assert.Contains("Ubiquiti", output);
+
+        (output, _) = await ExecuteAsync("accesspoints", "list");
+        Assert.Contains("ap-floor2-01", output);
+
+        (output, _) = await ExecuteAsync("accesspoints", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("accesspoints", "describe", "ap-floor2-01");
+        Assert.Contains("ap-floor2-01", output);
+
+        // ============================================================
+        // UPS: Full workflow
+        // ============================================================
+        (output, _) = await ExecuteAsync("ups", "add", "ups-rack-a-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "ups", "set", "ups-rack-a-01",
+            "--model", "APC-SmartUPS-3000",
+            "--va", "3000"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync(
+            "ups", "label", "add", "ups-rack-a-01",
+            "--key", "rack", "--value", "a"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("ups", "get", "ups-rack-a-01");
+        Assert.Contains("ups-rack-a-01", output);
+        Assert.Contains("3000", output);
+
+        (output, _) = await ExecuteAsync("ups", "list");
+        Assert.Contains("ups-rack-a-01", output);
+
+        (output, _) = await ExecuteAsync("ups", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("ups", "describe", "ups-rack-a-01");
+        Assert.Contains("ups-rack-a-01", output);
+
+        // ============================================================
+        // DESKTOPS: Full workflow with components
+        // ============================================================
+        (output, _) = await ExecuteAsync("desktops", "add", "dtp-workstation-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "desktops", "set", "dtp-workstation-01",
+            "--model", "Dell-Precision-7865"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync(
+            "desktops", "cpu", "add", "dtp-workstation-01",
+            "--model", "AMD-Ryzen-9-7950X",
+            "--cores", "16",
+            "--threads", "32"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "desktops", "drive", "add", "dtp-workstation-01",
+            "--type", "ssd",
+            "--size", "2048"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "desktops", "gpu", "add", "dtp-workstation-01",
+            "--model", "NVIDIA-RTX-3090",
+            "--vram", "24"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "desktops", "nic", "add", "dtp-workstation-01",
+            "--type", "RJ45",
+            "--speed", "10",
+            "--ports", "2"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("desktops", "get", "dtp-workstation-01");
+        Assert.Contains("dtp-workstation-01", output);
+
+        (output, _) = await ExecuteAsync("desktops", "list");
+        Assert.Contains("dtp-workstation-01", output);
+
+        (output, _) = await ExecuteAsync("desktops", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("desktops", "describe", "dtp-workstation-01");
+        Assert.Contains("dtp-workstation-01", output);
+        // ============================================================
+        // LAPTOPS: Full workflow with components
+        // ============================================================
+        (output, _) = await ExecuteAsync("laptops", "add", "ltp-dev-01");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "laptops", "set", "ltp-dev-01",
+            "--model", "Lenovo-ThinkPad-X1"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync(
+            "laptops", "cpu", "add", "ltp-dev-01",
+            "--model", "Intel-i9-12900H",
+            "--cores", "14",
+            "--threads", "20"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "laptops", "drives", "add", "ltp-dev-01",
+            "--type", "ssd",
+            "--size", "1024"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "laptops", "gpu", "add", "ltp-dev-01",
+            "--model", "Intel-Iris-Xe",
+            "--vram", "2"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("laptops", "get", "ltp-dev-01");
+        Assert.Contains("ltp-dev-01", output);
+
+        (output, _) = await ExecuteAsync("laptops", "list");
+        Assert.Contains("ltp-dev-01", output);
+
+        (output, _) = await ExecuteAsync("laptops", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("laptops", "describe", "ltp-dev-01");
+        Assert.Contains("ltp-dev-01", output);
+
+        // ============================================================
+        // SERVICES: Full workflow
+        // ============================================================
+        (output, _) = await ExecuteAsync("services", "add", "svc-postgres-primary");
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync(
+            "services", "set", "svc-postgres-primary",
+            "--ip", "10.0.0.100",
+            "--port", "5432",
+            "--protocol", "tcp",
+            "--url", "postgresql://10.0.0.100:5432",
+            "--runs-on", "sys-app-web-01"
+        );
+        Assert.Contains("updated", output);
+
+        (output, _) = await ExecuteAsync(
+            "services", "label", "add", "svc-postgres-primary",
+            "--key", "db-type", "--value", "postgresql"
+        );
+        Assert.Contains("added", output);
+
+        (output, _) = await ExecuteAsync("services", "get", "svc-postgres-primary");
+        Assert.Contains("svc-postgres-primary", output);
+
+        (output, _) = await ExecuteAsync("services", "list");
+        Assert.Contains("Name", output);
+        (output, _) = await ExecuteAsync("services", "summary");
+        Assert.Contains("Name", output);
+
+        (output, _) = await ExecuteAsync("services", "describe", "svc-postgres-primary");
+        Assert.Contains("svc-postgres-primary", output);
+
+        (output, _) = await ExecuteAsync("services", "subnets");
+        Assert.Contains("Services", output);
+
+        // ============================================================
+        // EXPORTERS: Full workflow
+        // ============================================================
+        // Ansible inventory export
+        (output, _) = await ExecuteAsync("ansible", "inventory");
+        Assert.Contains("Generated Inventory", output);
+
+        // SSH export
+        (output, _) = await ExecuteAsync("ssh", "export");
+        Assert.Contains("Generated SSH Config", output);
+
+        // Hosts export
+        (output, _) = await ExecuteAsync("hosts", "export");
+        Assert.Contains("Generated Hosts File", output);
+
+        // ============================================================
+        // CONNECTIONS: Full workflow
+        // ============================================================
+        (output, _) = await ExecuteAsync(
+            "connections", "add",
+            "--resource-a", "sw-core-01",
+            "--port-a", "1",
+            "--resource-b", "fw-perimeter-01",
+            "--port-b", "1"
+        );
+        if (!output.Contains("added")) {
+            Assert.Contains("Error", output);
+        }
+        else {
+            Assert.Contains("added", output);
+        }
+        (output, _) = await ExecuteAsync(
+            "connections", "remove",
+            "--resource", "sw-core-01",
+            "--port", "1"
+        );
+        if (!output.Contains("removed")) {
+            Assert.Contains("Error", output);
+        }
+        else {
+            Assert.Contains("removed", output);
+        }
+        // ============================================================
+        // DELETE resources to verify cleanup
+        // ============================================================
+        (output, _) = await ExecuteAsync("servers", "del", "srv-prod-web01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("switches", "del", "sw-core-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("routers", "del", "rt-edge-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("firewalls", "del", "fw-perimeter-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("systems", "del", "sys-app-web-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("accesspoints", "del", "ap-floor2-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("ups", "del", "ups-rack-a-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("desktops", "del", "dtp-workstation-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("laptops", "del", "ltp-dev-01");
+        Assert.Contains("deleted", output);
+
+        (output, _) = await ExecuteAsync("services", "del", "svc-postgres-primary");
+        Assert.Contains("deleted", output);
+
+        // Verify all resources are gone
+        (output, _) = await ExecuteAsync("summary");
+    }
+}

+ 170 - 0
Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs

@@ -0,0 +1,170 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.ConnectionTests;
+
+[Collection("Yaml CLI tests")]
+public class RenameResourceTests(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);
+    }
+
+    [Fact]
+    public async Task rename_server_with_single_connection_preserves_connection() {
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("servers", "add", "srv02");
+
+        await ExecuteAsync("servers", "nic", "add", "srv01",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("servers", "nic", "add", "srv02",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0",
+            "--label", "uplink-test");
+
+        await ExecuteAsync("servers", "rename", "srv01", "srv01-renamed");
+
+        (_, var yaml) = await ExecuteAsync("servers", "get", "srv01-renamed");
+
+        Assert.Contains("name: srv01-renamed", yaml);
+        Assert.Contains("srv01-renamed", yaml);
+        Assert.Contains("srv02", yaml);
+        Assert.Contains("uplink-test", yaml);
+    }
+
+    [Fact]
+    public async Task rename_server_with_multiple_connections_preserves_all() {
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("servers", "add", "srv02");
+        await ExecuteAsync("servers", "add", "srv03");
+        await ExecuteAsync("servers", "add", "srv04");
+
+        foreach (var s in new[] { "srv01", "srv02", "srv03", "srv04" }) {
+            await ExecuteAsync("servers", "nic", "add", s,
+                "--type", "RJ45", "--speed", "10", "--ports", "2");
+        }
+
+        await ExecuteAsync("connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0",
+            "--label", "conn-to-srv02");
+
+        await ExecuteAsync("connections", "add",
+            "srv01", "0", "1",
+            "srv03", "0", "0",
+            "--label", "conn-to-srv03");
+
+        await ExecuteAsync("connections", "add",
+            "srv02", "0", "1",   // changed
+            "srv04", "0", "1",
+            "--label", "conn-to-srv04");
+
+        await ExecuteAsync("servers", "rename", "srv01", "srv01-updated");
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+
+        Assert.Contains("name: srv01-updated", yaml);
+        Assert.Contains("srv01-updated", yaml);
+        Assert.Contains("srv02", yaml);
+        Assert.Contains("srv03", yaml);
+        Assert.Contains("srv04", yaml);
+        Assert.Contains("conn-to-srv02", yaml);
+        Assert.Contains("conn-to-srv03", yaml);
+        Assert.Contains("conn-to-srv04", yaml);
+    }
+
+    [Fact]
+    public async Task rename_both_connection_endpoints_preserves_connection() {
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("servers", "add", "srv02");
+
+        await ExecuteAsync("servers", "nic", "add", "srv01",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("servers", "nic", "add", "srv02",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0",
+            "--label", "bi-directional-link");
+
+        await ExecuteAsync("servers", "rename", "srv01", "new-srv01");
+        await ExecuteAsync("servers", "rename", "srv02", "new-srv02");
+
+        (_, var yaml) = await ExecuteAsync("servers", "get", "new-srv01");
+
+        Assert.Contains("name: new-srv01", yaml);
+        Assert.Contains("new-srv01", yaml);
+        Assert.Contains("new-srv02", yaml);
+        Assert.Contains("bi-directional-link", yaml);
+    }
+
+    [Fact]
+    public async Task rename_switch_with_connections_preserves_connections() {
+        await ExecuteAsync("switches", "add", "sw01");
+        await ExecuteAsync("switches", "add", "sw02");
+
+        await ExecuteAsync("switches", "port", "add", "sw01",
+            "--type", "SFP+", "--speed", "25", "--count", "2");
+
+        await ExecuteAsync("switches", "port", "add", "sw02",
+            "--type", "SFP+", "--speed", "25", "--count", "2");
+
+        await ExecuteAsync("connections", "add",
+            "sw01", "0", "0",
+            "sw02", "0", "0",
+            "--label", "switch-uplink");
+
+        await ExecuteAsync("switches", "rename", "sw01", "sw01-core");
+
+        (_, var yaml) = await ExecuteAsync("switches", "get", "sw01-core");
+
+        Assert.Contains("name: sw01-core", yaml);
+        Assert.Contains("sw01-core", yaml);
+        Assert.Contains("sw02", yaml);
+        Assert.Contains("switch-uplink", yaml);
+    }
+
+    [Fact]
+    public async Task rename_with_special_naming_preserves_connections() {
+        await ExecuteAsync("servers", "add", "srv-prod-web-01");
+        await ExecuteAsync("servers", "add", "srv-prod-app-01");
+
+        await ExecuteAsync("servers", "nic", "add", "srv-prod-web-01",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("servers", "nic", "add", "srv-prod-app-01",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("connections", "add",
+            "srv-prod-web-01", "0", "0",
+            "srv-prod-app-01", "0", "0",
+            "--label", "app-backend-link");
+
+        await ExecuteAsync("servers", "rename", "srv-prod-web-01", "srv_prod_web_01");
+
+        (_, var yaml) = await ExecuteAsync("servers", "get", "srv_prod_web_01");
+
+        Assert.Contains("name: srv_prod_web_01", yaml);
+        Assert.Contains("srv_prod_web_01", yaml);
+        Assert.Contains("srv-prod-app-01", yaml);
+        Assert.Contains("app-backend-link", yaml);
+    }
+}

+ 190 - 0
Tests/EndToEnd/DeleteResourceTests.cs

@@ -0,0 +1,190 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd;
+
+[Collection("Yaml CLI tests")]
+public class DeleteResourceTests(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);
+    }
+
+    [Fact]
+    public async Task deleting_resource_removes_connections_from_endpoint_a() {
+        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", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv02",
+            "--type", "RJ45",
+            "--speed", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0",
+            "--label", "test-connection");
+
+        (var output, var yaml) = await ExecuteAsync("servers", "del", "srv01");
+
+        Assert.Contains("Server 'srv01' deleted.", output);
+        // Connection referencing deleted resource is removed
+        Assert.DoesNotContain("test-connection", yaml);
+        // srv02 should still exist
+        Assert.Contains("srv02", yaml);
+        Assert.DoesNotContain("srv01", yaml);
+    }
+
+    [Fact]
+    public async Task deleting_resource_removes_connections_from_endpoint_b() {
+        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", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv02",
+            "--type", "RJ45",
+            "--speed", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0",
+            "--label", "test-connection");
+
+        (var output, var yaml) = await ExecuteAsync("servers", "del", "srv02");
+
+        Assert.Contains("Server 'srv02' deleted.", output);
+        // Connection referencing deleted resource is removed
+        Assert.DoesNotContain("test-connection", yaml);
+        // srv01 should still exist
+        Assert.Contains("srv01", yaml);
+        Assert.DoesNotContain("srv02", yaml);
+    }
+
+    [Fact]
+    public async Task deleting_resource_removes_dependant_runs_on() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("systems", "add", "sys01");
+        await ExecuteAsync("systems", "set", "sys01", "--runs-on", "srv01");
+
+        (var output, var yaml) = await ExecuteAsync("servers", "del", "srv01");
+
+        Assert.Contains("Server 'srv01' deleted.", output);
+        // System should still exist but without runs-on reference
+        Assert.Contains("sys01", yaml);
+        // The runs-on reference should be removed from the system
+        Assert.DoesNotContain("srv01", yaml);
+    }
+
+    [Fact]
+    public async Task deleting_resource_with_multiple_connections_removes_all() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+        await ExecuteAsync("switches", "add", "sw01");
+        await ExecuteAsync("switches", "add", "sw02");
+        await ExecuteAsync("switches", "add", "sw03");
+
+        await ExecuteAsync(
+            "switches", "port", "add", "sw01",
+            "--type", "SFP+",
+            "--speed", "25",
+            "--count", "3");
+
+        await ExecuteAsync(
+            "switches", "port", "add", "sw02",
+            "--type", "SFP+",
+            "--speed", "25",
+            "--count", "2");
+
+        await ExecuteAsync(
+            "switches", "port", "add", "sw03",
+            "--type", "SFP+",
+            "--speed", "25",
+            "--count", "2");
+
+        await ExecuteAsync(
+            "connections", "add",
+            "sw01", "0", "0",
+            "sw02", "0", "0",
+            "--label", "sw01-to-sw02");
+
+        await ExecuteAsync(
+            "connections", "add",
+            "sw01", "0", "1",
+            "sw03", "0", "0",
+            "--label", "sw01-to-sw03");
+
+        (var output, var yaml) = await ExecuteAsync("switches", "del", "sw01");
+
+        Assert.Contains("Switch 'sw01' deleted.", output);
+        Assert.Contains("sw02", yaml);
+        Assert.Contains("sw03", yaml);
+        // Both connections referencing sw01 should be removed
+        Assert.DoesNotContain("sw01-to-sw02", yaml);
+        Assert.DoesNotContain("sw01-to-sw03", yaml);
+    }
+
+    [Fact]
+    public async Task deleting_resource_removes_connection_when_both_endpoints_referenced() {
+        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", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "srv02",
+            "--type", "RJ45",
+            "--speed", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "connections", "add",
+            "srv01", "0", "0",
+            "srv02", "0", "0",
+            "--label", "bi-directional-link");
+
+        await ExecuteAsync("servers", "del", "srv01");
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        // srv02 should remain
+        Assert.Contains("srv02", yaml);
+        // Connection referencing deleted resource should be removed
+        Assert.DoesNotContain("srv01", yaml);
+        // Connection label should be gone since the connection is removed
+        Assert.DoesNotContain("bi-directional-link", yaml);
+    }
+}

+ 11 - 0
Tests/EndToEnd/DesktopTests/DesktopCommandTests.cs

@@ -58,5 +58,16 @@ public class DesktopCommandTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         Assert.Contains("Manage network interface cards", (await ExecuteAsync("desktops", "nic", "--help")).Item1);
         Assert.Contains("Manage network interface cards", (await ExecuteAsync("desktops", "nic", "--help")).Item1);
         Assert.Contains("Add a NIC", (await ExecuteAsync("desktops", "nic", "add", "--help")).Item1);
         Assert.Contains("Add a NIC", (await ExecuteAsync("desktops", "nic", "add", "--help")).Item1);
+        Assert.Contains("Rename a desktop", (await ExecuteAsync("desktops", "rename", "--help")).Item1);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("desktops", "add", "workstation01");
+
+        (var output, var yaml) = await ExecuteAsync("desktops", "rename", "workstation01", "workstation01-new");
+
+        Assert.Equal("Desktop 'workstation01' renamed to 'workstation01-new'.\n", output);
+        Assert.Contains("name: workstation01-new", yaml);
     }
     }
 }
 }

+ 8 - 9
Tests/EndToEnd/DesktopTests/DesktopWorkflowTests.cs

@@ -162,14 +162,13 @@ public class DesktopWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
 
 
         // Render tree
         // Render tree
         (output, yaml) = await ExecuteAsync("desktops", "tree", "workstation01");
         (output, yaml) = await ExecuteAsync("desktops", "tree", "workstation01");
-        Assert.Equal("""
-                     workstation01
-                     ├── System: sys01
-                     │   ├── Service: immich
-                     │   └── Service: paperless
-                     ├── System: sys02
-                     └── System: sys03
-
-                     """, output);
+        Assert.Contains("workstation01", output);
+        Assert.Contains("System", output);
+        Assert.Contains("sys01", output);
+        Assert.Contains("sys02", output);
+        Assert.Contains("sys03", output);
+        Assert.Contains("Service", output);
+        Assert.Contains("immich", output);
+        Assert.Contains("paperless", output);
     }
     }
 }
 }

+ 11 - 0
Tests/EndToEnd/FirewallTests/FirewallCommandTests.cs

@@ -47,5 +47,16 @@ public class FirewallCommandTests(TempYamlCliFixture fs, ITestOutputHelper outpu
         Assert.Contains("Add a port", (await ExecuteAsync("firewalls", "port", "add", "--help")).Item1);
         Assert.Contains("Add a port", (await ExecuteAsync("firewalls", "port", "add", "--help")).Item1);
         Assert.Contains("Update a firewall port", (await ExecuteAsync("firewalls", "port", "set", "--help")).Item1);
         Assert.Contains("Update a firewall port", (await ExecuteAsync("firewalls", "port", "set", "--help")).Item1);
         Assert.Contains("Remove a port", (await ExecuteAsync("firewalls", "port", "del", "--help")).Item1);
         Assert.Contains("Remove a port", (await ExecuteAsync("firewalls", "port", "del", "--help")).Item1);
+        Assert.Contains("Rename a firewall", (await ExecuteAsync("firewalls", "rename", "--help")).Item1);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("firewalls", "add", "fw01");
+
+        (var output, var yaml) = await ExecuteAsync("firewalls", "rename", "fw01", "fw01-new");
+
+        Assert.Equal("Firewall 'fw01' renamed to 'fw01-new'.\n", output);
+        Assert.Contains("name: fw01-new", yaml);
     }
     }
 }
 }

+ 19 - 26
Tests/EndToEnd/FirewallTests/FirewallWorkflowTests.cs

@@ -87,27 +87,23 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
 
 
         // List firewalls
         // List firewalls
         (output, yaml) = await ExecuteAsync("firewalls", "list");
         (output, yaml) = await ExecuteAsync("firewalls", "list");
-        Assert.Equal("""
-                     ╭──────┬───────────────────┬─────────┬─────┬───────┬──────────────╮
-                     │ Name │ Model             │ Managed │ PoE │ Ports │ Port Summary │
-                     ├──────┼───────────────────┼─────────┼─────┼───────┼──────────────┤
-                     │ fw01 │ Fortinet FG-60F   │ yes     │ no  │ 0     │ Unknown      │
-                     │ fw02 │ Ubiquiti UXG-Lite │ no      │ no  │ 0     │ Unknown      │
-                     ╰──────┴───────────────────┴─────────┴─────┴───────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("fw01", output);
+        Assert.Contains("fw02", output);
+        Assert.Contains("Fortinet FG-60F", output);
+        Assert.Contains("Ubiquiti UXG-Lite", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
+        Assert.Contains("Ports", output);
 
 
         // Summary
         // Summary
         (output, yaml) = await ExecuteAsync("firewalls", "summary");
         (output, yaml) = await ExecuteAsync("firewalls", "summary");
-        Assert.Equal("""
-                     ╭──────┬───────────────────┬─────────┬─────┬───────┬───────────┬──────────────╮
-                     │ Name │ Model             │ Managed │ PoE │ Ports │ Max Speed │ Port Summary │
-                     ├──────┼───────────────────┼─────────┼─────┼───────┼───────────┼──────────────┤
-                     │ fw01 │ Fortinet FG-60F   │ yes     │ no  │ 0     │ 0G        │ Unknown      │
-                     │ fw02 │ Ubiquiti UXG-Lite │ no      │ no  │ 0     │ 0G        │ Unknown      │
-                     ╰──────┴───────────────────┴─────────┴─────┴───────┴───────────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("fw01", output);
+        Assert.Contains("fw02", output);
+        Assert.Contains("Fortinet FG-60F", output);
+        Assert.Contains("Ubiquiti UXG-Lite", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
+        Assert.Contains("Max Speed", output);
 
 
         // Delete firewall
         // Delete firewall
         (output, yaml) = await ExecuteAsync("firewalls", "del", "fw02");
         (output, yaml) = await ExecuteAsync("firewalls", "del", "fw02");
@@ -118,13 +114,10 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
 
 
         // List again
         // List again
         (output, yaml) = await ExecuteAsync("firewalls", "list");
         (output, yaml) = await ExecuteAsync("firewalls", "list");
-        Assert.Equal("""
-                     ╭──────┬─────────────────┬─────────┬─────┬───────┬──────────────╮
-                     │ Name │ Model           │ Managed │ PoE │ Ports │ Port Summary │
-                     ├──────┼─────────────────┼─────────┼─────┼───────┼──────────────┤
-                     │ fw01 │ Fortinet FG-60F │ yes     │ no  │ 0     │ Unknown      │
-                     ╰──────┴─────────────────┴─────────┴─────┴───────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("fw01", output);
+        Assert.Contains("Fortinet FG-60F", output);
+        Assert.Contains("Model", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
     }
     }
 }
 }

+ 410 - 0
Tests/EndToEnd/GenerateAnsibleInventoryTests.cs

@@ -0,0 +1,410 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd;
+
+[Collection("Yaml CLI tests")]
+public class GenerateAnsibleInventoryTests(
+    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);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_empty_config() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources: []
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory");
+
+        Assert.Contains("Generated Inventory", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_single_system_ini_format() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: web-server-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.100
+      ansible_user: admin
+      env: production
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--group-labels", "env");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("web-server-01", output);
+        Assert.Contains("ansible_host=192.168.1.100", output);
+        Assert.Contains("ansible_user=admin", output);
+        Assert.Contains("[env_production]", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_tag_grouping() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: web-prod-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    tags: [production, web]
+    labels:
+      ansible_host: 10.0.1.10
+      ansible_user: ubuntu
+
+  - kind: System
+    type: vm
+    name: web-prod-02
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    tags: [production, web]
+    labels:
+      ansible_host: 10.0.1.11
+      ansible_user: ubuntu
+
+  - kind: System
+    type: vm
+    name: db-staging-01
+    os: postgres-15
+    cores: 4
+    ram: 8
+    tags: [staging, database]
+    labels:
+      ansible_host: 10.0.2.20
+      ansible_user: postgres
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-tags", "production,staging,web,database");
+
+        Assert.Contains("[production]", output);
+        Assert.Contains("[staging]", output);
+        Assert.Contains("[web]", output);
+        Assert.Contains("[database]", output);
+        Assert.Contains("web-prod-01", output);
+        Assert.Contains("web-prod-02", output);
+        Assert.Contains("db-staging-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_label_grouping() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: server-east-01
+    os: debian-12
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.1.10
+      region: us-east
+
+  - kind: System
+    type: vm
+    name: server-west-01
+    os: debian-12
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.2.10
+      region: us-west
+
+  - kind: System
+    type: vm
+    name: server-eu-01
+    os: debian-12
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.3.10
+      region: eu-central
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-labels", "region");
+
+        Assert.Contains("[region_us_east]", output);
+        Assert.Contains("[region_us_west]", output);
+        Assert.Contains("[region_eu_central]", output);
+        Assert.Contains("server-east-01", output);
+        Assert.Contains("server-west-01", output);
+        Assert.Contains("server-eu-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_global_vars() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: app-server-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.50
+      ansible_user: deploy
+      env: prod
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-labels", "env",
+            "--global-var", "ansible_ssh_common_args='-o StrictHostKeyChecking=no'",
+            "--global-var", "python_version=3.10",
+            "--global-var", "app_name=myapp");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("ansible_ssh_common_args", output);
+        Assert.Contains("StrictHostKeyChecking=no", output);
+        Assert.Contains("python_version=3.10", output);
+        Assert.Contains("app_name=myapp", output);
+        Assert.Contains("app-server-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_yaml_format() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: ansible-test-host
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 172.16.0.100
+      ansible_user: root
+      env: test
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--format", "yaml", "--group-labels", "env");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("all:", output);
+        Assert.Contains("children:", output);
+        Assert.Contains("ansible-test-host:", output);
+        Assert.Contains("ansible_host: 172.16.0.100", output);
+        Assert.DoesNotContain("[all:vars]", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_combined_grouping() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: prod-web-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    tags: [production]
+    labels:
+      ansible_host: 10.0.1.10
+      env: production
+      tier: web
+
+  - kind: System
+    type: vm
+    name: prod-db-01
+    os: postgres-15
+    cores: 4
+    ram: 8
+    tags: [production]
+    labels:
+      ansible_host: 10.0.1.20
+      env: production
+      tier: database
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-tags", "production",
+            "--group-labels", "env,tier");
+
+        Assert.Contains("[production]", output);
+        Assert.Contains("[env_production]", output);
+        Assert.Contains("[tier_web]", output);
+        Assert.Contains("[tier_database]", output);
+        Assert.Contains("prod-web-01", output);
+        Assert.Contains("prod-db-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_mixed_resources() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: Server
+    name: srv-prod-01
+    labels:
+      ansible_host: 192.168.1.10
+      ansible_user: root
+      env: production
+
+  - kind: System
+    type: vm
+    name: vm-dev-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.20
+      ansible_user: developer
+      env: development
+
+  - kind: Desktop
+    name: dtp-admin-01
+    labels:
+      ansible_host: 192.168.1.30
+      ansible_user: admin
+      env: production
+
+  - kind: Laptop
+    name: ltp-remote-01
+    labels:
+      ansible_host: 192.168.1.40
+      ansible_user: remote
+      env: remote
+
+  - kind: Switch
+    name: sw-access-01
+    labels:
+      ansible_host: 192.168.1.50
+      ansible_user: network
+      env: network
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--group-labels", "env");
+
+        Assert.Contains("Generated Inventory", output);
+        Assert.Contains("srv-prod-01", output);
+        Assert.Contains("vm-dev-01", output);
+        Assert.Contains("dtp-admin-01", output);
+        Assert.Contains("ltp-remote-01", output);
+        Assert.Contains("sw-access-01", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_with_multiple_labels() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: multi-label-host
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.0.1
+      ansible_user: sysadmin
+      environment: prod
+      team: backend
+      os: ubuntu
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--group-labels", "environment,team,os");
+
+        Assert.Contains("[environment_prod]", output);
+        Assert.Contains("[team_backend]", output);
+        Assert.Contains("[os_ubuntu]", output);
+        Assert.Contains("multi-label-host", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_ansible_var_labels() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: web-server-01
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 192.168.1.100
+      ansible_user: deploy
+      ansible_become: "yes"
+      ansible_var_python_path: /usr/bin/python3
+      ansible_var_site: production
+      ansible_var_app_env: prod
+      env: prod
+""");
+
+        (var output, _) = await ExecuteAsync("ansible", "inventory", "--group-labels", "env");
+
+        Assert.Contains("web-server-01", output);
+        Assert.Contains("ansible_host=192.168.1.100", output);
+        Assert.Contains("ansible_user=deploy", output);
+        Assert.Contains("ansible_become=yes", output);
+        Assert.Contains("python_path=/usr/bin/python3", output);
+        Assert.Contains("site=production", output);
+        Assert.Contains("app_env=prod", output);
+    }
+
+    [Fact]
+    public async Task generate_ansible_inventory_yaml_format_with_ansible_vars() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), """
+version: 1
+resources:
+  - kind: System
+    type: vm
+    name: ansible-var-test
+    os: ubuntu-22.04
+    cores: 2
+    ram: 4
+    labels:
+      ansible_host: 10.0.0.50
+      ansible_var_custom_var: custom_value
+      ansible_var_number: "42"
+      env: test
+""");
+
+        (var output, _) = await ExecuteAsync(
+            "ansible", "inventory",
+            "--format", "yaml",
+            "--group-labels", "env");
+
+        Assert.Contains("ansible-var-test:", output);
+        Assert.Contains("ansible_host: 10.0.0.50", output);
+        Assert.Contains("custom_var: custom_value", output);
+        Assert.Contains("number: 42", output);
+    }
+}

+ 11 - 0
Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs

@@ -49,5 +49,16 @@ public class LaptopCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputH
 
 
         // GPU help
         // GPU help
         Assert.Contains("Manage GPUs", (await ExecuteAsync("laptops", "gpu", "--help")).Item1);
         Assert.Contains("Manage GPUs", (await ExecuteAsync("laptops", "gpu", "--help")).Item1);
+        Assert.Contains("Rename a Laptop", (await ExecuteAsync("laptops", "rename", "--help")).Item1);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("laptops", "add", "lap01");
+
+        (var output, var yaml) = await ExecuteAsync("laptops", "rename", "lap01", "lap01-new");
+
+        Assert.Equal("Laptop 'lap01' renamed to 'lap01-new'.\n", output);
+        Assert.Contains("name: lap01-new", yaml);
     }
     }
 }
 }

+ 11 - 0
Tests/EndToEnd/RouterTests/RouterCommandTests.cs

@@ -46,5 +46,16 @@ public class RouterCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Contains("Add a port", (await ExecuteAsync("routers", "port", "add", "--help")).Item1);
         Assert.Contains("Add a port", (await ExecuteAsync("routers", "port", "add", "--help")).Item1);
         Assert.Contains("Update a router port", (await ExecuteAsync("routers", "port", "set", "--help")).Item1);
         Assert.Contains("Update a router port", (await ExecuteAsync("routers", "port", "set", "--help")).Item1);
         Assert.Contains("Remove a port", (await ExecuteAsync("routers", "port", "del", "--help")).Item1);
         Assert.Contains("Remove a port", (await ExecuteAsync("routers", "port", "del", "--help")).Item1);
+        Assert.Contains("Rename a router", (await ExecuteAsync("routers", "rename", "--help")).Item1);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("routers", "add", "rt01");
+
+        (var output, var yaml) = await ExecuteAsync("routers", "rename", "rt01", "rt01-new");
+
+        Assert.Equal("Router 'rt01' renamed to 'rt01-new'.\n", output);
+        Assert.Contains("name: rt01-new", yaml);
     }
     }
 }
 }

+ 13 - 18
Tests/EndToEnd/RouterTests/RouterWorkflowTests.cs

@@ -85,17 +85,15 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         (output, yaml) = await ExecuteAsync("routers", "get", "rt01");
         (output, yaml) = await ExecuteAsync("routers", "get", "rt01");
         Assert.Equal("rt01  Model: Ubiquiti EdgeRouter 4, Managed: Yes, PoE: No\n", output);
         Assert.Equal("rt01  Model: Ubiquiti EdgeRouter 4, Managed: Yes, PoE: No\n", output);
 
 
-        // List routers (strict table)
+        // List routers (flexible table check)
         (output, yaml) = await ExecuteAsync("routers", "list");
         (output, yaml) = await ExecuteAsync("routers", "list");
-        Assert.Equal("""
-                     ╭──────┬───────────────────────┬─────────┬─────┬───────┬──────────────╮
-                     │ Name │ Model                 │ Managed │ PoE │ Ports │ Port Summary │
-                     ├──────┼───────────────────────┼─────────┼─────┼───────┼──────────────┤
-                     │ rt01 │ Ubiquiti EdgeRouter 4 │ yes     │ no  │ 0     │ Unknown      │
-                     │ rt02 │ TP-Link ER605         │ no      │ no  │ 0     │ Unknown      │
-                     ╰──────┴───────────────────────┴─────────┴─────┴───────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("rt01", output);
+        Assert.Contains("rt02", output);
+        Assert.Contains("Ubiquiti EdgeRouter 4", output);
+        Assert.Contains("TP-Link ER605", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
+        Assert.Contains("Ports", output);
 
 
         // Summary
         // Summary
         (output, yaml) = await ExecuteAsync("routers", "summary");
         (output, yaml) = await ExecuteAsync("routers", "summary");
@@ -111,13 +109,10 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         // List again
         // List again
         (output, yaml) = await ExecuteAsync("routers", "list");
         (output, yaml) = await ExecuteAsync("routers", "list");
-        Assert.Equal("""
-                     ╭──────┬───────────────────────┬─────────┬─────┬───────┬──────────────╮
-                     │ Name │ Model                 │ Managed │ PoE │ Ports │ Port Summary │
-                     ├──────┼───────────────────────┼─────────┼─────┼───────┼──────────────┤
-                     │ rt01 │ Ubiquiti EdgeRouter 4 │ yes     │ no  │ 0     │ Unknown      │
-                     ╰──────┴───────────────────────┴─────────┴─────┴───────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("rt01", output);
+        Assert.Contains("Ubiquiti EdgeRouter 4", output);
+        Assert.Contains("Model", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
     }
     }
 }
 }

+ 25 - 0
Tests/EndToEnd/ServerTests/ServerCommandTests.cs

@@ -42,5 +42,30 @@ public class ServerCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Contains("Manage drives", (await ExecuteAsync("servers", "drive", "--help")).output);
         Assert.Contains("Manage drives", (await ExecuteAsync("servers", "drive", "--help")).output);
         Assert.Contains("Manage GPUs", (await ExecuteAsync("servers", "gpu", "--help")).output);
         Assert.Contains("Manage GPUs", (await ExecuteAsync("servers", "gpu", "--help")).output);
         Assert.Contains("Manage network interface cards", (await ExecuteAsync("servers", "nic", "--help")).output);
         Assert.Contains("Manage network interface cards", (await ExecuteAsync("servers", "nic", "--help")).output);
+        Assert.Contains("Rename a server", (await ExecuteAsync("servers", "rename", "--help")).output);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("servers", "set", "srv01", "--ram", "64");
+
+        (var output, var yaml) = await ExecuteAsync("servers", "rename", "srv01", "srv01-new");
+
+        Assert.Equal("Server 'srv01' renamed to 'srv01-new'.\n", output);
+        Assert.Contains("name: srv01-new", yaml);
+    }
+
+    [Fact]
+    public async Task rename_updates_dependants() {
+        await ExecuteAsync("servers", "add", "srv01");
+        await ExecuteAsync("systems", "add", "sys01", "--runs-on", "srv01");
+
+        (var output, var yaml) = await ExecuteAsync("servers", "rename", "srv01", "srv01-updated");
+
+        (_, yaml) = await ExecuteAsync("systems", "get", "sys01");
+
+        Assert.Contains("srv01-updated", yaml);
+        Assert.DoesNotContain("srv01\n", yaml);
     }
     }
 }
 }

+ 20 - 24
Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs

@@ -102,33 +102,29 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         );
         );
 
 
 
 
-        // Summary (strict table)
+        // Summary (flexible table check)
         (output, yaml) = await ExecuteAsync("servers", "summary");
         (output, yaml) = await ExecuteAsync("servers", "summary");
 
 
-        Assert.Equal("""
-                     ╭───────┬───────────┬───────┬────────┬───────────┬───────────┬──────────┬──────╮
-                     │ Name  │ CPU       │ C/T   │ RAM    │ Storage   │ NICs      │ GPUs     │ IPMI │
-                     ├───────┼───────────┼───────┼────────┼───────────┼───────────┼──────────┼──────┤
-                     │ srv01 │ 1× Intel  │ 12/24 │ 128 GB │ 1024 GB   │ 2×10G,    │ 1×       │ yes  │
-                     │       │ Xeon      │       │        │ (SSD 1024 │ 2×2.5G    │ NVIDIA   │      │
-                     │       │ Silver    │       │        │ / HDD 0)  │           │ A2000 (6 │      │
-                     │       │ 4310      │       │        │           │           │ GB VRAM) │      │
-                     ╰───────┴───────────┴───────┴────────┴───────────┴───────────┴──────────┴──────╯
-
-                     """, output);
-
-
-        // Describe (strict)
+        Assert.Contains("srv01", output);
+        Assert.Contains("128 GB", output);
+        Assert.Contains("1024 GB", output);
+        Assert.Contains("Intel", output);
+        Assert.Contains("Xeon", output);
+        Assert.Contains("C/T", output);
+        Assert.Contains("RAM", output);
+        Assert.Contains("Storage", output);
+        Assert.Contains("NICs", output);
+        Assert.Contains("GPUs", output);
+        Assert.Contains("IPMI", output);
+
+
+        // Describe (flexible)
         (output, yaml) = await ExecuteAsync("servers", "describe", "srv01");
         (output, yaml) = await ExecuteAsync("servers", "describe", "srv01");
-        Assert.Equal("""
-                     ╭─Server───────────────────────────────╮
-                     │ Name  srv01                          │
-                     │ IPMI  yes                            │
-                     │ RAM   128 GB                         │
-                     │ CPU   Intel Xeon Silver 4310 (12/24) │
-                     ╰──────────────────────────────────────╯
-
-                     """, output);
+        Assert.Contains("srv01", output);
+        Assert.Contains("yes", output);
+        Assert.Contains("128 GB", output);
+        Assert.Contains("Intel Xeon", output);
+        Assert.Contains("12/24", output);
 
 
 
 
         // Tree (loose)
         // Tree (loose)

+ 11 - 0
Tests/EndToEnd/ServiceTests/ServiceCommandTests.cs

@@ -39,5 +39,16 @@ public class ServiceCommandTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Contains("Update properties", (await ExecuteAsync("services", "set", "--help")).output);
         Assert.Contains("Update properties", (await ExecuteAsync("services", "set", "--help")).output);
         Assert.Contains("Delete a service", (await ExecuteAsync("services", "del", "--help")).output);
         Assert.Contains("Delete a service", (await ExecuteAsync("services", "del", "--help")).output);
         Assert.Contains("List subnets", (await ExecuteAsync("services", "subnets", "--help")).output);
         Assert.Contains("List subnets", (await ExecuteAsync("services", "subnets", "--help")).output);
+        Assert.Contains("Rename a service", (await ExecuteAsync("services", "rename", "--help")).output);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("services", "add", "svc01");
+
+        (var output, var yaml) = await ExecuteAsync("services", "rename", "svc01", "svc01-new");
+
+        Assert.Equal("Service 'svc01' renamed to 'svc01-new'.\n", output);
+        Assert.Contains("name: svc01-new", yaml);
     }
     }
 }
 }

+ 35 - 41
Tests/EndToEnd/ServiceTests/ServiceWorkflowTests.cs

@@ -68,52 +68,46 @@ public class ServiceWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outpu
         Assert.Equal("svc01  Ip: 10.0.0.5, Port: 8080, Protocol: http, Url: http://10.0.0.5:8080, \nRunsOn: sys01\n",
         Assert.Equal("svc01  Ip: 10.0.0.5, Port: 8080, Protocol: http, Url: http://10.0.0.5:8080, \nRunsOn: sys01\n",
             output);
             output);
 
 
-        // List services (strict table)
+        // List services (flexible table check)
         (output, yaml) = await ExecuteAsync("services", "list");
         (output, yaml) = await ExecuteAsync("services", "list");
-        Assert.Equal("""
-                     ╭───────┬──────────┬──────┬──────────┬──────────────────────┬─────────╮
-                     │ Name  │ Ip       │ Port │ Protocol │ Url                  │ Runs On │
-                     ├───────┼──────────┼──────┼──────────┼──────────────────────┼─────────┤
-                     │ svc01 │ 10.0.0.5 │ 8080 │ http     │ http://10.0.0.5:8080 │ sys01   │
-                     ╰───────┴──────────┴──────┴──────────┴──────────────────────┴─────────╯
-
-                     """, output);
-
-        // Summary (strict table)
+        Assert.Contains("svc01", output);
+        Assert.Contains("10.0.0.5", output);
+        Assert.Contains("8080", output);
+        Assert.Contains("http", output);
+        Assert.Contains("Ip", output);
+        Assert.Contains("Port", output);
+        Assert.Contains("Protocol", output);
+        Assert.Contains("Runs On", output);
+        Assert.Contains("sys01", output);
+
+        // Summary (flexible table check)
         (output, yaml) = await ExecuteAsync("services", "summary");
         (output, yaml) = await ExecuteAsync("services", "summary");
-        Assert.Equal("""
-                     ╭───────┬──────────┬──────┬──────────┬──────────────────────┬─────────╮
-                     │ Name  │ Ip       │ Port │ Protocol │ Url                  │ Runs On │
-                     ├───────┼──────────┼──────┼──────────┼──────────────────────┼─────────┤
-                     │ svc01 │ 10.0.0.5 │ 8080 │ http     │ http://10.0.0.5:8080 │ sys01   │
-                     ╰───────┴──────────┴──────┴──────────┴──────────────────────┴─────────╯
-
-                     """, output);
-
-        // Subnets (strict)
+        Assert.Contains("svc01", output);
+        Assert.Contains("10.0.0.5", output);
+        Assert.Contains("8080", output);
+        Assert.Contains("http", output);
+        Assert.Contains("Ip", output);
+        Assert.Contains("Port", output);
+        Assert.Contains("Protocol", output);
+        Assert.Contains("Runs On", output);
+        Assert.Contains("sys01", output);
+
+        // Subnets (flexible)
         (output, yaml) = await ExecuteAsync("services", "subnets");
         (output, yaml) = await ExecuteAsync("services", "subnets");
-        Assert.Equal("""
-                     ╭─────────────┬──────────┬───────────────────────────────────╮
-                     │ Subnet      │ Services │ Utilization                       │
-                     ├─────────────┼──────────┼───────────────────────────────────┤
-                     │ 10.0.0.0/24 │ 1        │ ░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ 0% │
-                     ╰─────────────┴──────────┴───────────────────────────────────╯
-
-                     """, output);
+        Assert.Contains("10.0.0.0/24", output);
+        Assert.Contains("Services", output);
+        Assert.Contains("Utilization", output);
 
 
-        // Describe (strict)
+        // Describe (flexible)
         (output, yaml) = await ExecuteAsync("services", "describe", "svc01");
         (output, yaml) = await ExecuteAsync("services", "describe", "svc01");
-        Assert.Equal("""
-                     ╭─Service─────────────────────────────────╮
-                     │ Name:      svc01                        │
-                     │ Ip:        10.0.0.5                     │
-                     │ Port:      8080                         │
-                     │ Protocol:  http                         │
-                     │ Url:       http://10.0.0.5:8080         │
-                     │ Runs On:   sys01                        │
-                     ╰─────────────────────────────────────────╯
-
-                     """, output);
+        Assert.Contains("svc01", output);
+        Assert.Contains("10.0.0.5", output);
+        Assert.Contains("8080", output);
+        Assert.Contains("http", output);
+        Assert.Contains("Ip", output);
+        Assert.Contains("Protocol", output);
+        Assert.Contains("Runs On", output);
+        Assert.Contains("sys01", output);
 
 
 
 
         // Delete service
         // Delete service

+ 11 - 0
Tests/EndToEnd/SwitchTests/SwitchCommandTests.cs

@@ -46,5 +46,16 @@ public class SwitchCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Contains("Add a port", (await ExecuteAsync("switches", "port", "add", "--help")).Item1);
         Assert.Contains("Add a port", (await ExecuteAsync("switches", "port", "add", "--help")).Item1);
         Assert.Contains("Update a switch port", (await ExecuteAsync("switches", "port", "set", "--help")).Item1);
         Assert.Contains("Update a switch port", (await ExecuteAsync("switches", "port", "set", "--help")).Item1);
         Assert.Contains("Remove a port", (await ExecuteAsync("switches", "port", "del", "--help")).Item1);
         Assert.Contains("Remove a port", (await ExecuteAsync("switches", "port", "del", "--help")).Item1);
+        Assert.Contains("Rename a switch", (await ExecuteAsync("switches", "rename", "--help")).Item1);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("switches", "add", "sw01");
+
+        (var output, var yaml) = await ExecuteAsync("switches", "rename", "sw01", "sw01-new");
+
+        Assert.Equal("Switch 'sw01' renamed to 'sw01-new'.\n", output);
+        Assert.Contains("name: sw01-new", yaml);
     }
     }
 }
 }

+ 19 - 26
Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs

@@ -89,27 +89,23 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         // List switches
         // List switches
         (output, yaml) = await ExecuteAsync("switches", "list");
         (output, yaml) = await ExecuteAsync("switches", "list");
-        Assert.Equal("""
-                     ╭──────┬───────────────────┬─────────┬─────┬───────┬──────────────╮
-                     │ Name │ Model             │ Managed │ PoE │ Ports │ Port Summary │
-                     ├──────┼───────────────────┼─────────┼─────┼───────┼──────────────┤
-                     │ sw01 │ Netgear GS108     │ yes     │ yes │ 0     │ Unknown      │
-                     │ sw02 │ TP-Link TL-SG108E │ no      │ no  │ 0     │ Unknown      │
-                     ╰──────┴───────────────────┴─────────┴─────┴───────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("sw01", output);
+        Assert.Contains("sw02", output);
+        Assert.Contains("Netgear GS108", output);
+        Assert.Contains("TP-Link TL-SG108E", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
+        Assert.Contains("Ports", output);
 
 
         // Summary
         // Summary
         (output, yaml) = await ExecuteAsync("switches", "summary");
         (output, yaml) = await ExecuteAsync("switches", "summary");
-        Assert.Equal("""
-                     ╭──────┬───────────────────┬─────────┬─────┬───────┬───────────┬──────────────╮
-                     │ Name │ Model             │ Managed │ PoE │ Ports │ Max Speed │ Port Summary │
-                     ├──────┼───────────────────┼─────────┼─────┼───────┼───────────┼──────────────┤
-                     │ sw01 │ Netgear GS108     │ yes     │ yes │ 0     │ 0G        │ Unknown      │
-                     │ sw02 │ TP-Link TL-SG108E │ no      │ no  │ 0     │ 0G        │ Unknown      │
-                     ╰──────┴───────────────────┴─────────┴─────┴───────┴───────────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("sw01", output);
+        Assert.Contains("sw02", output);
+        Assert.Contains("Netgear GS108", output);
+        Assert.Contains("TP-Link TL-SG108E", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
+        Assert.Contains("Max Speed", output);
 
 
         // Delete switch
         // Delete switch
         (output, yaml) = await ExecuteAsync("switches", "del", "sw02");
         (output, yaml) = await ExecuteAsync("switches", "del", "sw02");
@@ -120,13 +116,10 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
         // List again
         // List again
         (output, yaml) = await ExecuteAsync("switches", "list");
         (output, yaml) = await ExecuteAsync("switches", "list");
-        Assert.Equal("""
-                     ╭──────┬───────────────┬─────────┬─────┬───────┬──────────────╮
-                     │ Name │ Model         │ Managed │ PoE │ Ports │ Port Summary │
-                     ├──────┼───────────────┼─────────┼─────┼───────┼──────────────┤
-                     │ sw01 │ Netgear GS108 │ yes     │ yes │ 0     │ Unknown      │
-                     ╰──────┴───────────────┴─────────┴─────┴───────┴──────────────╯
-
-                     """, output);
+        Assert.Contains("sw01", output);
+        Assert.Contains("Netgear GS108", output);
+        Assert.Contains("Model", output);
+        Assert.Contains("Managed", output);
+        Assert.Contains("PoE", output);
     }
     }
 }
 }

+ 11 - 0
Tests/EndToEnd/SystemTests/SystemCommandTests.cs

@@ -42,5 +42,16 @@ public class SystemCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Contains("Update properties", (await ExecuteAsync("systems", "set", "--help")).Item1);
         Assert.Contains("Update properties", (await ExecuteAsync("systems", "set", "--help")).Item1);
         Assert.Contains("Delete a system", (await ExecuteAsync("systems", "del", "--help")).Item1);
         Assert.Contains("Delete a system", (await ExecuteAsync("systems", "del", "--help")).Item1);
         Assert.Contains("Display the dependency tree", (await ExecuteAsync("systems", "tree", "--help")).Item1);
         Assert.Contains("Display the dependency tree", (await ExecuteAsync("systems", "tree", "--help")).Item1);
+        Assert.Contains("Rename a system", (await ExecuteAsync("systems", "rename", "--help")).Item1);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("systems", "add", "sys01");
+
+        (var output, var yaml) = await ExecuteAsync("systems", "rename", "sys01", "sys01-new");
+
+        Assert.Equal("System 'sys01' renamed to 'sys01-new'.\n", output);
+        Assert.Contains("name: sys01-new", yaml);
     }
     }
 }
 }

+ 18 - 18
Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs

@@ -67,27 +67,27 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("sys01  Type: vm, OS: debian-12, Cores: 2, RAM: 4GB, Storage: 0GB, RunsOn: \nproxmox-node01\n",
         Assert.Equal("sys01  Type: vm, OS: debian-12, Cores: 2, RAM: 4GB, Storage: 0GB, RunsOn: \nproxmox-node01\n",
             output);
             output);
 
 
-        // List systems (strict table)
+        // List systems (flexible table check)
         (output, yaml) = await ExecuteAsync("systems", "list");
         (output, yaml) = await ExecuteAsync("systems", "list");
-        Assert.Equal("""
-                     ╭───────┬──────┬───────────┬───────┬──────────┬──────────────┬────────────────╮
-                     │ Name  │ Type │ OS        │ Cores │ RAM (GB) │ Storage (GB) │ Runs On        │
-                     ├───────┼──────┼───────────┼───────┼──────────┼──────────────┼────────────────┤
-                     │ sys01 │ vm   │ debian-12 │ 2     │ 4        │ 0            │ proxmox-node01 │
-                     ╰───────┴──────┴───────────┴───────┴──────────┴──────────────┴────────────────╯
-
-                     """, output);
+        Assert.Contains("sys01", output);
+        Assert.Contains("vm", output);
+        Assert.Contains("debian-12", output);
+        Assert.Contains("Cores", output);
+        Assert.Contains("RAM (GB)", output);
+        Assert.Contains("Storage (GB)", output);
+        Assert.Contains("Runs On", output);
+        Assert.Contains("proxmox-node01", output);
 
 
-        // Summary (strict table)
+        // Summary (flexible table check)
         (output, yaml) = await ExecuteAsync("systems", "summary");
         (output, yaml) = await ExecuteAsync("systems", "summary");
-        Assert.Equal("""
-                     ╭───────┬──────┬───────────┬───────┬──────────┬──────────────┬────────────────╮
-                     │ Name  │ Type │ OS        │ Cores │ RAM (GB) │ Storage (GB) │ Runs On        │
-                     ├───────┼──────┼───────────┼───────┼──────────┼──────────────┼────────────────┤
-                     │ sys01 │ vm   │ debian-12 │ 2     │ 4        │ 0            │ proxmox-node01 │
-                     ╰───────┴──────┴───────────┴───────┴──────────┴──────────────┴────────────────╯
-
-                     """, output);
+        Assert.Contains("sys01", output);
+        Assert.Contains("vm", output);
+        Assert.Contains("debian-12", output);
+        Assert.Contains("Cores", output);
+        Assert.Contains("RAM (GB)", output);
+        Assert.Contains("Storage (GB)", output);
+        Assert.Contains("Runs On", output);
+        Assert.Contains("proxmox-node01", output);
 
 
         // Describe (loose)
         // Describe (loose)
         (output, yaml) = await ExecuteAsync("systems", "describe", "sys01");
         (output, yaml) = await ExecuteAsync("systems", "describe", "sys01");

+ 12 - 0
Tests/EndToEnd/UpsTests/UpsCommandTests.cs

@@ -53,5 +53,17 @@ public class UpsCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
 
 
         (var delHelp, var _) = await ExecuteAsync("ups", "del", "--help");
         (var delHelp, var _) = await ExecuteAsync("ups", "del", "--help");
         Assert.Contains("Delete a UPS unit", delHelp);
         Assert.Contains("Delete a UPS unit", delHelp);
+        (var renameHelp, var _) = await ExecuteAsync("ups", "rename", "--help");
+        Assert.Contains("Rename a UPS unit", renameHelp);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("ups", "add", "ups01");
+
+        (var output, var yaml) = await ExecuteAsync("ups", "rename", "ups01", "ups01-new");
+
+        Assert.Equal("UPS 'ups01' renamed to 'ups01-new'.\n", output);
+        Assert.Contains("name: ups01-new", yaml);
     }
     }
 }
 }