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

Merge pull request #338 from BialySztorm/feature/ports-laptop-other-ups

Add ports to Laptop, Other and Ups, and a usb port type
Tim Jones 1 день назад
Родитель
Сommit
1443bfe29d
48 измененных файлов с 1468 добавлено и 15 удалено
  1. 16 0
      RackPeek.Domain/Helpers/PortSummaries.cs
  2. 2 0
      RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs
  3. 2 1
      RackPeek.Domain/Resources/Laptops/Laptop.cs
  4. 2 0
      RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs
  5. 5 1
      RackPeek.Domain/Resources/OtherHardware/Other.cs
  6. 4 1
      RackPeek.Domain/Resources/SubResources/Nic.cs
  7. 2 0
      RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs
  8. 5 1
      RackPeek.Domain/Resources/UpsUnits/Ups.cs
  9. 20 1
      RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json
  10. 20 1
      RackPeek.Web/wwwroot/schemas/v4/schema.v4.json
  11. 32 0
      Shared.Rcl/CliBootstrap.cs
  12. 1 0
      Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs
  13. 23 0
      Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddCommand.cs
  14. 22 0
      Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddSettings.cs
  15. 23 0
      Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveCommand.cs
  16. 14 0
      Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveSettings.cs
  17. 23 0
      Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetCommand.cs
  18. 26 0
      Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetSettings.cs
  19. 1 0
      Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs
  20. 35 0
      Shared.Rcl/Commands/OtherHardware/Ports/OtherPortAddCommand.cs
  21. 28 0
      Shared.Rcl/Commands/OtherHardware/Ports/OtherPortRemoveCommand.cs
  22. 40 0
      Shared.Rcl/Commands/OtherHardware/Ports/OtherPortUpdateCommand.cs
  23. 35 0
      Shared.Rcl/Commands/Ups/Ports/UpsPortAddCommand.cs
  24. 27 0
      Shared.Rcl/Commands/Ups/Ports/UpsPortRemoveCommand.cs
  25. 39 0
      Shared.Rcl/Commands/Ups/Ports/UpsPortUpdateCommand.cs
  26. 1 0
      Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs
  27. 7 0
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  28. 7 0
      Shared.Rcl/OtherHardware/OtherCardComponent.razor
  29. 7 0
      Shared.Rcl/Ups/UpsCardComponent.razor
  30. 12 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  31. 214 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  32. 14 8
      Shared.Rcl/wwwroot/raw_docs/resource-levels.md
  33. 44 0
      Tests.E2e/LaptopCardTests.cs
  34. 45 0
      Tests.E2e/OtherCardTests.cs
  35. 24 0
      Tests.E2e/PageObjectModels/LaptopCardPom.cs
  36. 24 0
      Tests.E2e/PageObjectModels/OtherCardPom.cs
  37. 24 0
      Tests.E2e/PageObjectModels/UpsCardPom.cs
  38. 48 0
      Tests.E2e/UpsCardTests.cs
  39. 7 0
      Tests/EndToEnd/LaptopTests/LaptopCommandTests.cs
  40. 49 0
      Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs
  41. 109 0
      Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs
  42. 13 0
      Tests/EndToEnd/OtherTests/OtherCommandTests.cs
  43. 53 0
      Tests/EndToEnd/OtherTests/OtherErrorTests.cs
  44. 114 0
      Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs
  45. 13 0
      Tests/EndToEnd/UpsTests/UpsCommandTests.cs
  46. 53 0
      Tests/EndToEnd/UpsTests/UpsErrorTest.cs
  47. 119 0
      Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs
  48. 20 1
      schemas/v4/schema.v4.json

+ 16 - 0
RackPeek.Domain/Helpers/PortSummaries.cs

@@ -0,0 +1,16 @@
+using RackPeek.Domain.Resources.SubResources;
+
+namespace RackPeek.Domain.Helpers;
+
+public static class PortSummaries {
+    public static string Describe(List<Port>? ports) {
+        if (ports == null || ports.Count == 0)
+            return "None";
+
+        IEnumerable<string> groups = ports
+            .GroupBy(p => p.Type ?? "Unknown")
+            .Select(g => $"{g.Key}: {g.Sum(p => p.Count ?? 0)}");
+
+        return string.Join(", ", groups);
+    }
+}

+ 2 - 0
RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs

@@ -22,6 +22,7 @@ public class DescribeLaptopUseCase(IResourceCollection repository) : IUseCase {
             ramSummary,
             laptop.Drives?.Count ?? 0,
             laptop.Gpus?.Count ?? 0,
+            laptop.Ports?.Count ?? 0,
             laptop.Labels
         );
     }
@@ -33,5 +34,6 @@ public record LaptopDescription(
     string? RamSummary,
     int DriveCount,
     int GpuCount,
+    int NicCount,
     Dictionary<string, string> Labels
 );

+ 2 - 1
RackPeek.Domain/Resources/Laptops/Laptop.cs

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

+ 2 - 0
RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs

@@ -7,6 +7,7 @@ public record OtherDescription(
     string Name,
     string? Model,
     string? Description,
+    string PortSummary,
     Dictionary<string, string> Labels
 );
 
@@ -23,6 +24,7 @@ public class DescribeOtherUseCase(IResourceCollection repository) : IUseCase {
             other.Name,
             other.Model,
             other.Description,
+            PortSummaries.Describe(other.Ports),
             other.Labels
         );
     }

+ 5 - 1
RackPeek.Domain/Resources/OtherHardware/Other.cs

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

+ 4 - 1
RackPeek.Domain/Resources/SubResources/Nic.cs

@@ -25,7 +25,10 @@ public class Nic {
         "xfp", "cx4",
 
         // Management / special-purpose
-        "mgmt" // Dedicated management NIC (IPMI/BMC)
+        "mgmt", // Dedicated management NIC (IPMI/BMC)
+
+        // Peripheral bus
+        "usb" // USB-attached hardware (dongles, external drives, accelerators)
     };
 
     public string? Type { get; set; }

+ 2 - 0
RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs

@@ -7,6 +7,7 @@ public record UpsDescription(
     string Name,
     string? Model,
     int? Va,
+    string PortSummary,
     Dictionary<string, string> Labels
 );
 
@@ -23,6 +24,7 @@ public class DescribeUpsUseCase(IResourceCollection repository) : IUseCase {
             ups.Name,
             ups.Model,
             ups.Va,
+            PortSummaries.Describe(ups.Ports),
             ups.Labels
         );
     }

+ 5 - 1
RackPeek.Domain/Resources/UpsUnits/Ups.cs

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

+ 20 - 1
RackPeek.Web.Viewer/wwwroot/schemas/v4/schema.v4.json

@@ -275,7 +275,8 @@
             "osfp",
             "xfp",
             "cx4",
-            "mgmt"
+            "mgmt",
+            "usb"
           ]
         },
         "speed": {
@@ -433,6 +434,12 @@
               "items": {
                 "$ref": "#/$defs/drive"
               }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }
@@ -587,6 +594,12 @@
             "va": {
               "type": "integer",
               "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }
@@ -609,6 +622,12 @@
             },
             "description": {
               "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }

+ 20 - 1
RackPeek.Web/wwwroot/schemas/v4/schema.v4.json

@@ -275,7 +275,8 @@
             "osfp",
             "xfp",
             "cx4",
-            "mgmt"
+            "mgmt",
+            "usb"
           ]
         },
         "speed": {
@@ -433,6 +434,12 @@
               "items": {
                 "$ref": "#/$defs/drive"
               }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }
