Explorar el Código

Merge pull request #280 from Timmoth/staging

Released v1.3.1 - stability / bug fixes.
Tim Jones hace 2 meses
padre
commit
a22371504b
Se han modificado 79 ficheros con 2380 adiciones y 348 borrados
  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:
       - rackpeek-config:/app/config
     restart: unless-stopped
+    healthcheck:
+      test: ["CMD-SHELL", "curl -s http://localhost:8080 | grep -q 'rackpeek' || exit 1"]
+      interval: 30s
+      timeout: 10s
+      retries: 3
 
 volumes:
   rackpeek-config:

+ 1 - 1
RackPeek.Domain/RpkConstants.cs

@@ -1,7 +1,7 @@
 namespace RackPeek.Domain;
 
 public static class RpkConstants {
-    public const string Version = "v1.3.0";
+    public const string Version = "v1.3.1";
 
     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))
                 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))
                 vars[k] = v;
         }

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

@@ -1,6 +1,7 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 
 namespace RackPeek.Domain.UseCases;
 
@@ -24,6 +25,13 @@ public class DeleteResourceUseCase<T>(IResourceCollection repo) : IDeleteResourc
             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);
     }
 }

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

@@ -1,20 +1,21 @@
 using RackPeek.Domain.Helpers;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Connections;
 
 namespace RackPeek.Domain.UseCases;
 
 public interface IRenameResourceUseCase<T> : IResourceUseCase<T>
     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 async Task ExecuteAsync(string originalName, string newName) {
-        originalName = Normalize.SystemName(originalName);
+        originalName = Normalize.HardwareName(originalName);
         ThrowIfInvalid.ResourceName(originalName);
 
-        newName = Normalize.SystemName(newName);
+        newName = Normalize.HardwareName(newName);
         ThrowIfInvalid.ResourceName(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.");
 
         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;
         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 void MapInventoryApi(this WebApplication app) {
+        app.MapGet("/health", () => Results.Content("rackpeek", "text/plain"))
+            .DisableAntiforgery();
         app.MapPost("/api/inventory",
                 async (ImportYamlRequest request,
                     UpsertInventoryUseCase useCase) => {

+ 1 - 1
RackPeek/RackPeek.csproj

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

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

@@ -93,8 +93,15 @@
                 <input type="number"
                        step="0.1"
                        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)
             {

+ 45 - 0
Shared.Rcl/CliBootstrap.cs

@@ -8,6 +8,7 @@ using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints.Labels;
+using Shared.Rcl.Commands.AccessPoints.Rename;
 using Shared.Rcl.Commands.Connections;
 using Shared.Rcl.Commands.Desktops;
 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.Labels;
 using Shared.Rcl.Commands.Desktops.Nics;
+using Shared.Rcl.Commands.Desktops.Rename;
 using Shared.Rcl.Commands.Exporters;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls.Labels;
 using Shared.Rcl.Commands.Firewalls.Ports;
+using Shared.Rcl.Commands.Firewalls.Rename;
 using Shared.Rcl.Commands.Laptops;
 using Shared.Rcl.Commands.Laptops.Cpus;
 using Shared.Rcl.Commands.Laptops.Drive;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Laptops.Labels;
+using Shared.Rcl.Commands.Laptops.Rename;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers.Labels;
 using Shared.Rcl.Commands.Routers.Ports;
+using Shared.Rcl.Commands.Routers.Rename;
 using Shared.Rcl.Commands.Servers;
 using Shared.Rcl.Commands.Servers.Cpus;
 using Shared.Rcl.Commands.Servers.Drives;
 using Shared.Rcl.Commands.Servers.Gpus;
 using Shared.Rcl.Commands.Servers.Labels;
 using Shared.Rcl.Commands.Servers.Nics;
+using Shared.Rcl.Commands.Servers.Rename;
 using Shared.Rcl.Commands.Services;
 using Shared.Rcl.Commands.Services.Labels;
+using Shared.Rcl.Commands.Services.Rename;
 using Shared.Rcl.Commands.Switches;
 using Shared.Rcl.Commands.Switches.Labels;
 using Shared.Rcl.Commands.Switches.Ports;
+using Shared.Rcl.Commands.Switches.Rename;
 using Shared.Rcl.Commands.Systems;
 using Shared.Rcl.Commands.Systems.Labels;
+using Shared.Rcl.Commands.Systems.Rename;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
+using Shared.Rcl.Commands.Ups.Rename;
 using Spectre.Console;
 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<ServerRenameCommand>("rename")
+                    .WithDescription("Rename a server to a new name.");
+
                 server.AddCommand<ServerTreeCommand>("tree")
                     .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<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 => {
                     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<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 => {
                     port.SetDescription("Manage ports on a router.");
 
@@ -298,6 +319,10 @@ public static class CliBootstrap {
 
                 firewalls.AddCommand<FirewallDeleteCommand>("del")
                     .WithDescription("Delete a firewall from the inventory.");
+
+                firewalls.AddCommand<FirewallRenameCommand>("rename")
+                    .WithDescription("Rename a firewall to a new name.");
+
                 firewalls.AddBranch("port", port => {
                     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<SystemRenameCommand>("rename")
+                    .WithDescription("Rename a system to a new name.");
+
                 system.AddCommand<SystemTreeCommand>("tree")
                     .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<AccessPointRenameCommand>("rename")
+                    .WithDescription("Rename an access point to a new name.");
+
                 ap.AddBranch("label", label => {
                     label.SetDescription("Manage labels on an access point.");
                     label.AddCommand<AccessPointLabelAddCommand>("add")
@@ -402,6 +433,9 @@ public static class CliBootstrap {
 
                 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 => {
                     label.SetDescription("Manage labels on 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<DesktopDeleteCommand>("del")
                     .WithDescription("Delete a desktop from the inventory.");
+
+                desktops.AddCommand<DesktopRenameCommand>("rename")
+                    .WithDescription("Rename a desktop to a new name.");
+
                 desktops.AddCommand<DesktopReportCommand>("summary")
                     .WithDescription("Show a summarized hardware report for all desktops.");
                 desktops.AddCommand<DesktopTreeCommand>("tree")
@@ -485,6 +523,10 @@ public static class CliBootstrap {
                     .WithDescription("Show detailed information about 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<LaptopRenameCommand>("rename")
+                    .WithDescription("Rename a Laptop to a new name.");
+
                 laptops.AddCommand<LaptopReportCommand>("summary")
                     .WithDescription("Show a summarized hardware report for all Laptops.");
                 laptops.AddCommand<LaptopTreeCommand>("tree")
@@ -544,6 +586,9 @@ public static class CliBootstrap {
 
                 service.AddCommand<ServiceDeleteCommand>("del").WithDescription("Delete a service.");
 
+                service.AddCommand<ServiceRenameCommand>("rename")
+                    .WithDescription("Rename a service to a new name.");
+
                 service.AddCommand<ServiceSubnetsCommand>("subnets")
                     .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());
 
-        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)
-            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(
             new Panel(grid)

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

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

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

@@ -1,6 +1,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using RackPeek.Domain.Resources.AccessPoints;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -32,8 +33,8 @@ public class AccessPointReportCommand(
 
         foreach (AccessPointHardwareRow ap in report.AccessPoints)
             table.AddRow(
-                ap.Name,
-                ap.Model,
+                ap.Name.EscapeMarkup(),
+                ap.Model.EscapeMarkup(),
                 $"{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 RackPeek.Domain.Resources.Desktops;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -18,16 +19,16 @@ public class DesktopDescribeCommand(IServiceProvider provider)
 
         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("RAM:", result.RamSummary ?? "None");
+        grid.AddRow("RAM:", (result.RamSummary ?? "None").EscapeMarkup());
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("NICs:", result.NicCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
 
         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));
 

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

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

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

@@ -1,6 +1,7 @@
 using Microsoft.Extensions.DependencyInjection;
 using Microsoft.Extensions.Logging;
 using RackPeek.Domain.Resources.Desktops;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -35,13 +36,13 @@ public class DesktopReportCommand(
 
         foreach (DesktopHardwareRow d in report.Desktops)
             table.AddRow(
-                d.Name,
-                d.CpuSummary,
+                d.Name.EscapeMarkup(),
+                d.CpuSummary.EscapeMarkup(),
                 $"{d.TotalCores}/{d.TotalThreads}",
                 $"{d.RamGb} GB",
                 $"{d.TotalStorageGb} GB (SSD {d.SsdStorageGb} / HDD {d.HddStorageGb})",
-                d.NicSummary,
-                d.GpuSummary
+                d.NicSummary.EscapeMarkup(),
+                d.GpuSummary.EscapeMarkup()
             );
 
         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 RackPeek.Domain.Resources.Firewalls;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -21,16 +22,16 @@ public class FirewallDescribeCommand(
             .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("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Total Ports:", sw.TotalPorts.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)
-            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(
             new Panel(grid)

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

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

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Firewalls;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -32,13 +33,13 @@ public class FirewallReportCommand(
 
         foreach (FirewallHardwareRow s in report.Firewalls)
             table.AddRow(
-                s.Name,
-                s.Model,
+                s.Name.EscapeMarkup(),
+                s.Model.EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 $"{s.MaxPortSpeedGb}G",
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
 
         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.Services.UseCases;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -46,7 +47,7 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             $"[bold]Hardware[/] ({hardwareSummary.TotalHardware})");
 
         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(
             $"[bold]Systems[/] ({systemSummary.TotalSystems})");
@@ -55,13 +56,13 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             TreeNode typesNode = systemsNode.AddNode("[bold]Types[/]");
             foreach ((var type, var count) in systemSummary.SystemsByType.OrderByDescending(h => h.Value)
                          .ThenBy(h => h.Key))
-                typesNode.AddNode($"{type}: {count}");
+                typesNode.AddNode($"{type.EscapeMarkup()}: {count}");
         }
 
         if (systemSummary.SystemsByOs.Count > 0) {
             TreeNode osNode = systemsNode.AddNode("[bold]Operating Systems[/]");
             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(
@@ -105,10 +106,10 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             .AddColumn("Count");
 
         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)
-            table.AddRow("OS", os, count.ToString());
+            table.AddRow("OS", os.EscapeMarkup(), count.ToString());
 
         AnsiConsole.Write(table);
     }
@@ -124,7 +125,7 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand {
             .AddColumn("Count");
 
         foreach ((var kind, var count) in hardwareSummary.HardwareByKind)
-            table.AddRow(kind, count.ToString());
+            table.AddRow(kind.EscapeMarkup(), count.ToString());
 
         AnsiConsole.Write(table);
     }

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

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

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Laptops;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -30,12 +31,12 @@ public class LaptopReportCommand(
 
         foreach (LaptopHardwareRow d in report.Laptops)
             table.AddRow(
-                d.Name,
-                d.CpuSummary,
+                d.Name.EscapeMarkup(),
+                d.CpuSummary.EscapeMarkup(),
                 $"{d.TotalCores}/{d.TotalThreads}",
                 $"{d.RamGb} GB",
                 $"{d.TotalStorageGb} GB (SSD {d.SsdStorageGb} / HDD {d.HddStorageGb})",
-                d.GpuSummary
+                d.GpuSummary.EscapeMarkup()
             );
 
         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());
 
-        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("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Total Ports:", sw.TotalPorts.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)
-            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(
             new Panel(grid)

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

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

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Routers;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -31,13 +32,13 @@ public class RouterReportCommand(
 
         foreach (RouterHardwareRow s in report.Routers)
             table.AddRow(
-                s.Name,
-                s.Model,
+                s.Name.EscapeMarkup(),
+                s.Model.EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 $"{s.MaxPortSpeedGb}G",
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
 
         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.SubResources;
 using RackPeek.Domain.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -24,16 +25,16 @@ public class ServerDescribeCommand(
             .AddColumn()
             .AddColumn();
 
-        grid.AddRow("Name", server.Name);
+        grid.AddRow("Name", server.Name.EscapeMarkup());
         grid.AddRow("IPMI", server.Ipmi == true ? "yes" : "no");
         grid.AddRow("RAM", $"{server.Ram?.Size ?? 0} GB");
 
         if (server.Cpus != null)
             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)
-            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(
             new Panel(grid)

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

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

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Servers;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -31,15 +32,15 @@ public class ServerReportCommand(IServiceProvider serviceProvider)
 
         foreach (ServerHardwareRow s in report.Servers)
             table.AddRow(
-                s.Name,
-                s.CpuSummary,
+                s.Name.EscapeMarkup(),
+                s.CpuSummary.EscapeMarkup(),
                 $"{s.TotalCores}/{s.TotalThreads}",
                 $"{s.RamGb} GB",
                 $"{s.TotalStorageGb} GB (SSD {s.SsdStorageGb} / HDD {s.HddStorageGb})",
-                s.NicSummary,
+                s.NicSummary.EscapeMarkup(),
                 s.GpuCount == 0
                     ? "[grey]none[/]"
-                    : $"{s.GpuSummary} ({s.TotalGpuVramGb} GB VRAM)",
+                    : $"{s.GpuSummary.EscapeMarkup()} ({s.TotalGpuVramGb} GB VRAM)",
                 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 RackPeek.Domain.Resources.Services.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -25,17 +26,17 @@ public class ServiceDescribeCommand(
             .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:",
             ServicesFormatExtensions.FormatRunsOn(string.Join(", ", service.RunsOnSystemHost),
                 string.Join(", ", service.RunsOnPhysicalHost)));
 
         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(
             new Panel(grid)

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Services.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -38,11 +39,11 @@ public class ServiceGetCommand(
             if (s.RunsOnPhysicalHost is not null) phys = string.Join(", ", s.RunsOnPhysicalHost);
 
             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)
             );
         }

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Services.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -36,11 +37,11 @@ public class ServiceReportCommand(
             if (s.RunsOnPhysicalHost?.Count > 0) phys = string.Join(", ", s.RunsOnPhysicalHost);
 
             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)
             );
         }

+ 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());
 
-        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("PoE:", sw.Poe.HasValue ? sw.Poe.Value ? "Yes" : "No" : "Unknown");
         grid.AddRow("Total Ports:", sw.TotalPorts.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)
-            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(
             new Panel(grid)

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

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

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Switches;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -31,13 +32,13 @@ public class SwitchReportCommand(
 
         foreach (SwitchHardwareRow s in report.Switches)
             table.AddRow(
-                s.Name,
-                s.Model,
+                s.Name.EscapeMarkup(),
+                s.Model.EscapeMarkup(),
                 s.Managed ? "[green]yes[/]" : "[red]no[/]",
                 s.Poe ? "[green]yes[/]" : "[red]no[/]",
                 s.TotalPorts.ToString(),
                 $"{s.MaxPortSpeedGb}G",
-                s.PortSummary
+                s.PortSummary.EscapeMarkup()
             );
 
         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 RackPeek.Domain.Resources.SystemResources.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -21,17 +22,17 @@ public class SystemDescribeCommand(
             .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("RAM (GB):", system.RamGb.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)
-            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(
             new Panel(grid)

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

@@ -1,5 +1,6 @@
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.SystemResources.UseCases;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -33,13 +34,13 @@ public class SystemGetCommand(
 
         foreach (SystemReportRow s in report.Systems)
             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.RamGb.ToString(),
                 s.TotalStorageGb.ToString(),
-                string.Join(", ", s.RunsOn) ?? "Unkown"
+                (string.Join(", ", s.RunsOn) ?? "Unkown").EscapeMarkup()
             );
 
         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 RackPeek.Domain.Resources.UpsUnits;
+using Shared.Rcl.Commands;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -20,12 +21,12 @@ public class UpsDescribeCommand(IServiceProvider provider)
             .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");
 
         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));
 

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

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

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

@@ -48,8 +48,15 @@
         <div>
             <div class="text-zinc-400 mb-1">Default SSH Port</div>
             <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"/>
             <div class="text-xs text-zinc-500 mt-1">
                 Used if ssh_port or ansible_port label is not defined

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

@@ -12,8 +12,7 @@
 }
 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++)
         {
             var index = i;
@@ -43,13 +42,13 @@ else
 
                     <div class="@PortClass(true)">
 
-                        <div class="text-zinc-500">
+                    <div class="truncate">
+                        <span class="text-zinc-500">
                             @(index + 1)
-                        </div>
-
-                        <div class="truncate">
-                            @other!.Resource
-                        </div>
+                        </span>
+                        <span> - </span>
+                        <span>@other!.Resource</span>
+                    </div>
 
                         @if (otherGroup != null)
                         {
@@ -70,12 +69,16 @@ else
 
                     <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>
@@ -113,15 +116,16 @@ else
     {
         return $@"
             w-28
-            h-12
+            min-h-12
+            py-1
             border-r
             border-b
             border-zinc-800
             text-[10px]
-            leading-tight
+            leading-snug
             flex
             flex-col
-            justify-center
+            justify-start
             px-1
             transition
             hover:bg-zinc-800

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

@@ -107,7 +107,15 @@
             {
                 <input type="number"
                        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)
             {

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

@@ -92,8 +92,15 @@
             {
                 <input type="number"
                        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)
             {

+ 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.
 
+---
+
 # 1. Making a Resource Ansible-Ready
 
 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.
 
+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
 labels:
@@ -27,24 +47,67 @@ labels:
   ansible_user: ubuntu
   ansible_port: 22
   ansible_ssh_private_key_file: ~/.ssh/id_rsa
-  env: prod
-  role: web
 ```
 
 ### 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.
 
@@ -75,7 +138,7 @@ vm-web01 ...
 
 ---
 
-# 3. Using Labels for Structured Groups
+# 5. Using Labels for Structured Groups
 
 Labels allow structured grouping.
 
@@ -107,7 +170,7 @@ This is cleaner and more scalable than raw tags.
 
 ---
 
-# 4. Example Resource
+# 6. Example Resource
 
 ```yaml
 - kind: System
@@ -116,19 +179,22 @@ This is cleaner and more scalable than raw tags.
   cores: 4
   ram: 8
   name: vm-web01
+
   tags:
   - prod
   - web
+
   labels:
     ansible_host: 192.168.1.10
     ansible_user: ubuntu
+    ansible_var_mac: 52:54:00:11:22:33
     env: prod
     role: web
 ```
 
 ---
 
-# 5. Generating Inventory
+# 7. Generating Inventory
 
 ## CLI
 
@@ -158,7 +224,7 @@ Click **Generate**.
 
 ---
 
-# 6. Example Generated Inventory
+# 8. Example Generated Inventory
 
 ```ini
 [all:vars]
@@ -166,18 +232,18 @@ ansible_python_interpreter=/usr/bin/python3
 ansible_user=ansible
 
 [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]
-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]
-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
 
@@ -239,7 +305,7 @@ ansible-playbook -i inventory.ini ping.yml
 
 ---
 
-# 8. Best Practices
+# 10. Best Practices
 
 ### 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:
 
@@ -268,7 +349,7 @@ Override per host only when needed.
 
 ---
 
-### 3. Separate Infrastructure and Services
+### 4. Separate Infrastructure and Services
 
 Model:
 
@@ -279,7 +360,7 @@ Deploy against systems, not services.
 
 ---
 
-### 4. Keep Inventory Deterministic
+### 5. Keep Inventory Deterministic
 
 Avoid:
 
@@ -289,7 +370,7 @@ Avoid:
 
 ---
 
-# 9. Advanced Pattern (Recommended)
+# 11. Advanced Pattern (Recommended)
 
 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:
 
 1. Add `ansible_host` label
 2. Add `env` and `role` labels
 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
 ```
 
+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.

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

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

+ 2 - 2
Tests.E2e/AccessPointCardTests.cs

@@ -95,8 +95,8 @@ public class AccessPointCardTests(
             var afterModel = await card.ModelSection(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();
         }

+ 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");
         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);
 
         (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");
-        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");
         Assert.Equal("""
@@ -102,13 +96,9 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
                      """, output);
 
         (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("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
         (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("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("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
         (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
         (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
         (output, yaml) = await ExecuteAsync("firewalls", "del", "fw02");
@@ -118,13 +114,10 @@ public class FirewallWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outp
 
         // List again
         (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
         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("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("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");
         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");
-        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
         (output, yaml) = await ExecuteAsync("routers", "summary");
@@ -111,13 +109,10 @@ public class RouterWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
         // List again
         (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 GPUs", (await ExecuteAsync("servers", "gpu", "--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");
 
-        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");
-        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)

+ 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("Delete a service", (await ExecuteAsync("services", "del", "--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",
             output);
 
-        // List services (strict table)
+        // List services (flexible table check)
         (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");
-        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");
-        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");
-        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

+ 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("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("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
         (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
         (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
         (output, yaml) = await ExecuteAsync("switches", "del", "sw02");
@@ -120,13 +116,10 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
         // List again
         (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("Delete a system", (await ExecuteAsync("systems", "del", "--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",
             output);
 
-        // List systems (strict table)
+        // List systems (flexible table check)
         (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");
-        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)
         (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");
         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);
     }
 }