Przeglądaj źródła

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 20 godzin temu
rodzic
commit
1443bfe29d
48 zmienionych plików z 1468 dodań i 15 usunięć
  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,
             ramSummary,
             laptop.Drives?.Count ?? 0,
             laptop.Drives?.Count ?? 0,
             laptop.Gpus?.Count ?? 0,
             laptop.Gpus?.Count ?? 0,
+            laptop.Ports?.Count ?? 0,
             laptop.Labels
             laptop.Labels
         );
         );
     }
     }
@@ -33,5 +34,6 @@ public record LaptopDescription(
     string? RamSummary,
     string? RamSummary,
     int DriveCount,
     int DriveCount,
     int GpuCount,
     int GpuCount,
+    int NicCount,
     Dictionary<string, string> Labels
     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;
 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 const string KindLabel = "Laptop";
     public Ram? Ram { get; set; }
     public Ram? Ram { get; set; }
     public string? Model { get; set; }
     public string? Model { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Cpu>? Cpus { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
     public List<Gpu>? Gpus { get; set; }
+    public List<Port>? Ports { get; set; }
 }
 }

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

@@ -7,6 +7,7 @@ public record OtherDescription(
     string Name,
     string Name,
     string? Model,
     string? Model,
     string? Description,
     string? Description,
+    string PortSummary,
     Dictionary<string, string> Labels
     Dictionary<string, string> Labels
 );
 );
 
 