@@ -587,6 +594,12 @@
             "va": {
               "type": "integer",
               "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }
@@ -609,6 +622,12 @@
             },
             "description": {
               "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }

+ 32 - 0
Shared.Rcl/CliBootstrap.cs

@@ -38,6 +38,7 @@ 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.Nics;
 using Shared.Rcl.Commands.Laptops.Rename;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers.Labels;
@@ -62,10 +63,12 @@ using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Rename;
 using Shared.Rcl.Commands.OtherHardware;
 using Shared.Rcl.Commands.OtherHardware.Labels;
+using Shared.Rcl.Commands.OtherHardware.Ports;
 using Shared.Rcl.Commands.OtherHardware.Rename;
 using Shared.Rcl.Commands.Tags;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
+using Shared.Rcl.Commands.Ups.Ports;
 using Shared.Rcl.Commands.Ups.Rename;
 using Spectre.Console;
 using Spectre.Console.Cli;
@@ -512,6 +515,16 @@ public static class CliBootstrap {
                 ups.AddCommand<UpsRenameCommand>("rename")
                     .WithDescription("Rename a UPS unit to a new name.");
 
+                ups.AddBranch("port", port => {
+                    port.SetDescription("Manage ports on a UPS unit.");
+
+                    port.AddCommand<UpsPortAddCommand>("add").WithDescription("Add a port to a UPS unit.");
+
+                    port.AddCommand<UpsPortUpdateCommand>("set").WithDescription("Update a UPS unit port.");
+
+                    port.AddCommand<UpsPortRemoveCommand>("del").WithDescription("Remove a port from a UPS unit.");
+                });
+
                 ups.AddBranch("label", label => {
                     label.SetDescription("Manage labels on a UPS unit.");
                     label.AddCommand<UpsLabelAddCommand>("add").WithDescription("Add a label to a UPS unit.");
@@ -553,6 +566,17 @@ public static class CliBootstrap {
                 other.AddCommand<OtherRenameCommand>("rename")
                     .WithDescription("Rename other hardware to a new name.");
 
+                other.AddBranch("port", port => {
+                    port.SetDescription("Manage ports on other hardware.");
+
+                    port.AddCommand<OtherPortAddCommand>("add").WithDescription("Add a port to other hardware.");
+
+                    port.AddCommand<OtherPortUpdateCommand>("set").WithDescription("Update an other hardware port.");
+
+                    port.AddCommand<OtherPortRemoveCommand>("del")
+                        .WithDescription("Remove a port from other hardware.");
+                });
+
                 other.AddBranch("label", label => {
                     label.SetDescription("Manage labels on other hardware.");
                     label.AddCommand<OtherLabelAddCommand>("add").WithDescription("Add a label to other hardware.");
@@ -688,6 +712,14 @@ public static class CliBootstrap {
                     gpu.AddCommand<LaptopGpuRemoveCommand>("del").WithDescription("Remove a GPU from a Laptop.");
                 });
 
+                // NICs
+                laptops.AddBranch("nic", nic => {
+                    nic.SetDescription("Manage network interface cards (NICs) for Laptops.");
+                    nic.AddCommand<LaptopNicAddCommand>("add").WithDescription("Add a NIC to a Laptop.");
+                    nic.AddCommand<LaptopNicSetCommand>("set").WithDescription("Update a Laptop NIC.");
+                    nic.AddCommand<LaptopNicRemoveCommand>("del").WithDescription("Remove a NIC from a Laptop.");
+                });
+
                 laptops.AddBranch("label", label => {
                     label.SetDescription("Manage labels on a laptop.");
                     label.AddCommand<LaptopLabelAddCommand>("add").WithDescription("Add a label to a laptop.");

+ 1 - 0
Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs

@@ -23,6 +23,7 @@ public class LaptopDescribeCommand(IServiceProvider provider)
         grid.AddRow("RAM:", result.RamSummary ?? "None");
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
+        grid.AddRow("NICs:", result.NicCount.ToString());
 
         if (result.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));

+ 23 - 0
Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddCommand.cs

@@ -0,0 +1,23 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Laptops.Nics;
+
+public class LaptopNicAddCommand(IServiceProvider provider)
+    : AsyncCommand<LaptopNicAddSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        LaptopNicAddSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        IAddPortUseCase<Laptop> useCase = scope.ServiceProvider.GetRequiredService<IAddPortUseCase<Laptop>>();
+
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Type, settings.Speed, settings.Ports);
+
+        AnsiConsole.MarkupLine($"[green]NIC added to Laptop '{settings.LaptopName}'.[/]");
+        return 0;
+    }
+}

+ 22 - 0
Shared.Rcl/Commands/Laptops/Nics/LaptopNicAddSettings.cs

@@ -0,0 +1,22 @@
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Laptops.Nics;
+
+public class LaptopNicAddSettings : CommandSettings {
+    [CommandArgument(0, "<Laptop>")]
+    [Description("The name of the Laptop.")]
+    public string LaptopName { get; set; } = default!;
+
+    [CommandOption("--type")]
+    [Description("The nic port type e.g rj45 / sfp+")]
+    public string? Type { get; set; }
+
+    [CommandOption("--speed")]
+    [Description("The port speed.")]
+    public double? Speed { get; set; }
+
+    [CommandOption("--ports")]
+    [Description("The number of ports.")]
+    public int? Ports { get; set; }
+}

+ 23 - 0
Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveCommand.cs

@@ -0,0 +1,23 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Laptops.Nics;
+
+public class LaptopNicRemoveCommand(IServiceProvider provider)
+    : AsyncCommand<LaptopNicRemoveSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        LaptopNicRemoveSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        IRemovePortUseCase<Laptop> useCase = scope.ServiceProvider.GetRequiredService<IRemovePortUseCase<Laptop>>();
+
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Index);
+
+        AnsiConsole.MarkupLine($"[green]NIC #{settings.Index} removed from Laptop '{settings.LaptopName}'.[/]");
+        return 0;
+    }
+}

+ 14 - 0
Shared.Rcl/Commands/Laptops/Nics/LaptopNicRemoveSettings.cs

@@ -0,0 +1,14 @@
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Laptops.Nics;
+
+public class LaptopNicRemoveSettings : CommandSettings {
+    [CommandArgument(0, "<Laptop>")]
+    [Description("The Laptop name.")]
+    public string LaptopName { get; set; } = default!;
+
+    [CommandArgument(1, "<index>")]
+    [Description("The index of the nic to remove.")]
+    public int Index { get; set; }
+}

+ 23 - 0
Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetCommand.cs

@@ -0,0 +1,23 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Laptops.Nics;
+
+public class LaptopNicSetCommand(IServiceProvider provider)
+    : AsyncCommand<LaptopNicSetSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        LaptopNicSetSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        IUpdatePortUseCase<Laptop> useCase = scope.ServiceProvider.GetRequiredService<IUpdatePortUseCase<Laptop>>();
+
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Index, settings.Type, settings.Speed, settings.Ports);
+
+        AnsiConsole.MarkupLine($"[green]NIC #{settings.Index} updated on Laptop '{settings.LaptopName}'.[/]");
+        return 0;
+    }
+}

+ 26 - 0
Shared.Rcl/Commands/Laptops/Nics/LaptopNicSetSettings.cs

