Преглед изворни кода

Add ports to Laptop, Other and Ups, and a usb port type

Laptop, Other and Ups were the only hardware kinds that could not
describe their physical connectivity, which made it impossible to model
USB-attached peripherals, IPTV decoders or a UPS monitoring link without
misusing Desktop as a stand-in.

Domain
- Laptop, Other and Ups implement IPortResource (List<Port>? Ports).
  The open-generic registration of IAddPortUseCase<> / ISetPortUseCase<>
  / IRemovePortUseCase<> already covers the new types, so no use-case
  wiring was needed.
- Add "usb" to Nic.ValidNicTypes. That single array drives port
  validation, CLI suggestions and both UI dropdowns.
- Add PortSummaries.Describe and surface ports in the describe use cases:
  UpsDescription.PortSummary, OtherDescription.PortSummary and
  LaptopDescription.NicCount (matching DescribeDesktopUseCase).

CLI
- rpk ups port add|set|del and rpk other port add|set|del, following the
  --count / --index convention used by switch, router and firewall.
- rpk laptops nic add|set|del, following the --ports convention used by
  servers and desktops.

UI
- PortGroupEditor on the laptop, other and UPS cards.

Schema v4
- ports on the laptop, ups and other definitions; "usb" in the port type
  enum. All three published copies stay byte-identical.
WhiteStorm пре 1 дан
родитељ
комит
c48981786f
31 измењених фајлова са 503 додато и 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. 14 8
      Shared.Rcl/wwwroot/raw_docs/resource-levels.md
  31. 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"/>
 
 

+ 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.

+ 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"
+              }
             }
             }
           }
           }
         }
         }