@@ -23,6 +24,7 @@ public class DescribeOtherUseCase(IResourceCollection repository) : IUseCase {
             other.Name,
             other.Name,
             other.Model,
             other.Model,
             other.Description,
             other.Description,
+            PortSummaries.Describe(other.Ports),
             other.Labels
             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;
 namespace RackPeek.Domain.Resources.OtherHardware;
 
 
-public class Other : Hardware.Hardware {
+public class Other : Hardware.Hardware, IPortResource {
     public const string KindLabel = "Other";
     public const string KindLabel = "Other";
     public string? Model { get; set; }
     public string? Model { get; set; }
     public string? Description { 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",
         "xfp", "cx4",
 
 
         // Management / special-purpose
         // 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; }
     public string? Type { get; set; }

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

@@ -7,6 +7,7 @@ public record UpsDescription(
     string Name,
     string Name,
     string? Model,
     string? Model,
     int? Va,
     int? Va,
+    string PortSummary,
     Dictionary<string, string> Labels
     Dictionary<string, string> Labels
 );
 );
 
 
@@ -23,6 +24,7 @@ public class DescribeUpsUseCase(IResourceCollection repository) : IUseCase {
             ups.Name,
             ups.Name,
             ups.Model,
             ups.Model,
             ups.Va,
             ups.Va,
+            PortSummaries.Describe(ups.Ports),
             ups.Labels
             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;
 namespace RackPeek.Domain.Resources.UpsUnits;
 
 
-public class Ups : Hardware.Hardware {
+public class Ups : Hardware.Hardware, IPortResource {
     public const string KindLabel = "Ups";
     public const string KindLabel = "Ups";
     public string? Model { get; set; }
     public string? Model { get; set; }
     public int? Va { 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",
             "osfp",
             "xfp",
             "xfp",
             "cx4",
             "cx4",
-            "mgmt"
+            "mgmt",
+            "usb"
           ]
           ]
         },
         },
         "speed": {
         "speed": {
@@ -433,6 +434,12 @@
               "items": {
               "items": {
                 "$ref": "#/$defs/drive"
                 "$ref": "#/$defs/drive"
               }
               }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }
@@ -587,6 +594,12 @@
             "va": {
             "va": {
               "type": "integer",
               "type": "integer",
               "minimum": 1
               "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }
@@ -609,6 +622,12 @@
             },
             },
             "description": {
             "description": {
               "type": "string"
               "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }

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

@@ -275,7 +275,8 @@
             "osfp",
             "osfp",
             "xfp",
             "xfp",
             "cx4",
             "cx4",
-            "mgmt"
+            "mgmt",
+            "usb"
           ]
           ]
         },
         },
         "speed": {
         "speed": {
@@ -433,6 +434,12 @@
               "items": {
               "items": {
                 "$ref": "#/$defs/drive"
                 "$ref": "#/$defs/drive"
               }
               }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }
@@ -587,6 +594,12 @@
             "va": {
             "va": {
               "type": "integer",
               "type": "integer",
               "minimum": 1
               "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }
@@ -609,6 +622,12 @@
             },
             },
             "description": {
             "description": {
               "type": "string"
               "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.Drive;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Laptops.Labels;
 using Shared.Rcl.Commands.Laptops.Labels;
+using Shared.Rcl.Commands.Laptops.Nics;
 using Shared.Rcl.Commands.Laptops.Rename;
 using Shared.Rcl.Commands.Laptops.Rename;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers.Labels;
 using Shared.Rcl.Commands.Routers.Labels;
@@ -62,10 +63,12 @@ using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Rename;
 using Shared.Rcl.Commands.Systems.Rename;
 using Shared.Rcl.Commands.OtherHardware;
 using Shared.Rcl.Commands.OtherHardware;
 using Shared.Rcl.Commands.OtherHardware.Labels;
 using Shared.Rcl.Commands.OtherHardware.Labels;
+using Shared.Rcl.Commands.OtherHardware.Ports;
 using Shared.Rcl.Commands.OtherHardware.Rename;
 using Shared.Rcl.Commands.OtherHardware.Rename;
 using Shared.Rcl.Commands.Tags;
 using Shared.Rcl.Commands.Tags;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
 using Shared.Rcl.Commands.Ups.Labels;
+using Shared.Rcl.Commands.Ups.Ports;
 using Shared.Rcl.Commands.Ups.Rename;
 using Shared.Rcl.Commands.Ups.Rename;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
@@ -512,6 +515,16 @@ public static class CliBootstrap {
                 ups.AddCommand<UpsRenameCommand>("rename")
                 ups.AddCommand<UpsRenameCommand>("rename")
                     .WithDescription("Rename a UPS unit to a new name.");
                     .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 => {
                 ups.AddBranch("label", label => {
                     label.SetDescription("Manage labels on a UPS unit.");
                     label.SetDescription("Manage labels on a UPS unit.");
                     label.AddCommand<UpsLabelAddCommand>("add").WithDescription("Add a label to a UPS unit.");
                     label.AddCommand<UpsLabelAddCommand>("add").WithDescription("Add a label to a UPS unit.");
@@ -553,6 +566,17 @@ public static class CliBootstrap {
                 other.AddCommand<OtherRenameCommand>("rename")
                 other.AddCommand<OtherRenameCommand>("rename")
                     .WithDescription("Rename other hardware to a new name.");
                     .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 => {
                 other.AddBranch("label", label => {
                     label.SetDescription("Manage labels on other hardware.");
                     label.SetDescription("Manage labels on other hardware.");
                     label.AddCommand<OtherLabelAddCommand>("add").WithDescription("Add a label to 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.");
                     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 => {
                 laptops.AddBranch("label", label => {
                     label.SetDescription("Manage labels on a laptop.");
                     label.SetDescription("Manage labels on a laptop.");
                     label.AddCommand<LaptopLabelAddCommand>("add").WithDescription("Add a label to 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("RAM:", result.RamSummary ?? "None");
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
+        grid.AddRow("NICs:", result.NicCount.ToString());
 
 
         if (result.Labels.Count > 0)
         if (result.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
             grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key}: {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("Name:", other.Name.EscapeMarkup());
         grid.AddRow("Model:", (other.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("Model:", (other.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("Description:", (other.Description ?? "Unknown").EscapeMarkup());
         grid.AddRow("Description:", (other.Description ?? "Unknown").EscapeMarkup());
+        grid.AddRow("Ports:", other.PortSummary.EscapeMarkup());
 
 
         if (other.Labels.Count > 0)
         if (other.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", other.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
             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("Name:", ups.Name.EscapeMarkup());
         grid.AddRow("Model:", (ups.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("Model:", (ups.Model ?? "Unknown").EscapeMarkup());
         grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown");
         grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown");
+        grid.AddRow("Ports:", ups.PortSummary.EscapeMarkup());
 
 
         if (ups.Labels.Count > 0)
         if (ups.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", ups.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
             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.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Gpus
 @using RackPeek.Domain.UseCases.Gpus
+@using Shared.Rcl.Hardware
 @inject IGetResourceByNameUseCase<Laptop> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Laptop> GetByNameUseCase
 @inject UpdateLaptopUseCase UpdateUseCase
 @inject UpdateLaptopUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<Laptop> DeleteUseCase
 @inject IDeleteResourceUseCase<Laptop> DeleteUseCase
@@ -176,6 +177,12 @@
             }
             }
         </div>
         </div>
 
 
+        <!-- NICs -->
+        <PortGroupEditor T="Laptop"
+                         Resource="Laptop"
+                         OnResourceChanged="r => Laptop = r"
+                         TestIdPrefix="laptop-ports"/>
+
     </div>
     </div>
 
 
     <ResourceTagEditor Resource="Laptop"
     <ResourceTagEditor Resource="Laptop"

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

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

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

@@ -1,4 +1,5 @@
 @using RackPeek.Domain.Resources.UpsUnits
 @using RackPeek.Domain.Resources.UpsUnits
+@using Shared.Rcl.Hardware
 @inject UpdateUpsUseCase UpdateUseCase
 @inject UpdateUpsUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Ups> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Ups> GetByNameUseCase
 @inject IDeleteResourceUseCase<Ups> DeleteUseCase
 @inject IDeleteResourceUseCase<Ups> DeleteUseCase
@@ -111,6 +112,12 @@
             }
             }
         </div>
         </div>
 
 
+        <!-- Ports -->
+        <PortGroupEditor T="Ups"
+                         Resource="Ups"
+                         OnResourceChanged="r => Ups = r"
+                         TestIdPrefix="ups-ports"/>
+
         <ResourceTagEditor Resource="Ups"
         <ResourceTagEditor Resource="Ups"
                            TestIdPrefix="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
     - [set](docs/Commands.md#rpk-ups-set) - Update properties of a UPS unit
     - [del](docs/Commands.md#rpk-ups-del) - Delete 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
     - [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
     - [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
       - [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
       - [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
     - [set](docs/Commands.md#rpk-other-set) - Update properties of other hardware
     - [del](docs/Commands.md#rpk-other-del) - Delete 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
     - [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
     - [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
       - [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
       - [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
       - [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
       - [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
       - [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
     - [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
       - [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
       - [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           
     set <name>                  Update properties of a UPS unit           
     del <name>                  Delete a UPS unit                         
     del <name>                  Delete a UPS unit                         
     rename <name> <new-name>    Rename a UPS unit to a new name           
     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               
     label                       Manage labels on a UPS unit               
     tag                         Manage tags on a UPS unit                 
     tag                         Manage tags on a UPS unit                 
 ```
 ```
@@ -2143,6 +2144,76 @@ OPTIONS:
     -h, --help    Prints help information
     -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`
 ## `rpk ups label`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:
@@ -2260,6 +2331,7 @@ COMMANDS:
     set <name>                  Update properties of other hardware           
     set <name>                  Update properties of other hardware           
     del <name>                  Delete other hardware                         
     del <name>                  Delete other hardware                         
     rename <name> <new-name>    Rename other hardware to a new name           
     rename <name> <new-name>    Rename other hardware to a new name           
+    port                        Manage ports on other hardware                
     label                       Manage labels on other hardware               
     label                       Manage labels on other hardware               
     tag                         Manage tags on other hardware                 
     tag                         Manage tags on other hardware                 
 ```
 ```
@@ -2381,6 +2453,76 @@ OPTIONS:
     -h, --help    Prints help information
     -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`
 ## `rpk other label`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:
@@ -3038,6 +3180,8 @@ COMMANDS:
     cpu                         Manage CPUs attached to Laptops                 
     cpu                         Manage CPUs attached to Laptops                 
     drive                       Manage storage drives attached to Laptops       
     drive                       Manage storage drives attached to Laptops       
     gpu                         Manage GPUs attached to Laptops                 
     gpu                         Manage GPUs attached to Laptops                 
+    nic                         Manage network interface cards (NICs) for       
+                                Laptops                                         
     label                       Manage labels on a laptop                       
     label                       Manage labels on a laptop                       
     tag                         Manage tags on a laptop                         
     tag                         Manage tags on a laptop                         
 ```
 ```
@@ -3379,6 +3523,76 @@ OPTIONS:
     -h, --help    Prints help information
     -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`
 ## `rpk laptops label`
 ```
 ```
 DESCRIPTION:
 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.
 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
 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.
 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();
             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();
             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 TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
     public LabelsPom Labels => new(page);
 
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "laptop-ports";
+
     // -------------------------------------------------
     // -------------------------------------------------
     // Modals
     // Modals
     // -------------------------------------------------
     // -------------------------------------------------
@@ -184,4 +188,24 @@ public class LaptopCardPom(IPage page) {
 
 
     private static string Sanitize(string value)
     private static string Sanitize(string value)
         => value.Replace(" ", "-");
         => 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 TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
     public LabelsPom Labels => new(page);
 
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "other-ports";
+
     // -------------------------------------------------
     // -------------------------------------------------
     // Notes
     // Notes
     // -------------------------------------------------
     // -------------------------------------------------
@@ -139,4 +143,24 @@ public class OtherCardPom(IPage page) {
         await DeleteButton(name).ClickAsync();
         await DeleteButton(name).ClickAsync();
         await ConfirmDeleteButton.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 TagsPom Tags => new(page);
     public LabelsPom Labels => new(page);
     public LabelsPom Labels => new(page);
 
 
+    public PortsPom Ports => new(page);
+
+    private const string _portsPrefix = "ups-ports";
+
     // -------------------------------------------------
     // -------------------------------------------------
     // Notes
     // Notes
     // -------------------------------------------------
     // -------------------------------------------------
@@ -139,4 +143,24 @@ public class UpsCardPom(IPage page) {
         await DeleteButton(name).ClickAsync();
         await DeleteButton(name).ClickAsync();
         await ConfirmDeleteButton.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();
             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
         // GPU help
         Assert.Contains("Manage GPUs", (await ExecuteAsync("laptops", "gpu", "--help")).Item1);
         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);
         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);
         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);
         Assert.Contains("Delete other hardware", delHelp);
         (var renameHelp, var _) = await ExecuteAsync("other", "rename", "--help");
         (var renameHelp, var _) = await ExecuteAsync("other", "rename", "--help");
         Assert.Contains("Rename other hardware", renameHelp);
         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]
     [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);
         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);
         Assert.Contains("Delete a UPS unit", delHelp);
         (var renameHelp, var _) = await ExecuteAsync("ups", "rename", "--help");
         (var renameHelp, var _) = await ExecuteAsync("ups", "rename", "--help");
         Assert.Contains("Rename a UPS unit", renameHelp);
         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]
     [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);
         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",
             "osfp",
             "xfp",
             "xfp",
             "cx4",
             "cx4",
-            "mgmt"
+            "mgmt",
+            "usb"
           ]
           ]
         },
         },
         "speed": {
         "speed": {
@@ -433,6 +434,12 @@
               "items": {
               "items": {
                 "$ref": "#/$defs/drive"
                 "$ref": "#/$defs/drive"
               }
               }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }
@@ -587,6 +594,12 @@
             "va": {
             "va": {
               "type": "integer",
               "type": "integer",
               "minimum": 1
               "minimum": 1
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }
@@ -609,6 +622,12 @@
             },
             },
             "description": {
             "description": {
               "type": "string"
               "type": "string"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
             }
             }
           }
           }
         }
         }