@@ -0,0 +1,26 @@
+using System.ComponentModel;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.Laptops.Nics;
+
+public class LaptopNicSetSettings : CommandSettings {
+    [CommandArgument(0, "<Laptop>")]
+    [Description("The Laptop name.")]
+    public string LaptopName { get; set; } = default!;
+
+    [CommandArgument(1, "<index>")]
+    [Description("The index of the nic to update.")]
+    public int Index { get; set; }
+
+    [CommandOption("--type")]
+    [Description("The nic port type e.g rj45 / sfp+")]
+    public string? Type { get; set; }
+
+    [CommandOption("--speed")]
+    [Description("The port speed.")]
+    public double? Speed { get; set; }
+
+    [CommandOption("--ports")]
+    [Description("The number of ports.")]
+    public int? Ports { get; set; }
+}

+ 1 - 0
Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs

@@ -23,6 +23,7 @@ public class OtherDescribeCommand(IServiceProvider provider)
         grid.AddRow("Name:", other.Name.EscapeMarkup());
         grid.AddRow("Model:", (other.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("Description:", (other.Description ?? "Unknown").EscapeMarkup());
+        grid.AddRow("Ports:", other.PortSummary.EscapeMarkup());
 
         if (other.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", other.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));

+ 35 - 0
Shared.Rcl/Commands/OtherHardware/Ports/OtherPortAddCommand.cs

@@ -0,0 +1,35 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware.Ports;
+
+public class OtherPortAddSettings : OtherNameSettings {
+    [CommandOption("--type")]
+    [Description("The port type (e.g., rj45, sfp+).")]
+    public string? Type { get; set; }
+
+    [CommandOption("--speed")]
+    [Description("The port speed (e.g., 1, 2.5, 10).")]
+    public double? Speed { get; set; }
+
+    [CommandOption("--count")]
+    [Description("Number of ports of this type.")]
+    public int? Count { get; set; }
+}
+
+public class OtherPortAddCommand(IServiceProvider sp)
+    : AsyncCommand<OtherPortAddSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext ctx, OtherPortAddSettings s, CancellationToken ct) {
+        using IServiceScope scope = sp.CreateScope();
+        IAddPortUseCase<Other> useCase = scope.ServiceProvider.GetRequiredService<IAddPortUseCase<Other>>();
+
+        await useCase.ExecuteAsync(s.Name, s.Type, s.Speed, s.Count);
+
+        AnsiConsole.MarkupLine($"[green]Port added to other hardware '{s.Name}'.[/]");
+        return 0;
+    }
+}

+ 28 - 0
Shared.Rcl/Commands/OtherHardware/Ports/OtherPortRemoveCommand.cs

@@ -0,0 +1,28 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware.Ports;
+
+public class OtherPortRemoveSettings : OtherNameSettings {
+    [CommandOption("--index <INDEX>")]
+    [Description("The index of the port to remove.")]
+    public int Index { get; set; }
+}
+
+public class OtherPortRemoveCommand(IServiceProvider sp)
+    : AsyncCommand<OtherPortRemoveSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext ctx, OtherPortRemoveSettings s,
+        CancellationToken ct) {
+        using IServiceScope scope = sp.CreateScope();
+        IRemovePortUseCase<Other> useCase = scope.ServiceProvider.GetRequiredService<IRemovePortUseCase<Other>>();
+
+        await useCase.ExecuteAsync(s.Name, s.Index);
+
+        AnsiConsole.MarkupLine($"[green]Port #{s.Index} removed from other hardware '{s.Name}'.[/]");
+        return 0;
+    }
+}

+ 40 - 0
Shared.Rcl/Commands/OtherHardware/Ports/OtherPortUpdateCommand.cs

@@ -0,0 +1,40 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware.Ports;
+
+public class OtherPortUpdateSettings : OtherNameSettings {
+    [CommandOption("--index <INDEX>")]
+    [Description("The index of the port to update.")]
+    public int Index { get; set; }
+
+    [CommandOption("--type")]
+    [Description("The port type (e.g., rj45, sfp+).")]
+    public string? Type { get; set; }
+
+    [CommandOption("--speed")]
+    [Description("The port speed (e.g., 1, 2.5, 10).")]
+    public double? Speed { get; set; }
+
+    [CommandOption("--count")]
+    [Description("Number of ports of this type.")]
+    public int? Count { get; set; }
+}
+
+public class OtherPortUpdateCommand(IServiceProvider sp)
+    : AsyncCommand<OtherPortUpdateSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext ctx, OtherPortUpdateSettings s,
+        CancellationToken ct) {
+        using IServiceScope scope = sp.CreateScope();
+        IUpdatePortUseCase<Other> useCase = scope.ServiceProvider.GetRequiredService<IUpdatePortUseCase<Other>>();
+
+        await useCase.ExecuteAsync(s.Name, s.Index, s.Type, s.Speed, s.Count);
+
+        AnsiConsole.MarkupLine($"[green]Port #{s.Index} updated on other hardware '{s.Name}'.[/]");
+        return 0;
+    }
+}

+ 35 - 0
Shared.Rcl/Commands/Ups/Ports/UpsPortAddCommand.cs

@@ -0,0 +1,35 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using UpsUnit = RackPeek.Domain.Resources.UpsUnits.Ups;
+
+namespace Shared.Rcl.Commands.Ups.Ports;
+
+public class UpsPortAddSettings : UpsNameSettings {
+    [CommandOption("--type")]
+    [Description("The port type (e.g., rj45, usb).")]
+    public string? Type { get; set; }
+
+    [CommandOption("--speed")]
+    [Description("The port speed (e.g., 0.1, 1).")]
+    public double? Speed { get; set; }
+
+    [CommandOption("--count")]
+    [Description("Number of ports of this type.")]
+    public int? Count { get; set; }
+}
+
+public class UpsPortAddCommand(IServiceProvider sp)
+    : AsyncCommand<UpsPortAddSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext ctx, UpsPortAddSettings s, CancellationToken ct) {
+        using IServiceScope scope = sp.CreateScope();
+        IAddPortUseCase<UpsUnit> useCase = scope.ServiceProvider.GetRequiredService<IAddPortUseCase<UpsUnit>>();
+
+        await useCase.ExecuteAsync(s.Name, s.Type, s.Speed, s.Count);
+
+        AnsiConsole.MarkupLine($"[green]Port added to UPS '{s.Name}'.[/]");
+        return 0;
+    }
+}

+ 27 - 0
Shared.Rcl/Commands/Ups/Ports/UpsPortRemoveCommand.cs

@@ -0,0 +1,27 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using UpsUnit = RackPeek.Domain.Resources.UpsUnits.Ups;
+
+namespace Shared.Rcl.Commands.Ups.Ports;
+
+public class UpsPortRemoveSettings : UpsNameSettings {
+    [CommandOption("--index <INDEX>")]
+    [Description("The index of the port to remove.")]
+    public int Index { get; set; }
+}
+
+public class UpsPortRemoveCommand(IServiceProvider sp)
+    : AsyncCommand<UpsPortRemoveSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext ctx, UpsPortRemoveSettings s, CancellationToken ct) {
+        using IServiceScope scope = sp.CreateScope();
+        IRemovePortUseCase<UpsUnit> useCase = scope.ServiceProvider.GetRequiredService<IRemovePortUseCase<UpsUnit>>();
+
+        await useCase.ExecuteAsync(s.Name, s.Index);
+
+        AnsiConsole.MarkupLine($"[green]Port #{s.Index} removed from UPS '{s.Name}'.[/]");
+        return 0;
+    }
+}

+ 39 - 0
Shared.Rcl/Commands/Ups/Ports/UpsPortUpdateCommand.cs

@@ -0,0 +1,39 @@
+using System.ComponentModel;
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases.Ports;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using UpsUnit = RackPeek.Domain.Resources.UpsUnits.Ups;
+
+namespace Shared.Rcl.Commands.Ups.Ports;
+
+public class UpsPortUpdateSettings : UpsNameSettings {
+    [CommandOption("--index <INDEX>")]
+    [Description("The index of the port to update.")]
+    public int Index { get; set; }
+
+    [CommandOption("--type")]
+    [Description("The port type (e.g., rj45, usb).")]
+    public string? Type { get; set; }
+
+    [CommandOption("--speed")]
+    [Description("The port speed (e.g., 0.1, 1).")]
+    public double? Speed { get; set; }
+
+    [CommandOption("--count")]
+    [Description("Number of ports of this type.")]
+    public int? Count { get; set; }
+}
+
+public class UpsPortUpdateCommand(IServiceProvider sp)
+    : AsyncCommand<UpsPortUpdateSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext ctx, UpsPortUpdateSettings s, CancellationToken ct) {
+        using IServiceScope scope = sp.CreateScope();
+        IUpdatePortUseCase<UpsUnit> useCase = scope.ServiceProvider.GetRequiredService<IUpdatePortUseCase<UpsUnit>>();
+
+        await useCase.ExecuteAsync(s.Name, s.Index, s.Type, s.Speed, s.Count);
+
+        AnsiConsole.MarkupLine($"[green]Port #{s.Index} updated on UPS '{s.Name}'.[/]");
+        return 0;
+    }
+}

+ 1 - 0
Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs

@@ -24,6 +24,7 @@ public class UpsDescribeCommand(IServiceProvider provider)
         grid.AddRow("Name:", ups.Name.EscapeMarkup());
         grid.AddRow("Model:", (ups.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown");
+        grid.AddRow("Ports:", ups.PortSummary.EscapeMarkup());
 
         if (ups.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", ups.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));

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

@@ -3,6 +3,7 @@
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
+@using Shared.Rcl.Hardware
 @inject IGetResourceByNameUseCase<Laptop> GetByNameUseCase
 @inject UpdateLaptopUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<Laptop> DeleteUseCase
@@ -176,6 +177,12 @@
             }
         </div>
 
+        <!-- NICs -->
+        <PortGroupEditor T="Laptop"
+                         Resource="Laptop"
+                         OnResourceChanged="r => Laptop = r"
+                         TestIdPrefix="laptop-ports"/>
+
     </div>
 
     <ResourceTagEditor Resource="Laptop"

+ 7 - 0
Shared.Rcl/OtherHardware/OtherCardComponent.razor

@@ -1,4 +1,5 @@
 @using RackPeek.Domain.Resources.OtherHardware
+@using Shared.Rcl.Hardware
 @inject UpdateOtherUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Other> GetByNameUseCase
 @inject IDeleteResourceUseCase<Other> DeleteUseCase
@@ -103,6 +104,12 @@
             }
         </div>
 
+        <!-- Ports -->
+        <PortGroupEditor T="Other"
+                         Resource="Other"
+                         OnResourceChanged="r => Other = r"
+                         TestIdPrefix="other-ports" />
+
         <ResourceTagEditor Resource="Other"
                            TestIdPrefix="other" />
 

+ 7 - 0
Shared.Rcl/Ups/UpsCardComponent.razor

@@ -1,4 +1,5 @@
 @using RackPeek.Domain.Resources.UpsUnits
+@using Shared.Rcl.Hardware
 @inject UpdateUpsUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Ups> GetByNameUseCase
 @inject IDeleteResourceUseCase<Ups> DeleteUseCase
@@ -111,6 +112,12 @@
             }
         </div>
 
+        <!-- Ports -->
+        <PortGroupEditor T="Ups"
+                         Resource="Ups"
+                         OnResourceChanged="r => Ups = r"
+                         TestIdPrefix="ups-ports"/>
+
         <ResourceTagEditor Resource="Ups"
                            TestIdPrefix="ups"/>
 

+ 12 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md

@@ -129,6 +129,10 @@
     - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit
     - [del](docs/Commands.md#rpk-ups-del) - Delete a UPS unit
     - [rename](docs/Commands.md#rpk-ups-rename) - Rename a UPS unit to a new name
+    - [port](docs/Commands.md#rpk-ups-port) - Manage ports on a UPS unit
+      - [add](docs/Commands.md#rpk-ups-port-add) - Add a port to a UPS unit
+      - [set](docs/Commands.md#rpk-ups-port-set) - Update a UPS unit port
+      - [del](docs/Commands.md#rpk-ups-port-del) - Remove a port from a UPS unit
     - [label](docs/Commands.md#rpk-ups-label) - Manage labels on a UPS unit
       - [add](docs/Commands.md#rpk-ups-label-add) - Add a label to a UPS unit
       - [remove](docs/Commands.md#rpk-ups-label-remove) - Remove a label from a UPS unit
@@ -144,6 +148,10 @@
     - [set](docs/Commands.md#rpk-other-set) - Update properties of other hardware
     - [del](docs/Commands.md#rpk-other-del) - Delete other hardware
     - [rename](docs/Commands.md#rpk-other-rename) - Rename other hardware to a new name
+    - [port](docs/Commands.md#rpk-other-port) - Manage ports on other hardware
+      - [add](docs/Commands.md#rpk-other-port-add) - Add a port to other hardware
+      - [set](docs/Commands.md#rpk-other-port-set) - Update an other hardware port
+      - [del](docs/Commands.md#rpk-other-port-del) - Remove a port from other hardware
     - [label](docs/Commands.md#rpk-other-label) - Manage labels on other hardware
       - [add](docs/Commands.md#rpk-other-label-add) - Add a label to other hardware
       - [remove](docs/Commands.md#rpk-other-label-remove) - Remove a label from other hardware
@@ -204,6 +212,10 @@
       - [add](docs/Commands.md#rpk-laptops-gpu-add) - Add a GPU to a Laptop
       - [set](docs/Commands.md#rpk-laptops-gpu-set) - Update a Laptop GPU
       - [del](docs/Commands.md#rpk-laptops-gpu-del) - Remove a GPU from a Laptop
+    - [nic](docs/Commands.md#rpk-laptops-nic) - Manage network interface cards (NICs) for Laptops
+      - [add](docs/Commands.md#rpk-laptops-nic-add) - Add a NIC to a Laptop
+      - [set](docs/Commands.md#rpk-laptops-nic-set) - Update a Laptop NIC
+      - [del](docs/Commands.md#rpk-laptops-nic-del) - Remove a NIC from a Laptop
     - [label](docs/Commands.md#rpk-laptops-label) - Manage labels on a laptop
       - [add](docs/Commands.md#rpk-laptops-label-add) - Add a label to a laptop
       - [remove](docs/Commands.md#rpk-laptops-label-remove) - Remove a label from a laptop

+ 214 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands.md

@@ -2022,6 +2022,7 @@ COMMANDS:
     set <name>                  Update properties of a UPS unit           
     del <name>                  Delete a UPS unit                         
     rename <name> <new-name>    Rename a UPS unit to a new name           
+    port                        Manage ports on a UPS unit                
     label                       Manage labels on a UPS unit               
     tag                         Manage tags on a UPS unit                 
 ```
@@ -2143,6 +2144,76 @@ OPTIONS:
     -h, --help    Prints help information
 ```
 
+## `rpk ups port`
+```
+DESCRIPTION:
+Manage ports on a UPS unit
+
+USAGE:
+    rpk ups port [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    add <name>    Add a port to a UPS unit     
+    set <name>    Update a UPS unit port       
+    del <name>    Remove a port from a UPS unit
+```
+
+## `rpk ups port add`
+```
+DESCRIPTION:
+Add a port to a UPS unit
+
+USAGE:
+    rpk ups port add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help     Prints help information        
+        --type     The port type (e.g., rj45, usb)
+        --speed    The port speed (e.g., 0.1, 1)  
+        --count    Number of ports of this type   
+```
+
+## `rpk ups port set`
+```
+DESCRIPTION:
+Update a UPS unit port
+
+USAGE:
+    rpk ups port set <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help             Prints help information        
+        --index <INDEX>    The index of the port to update
+        --type             The port type (e.g., rj45, usb)
+        --speed            The port speed (e.g., 0.1, 1)  
+        --count            Number of ports of this type   
+```
+
+## `rpk ups port del`
+```
+DESCRIPTION:
+Remove a port from a UPS unit
+
+USAGE:
+    rpk ups port del <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help             Prints help information        
+        --index <INDEX>    The index of the port to remove
+```
+
 ## `rpk ups label`
 ```
 DESCRIPTION:
@@ -2260,6 +2331,7 @@ COMMANDS:
     set <name>                  Update properties of other hardware           
     del <name>                  Delete other hardware                         
     rename <name> <new-name>    Rename other hardware to a new name           
+    port                        Manage ports on other hardware                
     label                       Manage labels on other hardware               
     tag                         Manage tags on other hardware                 
 ```
@@ -2381,6 +2453,76 @@ OPTIONS:
     -h, --help    Prints help information
 ```
 
+## `rpk other port`
+```
+DESCRIPTION:
+Manage ports on other hardware
+
+USAGE:
+    rpk other port [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    add <name>    Add a port to other hardware     
+    set <name>    Update an other hardware port    
+    del <name>    Remove a port from other hardware
+```
+
+## `rpk other port add`
+```
+DESCRIPTION:
+Add a port to other hardware
+
+USAGE:
+    rpk other port add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help     Prints help information          
+        --type     The port type (e.g., rj45, sfp+) 
+        --speed    The port speed (e.g., 1, 2.5, 10)
+        --count    Number of ports of this type     
+```
+
+## `rpk other port set`
+```
+DESCRIPTION:
+Update an other hardware port
+
+USAGE:
+    rpk other port set <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help             Prints help information          
+        --index <INDEX>    The index of the port to update  
+        --type             The port type (e.g., rj45, sfp+) 
+        --speed            The port speed (e.g., 1, 2.5, 10)
+        --count            Number of ports of this type     
+```
+
+## `rpk other port del`
+```
+DESCRIPTION:
+Remove a port from other hardware
+
+USAGE:
+    rpk other port del <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help             Prints help information        
+        --index <INDEX>    The index of the port to remove
+```
+
 ## `rpk other label`
 ```
 DESCRIPTION:
@@ -3038,6 +3180,8 @@ COMMANDS:
     cpu                         Manage CPUs attached to Laptops                 
     drive                       Manage storage drives attached to Laptops       
     gpu                         Manage GPUs attached to Laptops                 
+    nic                         Manage network interface cards (NICs) for       
+                                Laptops                                         
     label                       Manage labels on a laptop                       
     tag                         Manage tags on a laptop                         
 ```
@@ -3379,6 +3523,76 @@ OPTIONS:
     -h, --help    Prints help information
 ```
 
+## `rpk laptops nic`
+```
+DESCRIPTION:
+Manage network interface cards (NICs) for Laptops
+
+USAGE:
+    rpk laptops nic [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    add <Laptop>            Add a NIC to a Laptop     
+    set <Laptop> <index>    Update a Laptop NIC       
+    del <Laptop> <index>    Remove a NIC from a Laptop
+```
+
+## `rpk laptops nic add`
+```
+DESCRIPTION:
+Add a NIC to a Laptop
+
+USAGE:
+    rpk laptops nic add <Laptop> [OPTIONS]
+
+ARGUMENTS:
+    <Laptop>    The name of the Laptop
+
+OPTIONS:
+    -h, --help     Prints help information          
+        --type     The nic port type e.g rj45 / sfp+
+        --speed    The port speed                   
+        --ports    The number of ports              
+```
+
+## `rpk laptops nic set`
+```
+DESCRIPTION:
+Update a Laptop NIC
+
+USAGE:
+    rpk laptops nic set <Laptop> <index> [OPTIONS]
+
+ARGUMENTS:
+    <Laptop>    The Laptop name               
+    <index>     The index of the nic to update
+
+OPTIONS:
+    -h, --help     Prints help information          
+        --type     The nic port type e.g rj45 / sfp+
+        --speed    The port speed                   
+        --ports    The number of ports              
+```
+
+## `rpk laptops nic del`
+```
+DESCRIPTION:
+Remove a NIC from a Laptop
+
+USAGE:
+    rpk laptops nic del <Laptop> <index> [OPTIONS]
+
+ARGUMENTS:
+    <Laptop>    The Laptop name               
+    <index>     The index of the nic to remove
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
 ## `rpk laptops label`
 ```
 DESCRIPTION:

+ 14 - 8
Shared.Rcl/wwwroot/raw_docs/resource-levels.md

@@ -48,14 +48,20 @@ network switches, Wi-Fi access points, UPS units, and workstations.
 
 Some hardware types support sub-resources that describe their internal components.
 
-| Sub-Resource | Server | Desktop | Laptop | Switch | Router | Firewall |
-|--------------|:------:|:-------:|:------:|:------:|:------:|:--------:|
-| CPU          |  Yes   |   Yes   |  Yes   |        |        |          |
-| Drive        |  Yes   |   Yes   |  Yes   |        |        |          |
-| GPU          |  Yes   |   Yes   |  Yes   |        |        |          |
-| NIC          |  Yes   |   Yes   |        |        |        |          |
-| Port         |        |         |        |  Yes   |  Yes   |   Yes    |
-| RAM          |  Yes   |   Yes   |  Yes   |        |        |          |
+| Sub-Resource | Server | Desktop | Laptop | Switch | Router | Firewall | Access Point | UPS | Other |
+|--------------|:------:|:-------:|:------:|:------:|:------:|:--------:|:------------:|:---:|:-----:|
+| CPU          |  Yes   |   Yes   |  Yes   |        |        |          |              |     |       |
+| Drive        |  Yes   |   Yes   |  Yes   |        |        |          |              |     |       |
+| GPU          |  Yes   |   Yes   |  Yes   |        |        |          |              |     |       |
+| NIC          |  Yes   |   Yes   |  Yes   |        |        |          |              |     |       |
+| Port         |        |         |        |  Yes   |  Yes   |   Yes    |     Yes*     | Yes |  Yes  |
+| RAM          |  Yes   |   Yes   |  Yes   |        |        |          |              |     |       |
+
+\* Access Point ports are editable in the web UI and in YAML, but have no `rpk accesspoints port` CLI branch yet.
+
+NIC and Port are the same underlying sub-resource — they only differ in the CLI branch used to manage them. Compute
+kinds expose it as `rpk <kind> nic`, network and appliance kinds as `rpk <kind> port`. Either way the resource can be
+wired up with `rpk connections add` and shows up in `rpk graph topology`.
 
 Hardware is the foundation. Nothing runs "on" hardware in the RackPeek sense — hardware just exists. Systems and
 services cannot be hardware; they live on top of it.

+ 44 - 0
Tests.E2e/LaptopCardTests.cs

@@ -326,4 +326,48 @@ public class LaptopCardTests(
             await context.CloseAsync();
         }
     }
+
+    // =============================================================
+    // NICs (ports)
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Add_Nics_To_A_Laptop() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var name = $"e2e-lap-{Guid.NewGuid():N}"[..16];
+
+        try {
+            var list = new LaptopListPom(page);
+            await list.GotoAsync(_fixture.BaseUrl);
+            await list.AssertLoadedAsync();
+
+            await list.AddLaptopAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync();
+
+            await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync();
+
+            // Built-in wired NIC plus a USB-attached dock.
+            await card.AddPortGroupAsync("rj45", "1", 1);
+            await card.AssertPortGroupVisibleAsync(0);
+
+            await card.AddPortGroupAsync("usb", "10", 2);
+            await card.AssertPortGroupVisibleAsync(1);
+
+            await page.ReloadAsync();
+            await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync();
+
+            await card.AssertPortVisibleAsync(0, 0);
+            await card.AssertPortVisibleAsync(1, 0);
+            await card.AssertPortVisibleAsync(1, 1);
+
+            await card.DeleteLaptopAsync(name);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }

+ 45 - 0
Tests.E2e/OtherCardTests.cs

@@ -222,4 +222,49 @@ public class OtherCardTests(
             await context.CloseAsync();
         }
     }
+
+    // =============================================================
+    // Ports
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Add_Port_Groups_To_Other_Hardware() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var name = $"e2e-oth-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/other/list");
+
+            var list = new OtherListPom(page);
+            await list.AddOtherAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+                await list.OpenOtherAsync(name);
+
+            var card = new OtherCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync();
+
+            await card.AddPortGroupAsync("rj45", "0.1", 1);
+            await card.AssertPortGroupVisibleAsync(0);
+
+            await card.AddPortGroupAsync("usb", "0.48", 2);
+            await card.AssertPortGroupVisibleAsync(1);
+
+            await page.ReloadAsync();
+            await card.AssertVisibleAsync(name);
+
+            await card.AssertPortVisibleAsync(0, 0);
+            await card.AssertPortVisibleAsync(1, 0);
+            await card.AssertPortVisibleAsync(1, 1);
+
+            await card.DeleteAsync(name);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }

+ 24 - 0
Tests.E2e/PageObjectModels/LaptopCardPom.cs

@@ -6,6 +6,10 @@ public class LaptopCardPom(IPage page) {
     public TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "laptop-ports";
+
     // -------------------------------------------------
     // Modals
     // -------------------------------------------------
@@ -184,4 +188,24 @@ public class LaptopCardPom(IPage page) {
 
     private static string Sanitize(string value)
         => value.Replace(" ", "-");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortGroupSection => Ports.Root(_portsPrefix);
+
+    public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index);
+
+    public ILocator Port(int groupIndex, int portIndex)
+        => Ports.Port(_portsPrefix, groupIndex, portIndex);
+
+    public async Task AddPortGroupAsync(string type, string speed, int count)
+        => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count);
+
+    public async Task AssertPortGroupVisibleAsync(int index)
+        => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index);
+
+    public async Task AssertPortVisibleAsync(int groupIndex, int portIndex)
+        => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex);
 }

+ 24 - 0
Tests.E2e/PageObjectModels/OtherCardPom.cs

@@ -6,6 +6,10 @@ public class OtherCardPom(IPage page) {
     public TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "other-ports";
+
     // -------------------------------------------------
     // Notes
     // -------------------------------------------------
@@ -139,4 +143,24 @@ public class OtherCardPom(IPage page) {
         await DeleteButton(name).ClickAsync();
         await ConfirmDeleteButton.ClickAsync();
     }
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortGroupSection => Ports.Root(_portsPrefix);
+
+    public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index);
+
+    public ILocator Port(int groupIndex, int portIndex)
+        => Ports.Port(_portsPrefix, groupIndex, portIndex);
+
+    public async Task AddPortGroupAsync(string type, string speed, int count)
+        => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count);
+
+    public async Task AssertPortGroupVisibleAsync(int index)
+        => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index);
+
+    public async Task AssertPortVisibleAsync(int groupIndex, int portIndex)
+        => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex);
 }

+ 24 - 0
Tests.E2e/PageObjectModels/UpsCardPom.cs

@@ -6,6 +6,10 @@ public class UpsCardPom(IPage page) {
     public TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "ups-ports";
+
     // -------------------------------------------------
     // Notes
     // -------------------------------------------------
@@ -139,4 +143,24 @@ public class UpsCardPom(IPage page) {
         await DeleteButton(name).ClickAsync();
         await ConfirmDeleteButton.ClickAsync();
     }
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortGroupSection => Ports.Root(_portsPrefix);
+
+    public ILocator PortGroup(int index) => Ports.PortGroup(_portsPrefix, index);
+
+    public ILocator Port(int groupIndex, int portIndex)
+        => Ports.Port(_portsPrefix, groupIndex, portIndex);
+
+    public async Task AddPortGroupAsync(string type, string speed, int count)
+        => await Ports.AddPortGroupAsync(_portsPrefix, type, speed, count);
+
+    public async Task AssertPortGroupVisibleAsync(int index)
+        => await Ports.AssertPortGroupVisibleAsync(_portsPrefix, index);
+
+    public async Task AssertPortVisibleAsync(int groupIndex, int portIndex)
+        => await Ports.AssertPortVisibleAsync(_portsPrefix, groupIndex, portIndex);
 }

+ 48 - 0
Tests.E2e/UpsCardTests.cs

@@ -222,4 +222,52 @@ public class UpsCardTests(
             await context.CloseAsync();
         }
     }
+
+    // =============================================================
+    // Ports
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Add_Usb_And_Rj45_Port_Groups_To_A_Ups() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var name = $"e2e-ups-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+                await list.OpenUpsAsync(name);
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await Assertions.Expect(card.PortGroupSection).ToBeVisibleAsync();
+
+            // The monitoring port: physically RJ45-shaped, enumerates as USB.
+            await card.AddPortGroupAsync("usb", "0.48", 1);
+            await card.AssertPortGroupVisibleAsync(0);
+
+            // The dataline surge pass-through pair.
+            await card.AddPortGroupAsync("rj45", "1", 2);
+            await card.AssertPortGroupVisibleAsync(1);
+
+            // Both groups must survive a round trip through the API.
+            await page.ReloadAsync();
+            await card.AssertVisibleAsync(name);
+
+            await card.AssertPortVisibleAsync(0, 0);
+            await card.AssertPortVisibleAsync(1, 0);
+            await card.AssertPortVisibleAsync(1, 1);
+
+            await card.DeleteAsync(name);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
 }

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

@@ -49,6 +49,13 @@ public class LaptopCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputH
 
         // GPU help
         Assert.Contains("Manage GPUs", (await ExecuteAsync("laptops", "gpu", "--help")).Item1);
+
+        // NIC help
+        Assert.Contains("Manage network interface cards", (await ExecuteAsync("laptops", "nic", "--help")).Item1);
+        Assert.Contains("Add a NIC to a Laptop", (await ExecuteAsync("laptops", "nic", "add", "--help")).Item1);
+        Assert.Contains("Update a Laptop NIC", (await ExecuteAsync("laptops", "nic", "set", "--help")).Item1);
+        Assert.Contains("Remove a NIC from a Laptop", (await ExecuteAsync("laptops", "nic", "del", "--help")).Item1);
+
         Assert.Contains("Rename a Laptop", (await ExecuteAsync("laptops", "rename", "--help")).Item1);
     }
 

+ 49 - 0
Tests/EndToEnd/LaptopTests/LaptopErrorTests.cs

@@ -87,4 +87,53 @@ public class LaptopErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
 
         Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
     }
+
+
+    // NIC errors
+    [Fact]
+    public async Task nic_add_missing_laptop_returns_error() {
+        (var output, var _) = await ExecuteAsync(
+            "laptops", "nic", "add", "ghost",
+            "--type", "rj45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task nic_add_invalid_type_returns_error() {
+        await ExecuteAsync("laptops", "add", "lap01");
+
+        (var output, var _) = await ExecuteAsync(
+            "laptops", "nic", "add", "lap01",
+            "--type", "not-a-port-type",
+            "--speed", "1",
+            "--ports", "1"
+        );
+
+        Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task nic_set_invalid_index_returns_error() {
+        await ExecuteAsync("laptops", "add", "lap01");
+
+        (var output, var _) = await ExecuteAsync(
+            "laptops", "nic", "set", "lap01", "4",
+            "--type", "rj45"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task nic_del_invalid_index_returns_error() {
+        await ExecuteAsync("laptops", "add", "lap01");
+
+        (var output, var _) = await ExecuteAsync("laptops", "nic", "del", "lap01", "2");
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
 }

+ 109 - 0
Tests/EndToEnd/LaptopTests/LaptopNicWorkflowTests.cs

@@ -0,0 +1,109 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.LaptopTests;
+
+[Collection("Yaml CLI tests")]
+public class LaptopNicWorkflowTests(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 laptop_nic_cli_workflow_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("laptops", "add", "lap01");
+        await ExecuteAsync("laptops", "set", "lap01", "--model", "ThinkPad X1 Carbon");
+
+        // Built-in wired NIC.
+        (var output, var yaml) = await ExecuteAsync(
+            "laptops", "nic", "add", "lap01",
+            "--type", "rj45",
+            "--speed", "1",
+            "--ports", "1"
+        );
+        Assert.Equal("NIC added to Laptop 'lap01'.\n", output);
+
+        // Dock, attached over USB.
+        (output, yaml) = await ExecuteAsync(
+            "laptops", "nic", "add", "lap01",
+            "--type", "usb",
+            "--speed", "10",
+            "--ports", "2"
+        );
+        Assert.Equal("NIC added to Laptop 'lap01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Laptop
+                       model: ThinkPad X1 Carbon
+                       ports:
+                       - type: rj45
+                         speed: 1
+                         count: 1
+                       - type: usb
+                         speed: 10
+                         count: 2
+                       name: lap01
+                     connections: []
+
+                     """, yaml);
+
+        (output, yaml) = await ExecuteAsync(
+            "laptops", "nic", "set", "lap01", "1",
+            "--type", "usb",
+            "--speed", "20",
+            "--ports", "2"
+        );
+        Assert.Equal("NIC #1 updated on Laptop 'lap01'.\n", output);
+        Assert.Contains("speed: 20", yaml);
+
+        // Describe reports the NIC count, matching how desktops report theirs.
+        (output, yaml) = await ExecuteAsync("laptops", "describe", "lap01");
+        Assert.Contains("NICs:", output);
+        Assert.Contains("2", output);
+
+        (output, yaml) = await ExecuteAsync("laptops", "nic", "del", "lap01", "1");
+        Assert.Equal("NIC #1 removed from Laptop 'lap01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Laptop
+                       model: ThinkPad X1 Carbon
+                       ports:
+                       - type: rj45
+                         speed: 1
+                         count: 1
+                       name: lap01
+                     connections: []
+
+                     """, yaml);
+    }
+
+    [Fact]
+    public async Task describe_reports_zero_nics_when_laptop_has_none() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("laptops", "add", "lap-bare");
+
+        (var output, var _) = await ExecuteAsync("laptops", "describe", "lap-bare");
+
+        Assert.Contains("NICs:", output);
+        Assert.Contains("0", output);
+    }
+}

+ 13 - 0
Tests/EndToEnd/OtherTests/OtherCommandTests.cs

@@ -55,6 +55,19 @@ public class OtherCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHe
         Assert.Contains("Delete other hardware", delHelp);
         (var renameHelp, var _) = await ExecuteAsync("other", "rename", "--help");
         Assert.Contains("Rename other hardware", renameHelp);
+
+        // Port help
+        (var portHelp, var _) = await ExecuteAsync("other", "port", "--help");
+        Assert.Contains("Manage ports on other hardware", portHelp);
+
+        (var portAddHelp, var _) = await ExecuteAsync("other", "port", "add", "--help");
+        Assert.Contains("Add a port to other hardware", portAddHelp);
+
+        (var portSetHelp, var _) = await ExecuteAsync("other", "port", "set", "--help");
+        Assert.Contains("Update an other hardware port", portSetHelp);
+
+        (var portDelHelp, var _) = await ExecuteAsync("other", "port", "del", "--help");
+        Assert.Contains("Remove a port from other hardware", portDelHelp);
     }
 
     [Fact]

+ 53 - 0
Tests/EndToEnd/OtherTests/OtherErrorTests.cs

@@ -57,4 +57,57 @@ public class OtherErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
 
         Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
     }
+
+
+    // Port errors
+    [Fact]
+    public async Task port_add_missing_other_returns_error() {
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "add", "ghost",
+            "--type", "rj45",
+            "--speed", "1",
+            "--count", "1"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_add_invalid_type_returns_error() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "add", "radio01",
+            "--type", "not-a-port-type",
+            "--speed", "1",
+            "--count", "1"
+        );
+
+        Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_set_invalid_index_returns_error() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "set", "radio01",
+            "--index", "5",
+            "--type", "rj45"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_del_invalid_index_returns_error() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var _) = await ExecuteAsync(
+            "other", "port", "del", "radio01",
+            "--index", "3"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
 }

+ 114 - 0
Tests/EndToEnd/OtherTests/OtherPortWorkflowTests.cs

@@ -0,0 +1,114 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.OtherTests;
+
+[Collection("Yaml CLI tests")]
+public class OtherPortWorkflowTests(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 other_port_cli_workflow_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("other", "add", "decoder01");
+        await ExecuteAsync(
+            "other", "set", "decoder01",
+            "--model", "TVIP-v605",
+            "--description", "IPTV set-top box"
+        );
+
+        (var output, var yaml) = await ExecuteAsync(
+            "other", "port", "add", "decoder01",
+            "--type", "rj45",
+            "--speed", "0.1",
+            "--count", "1"
+        );
+        Assert.Equal("Port added to other hardware 'decoder01'.\n", output);
+
+        (output, yaml) = await ExecuteAsync(
+            "other", "port", "add", "decoder01",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "2"
+        );
+        Assert.Equal("Port added to other hardware 'decoder01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Other
+                       model: TVIP-v605
+                       description: IPTV set-top box
+                       ports:
+                       - type: rj45
+                         speed: 0.1
+                         count: 1
+                       - type: usb
+                         speed: 0.48
+                         count: 2
+                       name: decoder01
+                     connections: []
+
+                     """, yaml);
+
+        (output, yaml) = await ExecuteAsync(
+            "other", "port", "set", "decoder01",
+            "--index", "1",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "3"
+        );
+        Assert.Equal("Port #1 updated on other hardware 'decoder01'.\n", output);
+        Assert.Contains("count: 3", yaml);
+
+        (output, yaml) = await ExecuteAsync("other", "describe", "decoder01");
+        Assert.Contains("Ports:", output);
+        Assert.Contains("rj45: 1", output);
+        Assert.Contains("usb: 3", output);
+
+        (output, yaml) = await ExecuteAsync("other", "port", "del", "decoder01", "--index", "0");
+        Assert.Equal("Port #0 removed from other hardware 'decoder01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Other
+                       model: TVIP-v605
+                       description: IPTV set-top box
+                       ports:
+                       - type: usb
+                         speed: 0.48
+                         count: 3
+                       name: decoder01
+                     connections: []
+
+                     """, yaml);
+    }
+
+    [Fact]
+    public async Task describe_reports_none_when_other_has_no_ports() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("other", "add", "bare01");
+
+        (var output, var _) = await ExecuteAsync("other", "describe", "bare01");
+
+        Assert.Contains("Ports:", output);
+        Assert.Contains("None", output);
+    }
+}

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

@@ -55,6 +55,19 @@ public class UpsCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
         Assert.Contains("Delete a UPS unit", delHelp);
         (var renameHelp, var _) = await ExecuteAsync("ups", "rename", "--help");
         Assert.Contains("Rename a UPS unit", renameHelp);
+
+        // Port help
+        (var portHelp, var _) = await ExecuteAsync("ups", "port", "--help");
+        Assert.Contains("Manage ports on a UPS unit", portHelp);
+
+        (var portAddHelp, var _) = await ExecuteAsync("ups", "port", "add", "--help");
+        Assert.Contains("Add a port to a UPS unit", portAddHelp);
+
+        (var portSetHelp, var _) = await ExecuteAsync("ups", "port", "set", "--help");
+        Assert.Contains("Update a UPS unit port", portSetHelp);
+
+        (var portDelHelp, var _) = await ExecuteAsync("ups", "port", "del", "--help");
+        Assert.Contains("Remove a port from a UPS unit", portDelHelp);
     }
 
     [Fact]

+ 53 - 0
Tests/EndToEnd/UpsTests/UpsErrorTest.cs

@@ -62,4 +62,57 @@ public class UpsErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper
 
         Assert.Contains("error", output, StringComparison.OrdinalIgnoreCase);
     }
+
+
+    // Port errors
+    [Fact]
+    public async Task port_add_missing_ups_returns_error() {
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "add", "ghost",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "1"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_add_invalid_type_returns_error() {
+        await ExecuteAsync("ups", "add", "ups01");
+
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "add", "ups01",
+            "--type", "not-a-port-type",
+            "--speed", "1",
+            "--count", "1"
+        );
+
+        Assert.Contains("not valid", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_set_invalid_index_returns_error() {
+        await ExecuteAsync("ups", "add", "ups01");
+
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "set", "ups01",
+            "--index", "5",
+            "--type", "usb"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task port_del_invalid_index_returns_error() {
+        await ExecuteAsync("ups", "add", "ups01");
+
+        (var output, var _) = await ExecuteAsync(
+            "ups", "port", "del", "ups01",
+            "--index", "3"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
 }

+ 119 - 0
Tests/EndToEnd/UpsTests/UpsPortWorkflowTests.cs

@@ -0,0 +1,119 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.UpsTests;
+
+[Collection("Yaml CLI tests")]
+public class UpsPortWorkflowTests(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 ups_port_cli_workflow_test() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("ups", "add", "ups01");
+        await ExecuteAsync("ups", "set", "ups01", "--model", "APC-BGM2200", "--va", "2200");
+
+        // A UPS data port that is physically RJ45-shaped but enumerates as USB.
+        (var output, var yaml) = await ExecuteAsync(
+            "ups", "port", "add", "ups01",
+            "--type", "usb",
+            "--speed", "0.48",
+            "--count", "1"
+        );
+        Assert.Equal("Port added to UPS 'ups01'.\n", output);
+
+        // The dataline surge pass-through pair.
+        (output, yaml) = await ExecuteAsync(
+            "ups", "port", "add", "ups01",
+            "--type", "rj45",
+            "--speed", "1",
+            "--count", "2"
+        );
+        Assert.Equal("Port added to UPS 'ups01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Ups
+                       model: APC-BGM2200
+                       va: 2200
+                       ports:
+                       - type: usb
+                         speed: 0.48
+                         count: 1
+                       - type: rj45
+                         speed: 1
+                         count: 2
+                       name: ups01
+                     connections: []
+
+                     """, yaml);
+
+        // Update the second group in place.
+        (output, yaml) = await ExecuteAsync(
+            "ups", "port", "set", "ups01",
+            "--index", "1",
+            "--type", "rj45",
+            "--speed", "1",
+            "--count", "4"
+        );
+        Assert.Equal("Port #1 updated on UPS 'ups01'.\n", output);
+        Assert.Contains("count: 4", yaml);
+
+        // Describe surfaces the port summary.
+        (output, yaml) = await ExecuteAsync("ups", "describe", "ups01");
+        Assert.Contains("Ports:", output);
+        Assert.Contains("usb: 1", output);
+        Assert.Contains("rj45: 4", output);
+
+        // Remove the pass-through pair again.
+        (output, yaml) = await ExecuteAsync("ups", "port", "del", "ups01", "--index", "1");
+        Assert.Equal("Port #1 removed from UPS 'ups01'.\n", output);
+
+        Assert.Equal("""
+                     version: 4
+                     resources:
+                     - kind: Ups
+                       model: APC-BGM2200
+                       va: 2200
+                       ports:
+                       - type: usb
+                         speed: 0.48
+                         count: 1
+                       name: ups01
+                     connections: []
+
+                     """, yaml);
+
+        (output, yaml) = await ExecuteAsync("ups", "describe", "ups01");
+        Assert.Contains("usb: 1", output);
+        Assert.DoesNotContain("rj45", output);
+    }
+
+    [Fact]
+    public async Task describe_reports_none_when_ups_has_no_ports() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        await ExecuteAsync("ups", "add", "ups-bare");
+
+        (var output, var _) = await ExecuteAsync("ups", "describe", "ups-bare");
+
+        Assert.Contains("Ports:", output);
+        Assert.Contains("None", output);
+    }
+}

+ 20 - 1
schemas/v4/schema.v4.json

@@ -275,7 +275,8 @@
             "osfp",
             "xfp",
             "cx4",
-            "mgmt"
+            "mgmt",
+            "usb"
           ]
         },
         "speed": {
@@ -433,6 +434,12 @@
               "items": {
                 "$ref": "#/$defs/drive"
               }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }
@@ -587,6 +594,12 @@
             "va": {
               "type": "integer",
               "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }
@@ -609,6 +622,12 @@
             },
             "description": {
               "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
           }
         }