Tim Jones 1 месяц назад
Родитель
Сommit
8515ffa681
87 измененных файлов с 3195 добавлено и 256 удалено
  1. 25 0
      .dockerignore
  2. 1 0
      .idea/.idea.RackPeek/.idea/.name
  3. 1 1
      RackPeek.Domain/Helpers/ThrowIfInvalid.cs
  4. 10 2
      RackPeek.Domain/Resources/Hardware/Desktops/Cpus/UpdateDesktopCpuUseCase.cs
  5. 9 2
      RackPeek.Domain/Resources/Hardware/Desktops/Drives/AddDesktopDriveUseCase.cs
  6. 5 3
      RackPeek.Domain/Resources/Hardware/Desktops/Drives/UpdateDesktopDriveUseCase.cs
  7. 9 3
      RackPeek.Domain/Resources/Hardware/Desktops/Gpus/AddDesktopGpuUseCase.cs
  8. 8 3
      RackPeek.Domain/Resources/Hardware/Desktops/Gpus/UpdateDesktopGpuUseCase.cs
  9. 14 3
      RackPeek.Domain/Resources/Hardware/Desktops/Nics/AddDesktopNicUseCase.cs
  10. 15 4
      RackPeek.Domain/Resources/Hardware/Desktops/Nics/UpdateDesktopNicUseCase.cs
  11. 17 1
      RackPeek.Domain/Resources/Hardware/Desktops/UpdateDesktopUseCase.cs
  12. 1 1
      RackPeek.Domain/Resources/Hardware/Firewalls/DescribeFirewallUseCase.cs
  13. 1 1
      RackPeek.Domain/Resources/Hardware/Firewalls/FirewallHardwareReport.cs
  14. 35 0
      RackPeek.Domain/Resources/Hardware/Firewalls/Ports/AddFirewallPortUseCase.cs
  15. 23 0
      RackPeek.Domain/Resources/Hardware/Firewalls/Ports/RemoveFirewallPortUseCase.cs
  16. 37 0
      RackPeek.Domain/Resources/Hardware/Firewalls/Ports/UpdateFirewallPortUseCase.cs
  17. 10 3
      RackPeek.Domain/Resources/Hardware/Laptops/Cpus/UpdateDesktopCpuUseCase.cs
  18. 9 3
      RackPeek.Domain/Resources/Hardware/Laptops/Drives/AddDesktopDriveUseCase.cs
  19. 5 3
      RackPeek.Domain/Resources/Hardware/Laptops/Drives/UpdateDesktopDriveUseCase.cs
  20. 9 3
      RackPeek.Domain/Resources/Hardware/Laptops/Gpus/AddDesktopGpuUseCase.cs
  21. 8 3
      RackPeek.Domain/Resources/Hardware/Laptops/Gpus/UpdateDesktopGpuUseCase.cs
  22. 43 0
      RackPeek.Domain/Resources/Hardware/Laptops/UpdateLaptopUseCase.cs
  23. 2 1
      RackPeek.Domain/Resources/Hardware/Models/Laptop.cs
  24. 1 1
      RackPeek.Domain/Resources/Hardware/Models/Nic.cs
  25. 1 1
      RackPeek.Domain/Resources/Hardware/Models/Port.cs
  26. 1 1
      RackPeek.Domain/Resources/Hardware/Routers/DescribeRouterUseCase.cs
  27. 35 0
      RackPeek.Domain/Resources/Hardware/Routers/Ports/AddRouterPortUseCase.cs
  28. 23 0
      RackPeek.Domain/Resources/Hardware/Routers/Ports/RemoveRouterPortUseCase.cs
  29. 37 0
      RackPeek.Domain/Resources/Hardware/Routers/Ports/UpdateRouterPortUseCase.cs
  30. 1 1
      RackPeek.Domain/Resources/Hardware/Routers/RouterHardwareReport.cs
  31. 1 1
      RackPeek.Domain/Resources/Hardware/Servers/Nics/AddNicUseCase.cs
  32. 1 1
      RackPeek.Domain/Resources/Hardware/Servers/Nics/UpdateNicUseCase.cs
  33. 1 1
      RackPeek.Domain/Resources/Hardware/Servers/ServerHardwareReport.cs
  34. 1 1
      RackPeek.Domain/Resources/Hardware/Switches/DescribeSwitchUseCase.cs
  35. 35 0
      RackPeek.Domain/Resources/Hardware/Switches/Ports/AddSwitchPortUseCase.cs
  36. 23 0
      RackPeek.Domain/Resources/Hardware/Switches/Ports/RemoveSwitchPortUseCase.cs
  37. 37 0
      RackPeek.Domain/Resources/Hardware/Switches/Ports/UpdateSwitchPortUseCase.cs
  38. 1 1
      RackPeek.Domain/Resources/Hardware/Switches/SwitchHardwareReport.cs
  39. 20 0
      RackPeek.Domain/Resources/Resource.cs
  40. 61 10
      RackPeek.Web/Components/AccessPoints/AccessPointCardComponent.razor
  41. 17 4
      RackPeek.Web/Components/AccessPoints/AccessPointsListComponent.razor
  42. 68 0
      RackPeek.Web/Components/AccessPoints/AddAccessPointComponent.razor
  43. 68 0
      RackPeek.Web/Components/Desktops/AddDesktopComponent.razor
  44. 444 44
      RackPeek.Web/Components/Desktops/DesktopCardComponent.razor
  45. 16 4
      RackPeek.Web/Components/Desktops/DesktopsListComponent.razor
  46. 68 0
      RackPeek.Web/Components/Firewalls/AddFirewallComponent.razor
  47. 280 0
      RackPeek.Web/Components/Firewalls/FirewallCardComponent.razor
  48. 50 0
      RackPeek.Web/Components/Firewalls/FirewallListComponent.razor
  49. 9 0
      RackPeek.Web/Components/Firewalls/FirewallListPage.razor
  50. 17 0
      RackPeek.Web/Components/Hardware/HardwareDetailsPage.razor
  51. 17 8
      RackPeek.Web/Components/Hardware/HardwareTreePage.razor
  52. 68 0
      RackPeek.Web/Components/Laptops/AddLaptopComponent.razor
  53. 395 0
      RackPeek.Web/Components/Laptops/LaptopCardComponent.razor
  54. 48 0
      RackPeek.Web/Components/Laptops/LaptopsListComponent.razor
  55. 9 0
      RackPeek.Web/Components/Laptops/LaptopsListPage.razor
  56. 1 1
      RackPeek.Web/Components/Modals/NicModal.razor
  57. 12 4
      RackPeek.Web/Components/Modals/PortModal.razor
  58. 11 7
      RackPeek.Web/Components/Pages/Home.razor
  59. 68 0
      RackPeek.Web/Components/Routers/AddRouterComponent.razor
  60. 277 0
      RackPeek.Web/Components/Routers/RouterCardComponent.razor
  61. 50 0
      RackPeek.Web/Components/Routers/RouterListComponent.razor
  62. 9 0
      RackPeek.Web/Components/Routers/RouterListPage.razor
  63. 7 5
      RackPeek.Web/Components/Servers/ServerCardComponent.razor
  64. 11 1
      RackPeek.Web/Components/Services/ServiceCardComponent.razor
  65. 68 0
      RackPeek.Web/Components/Switches/AddSwitchComponent.razor
  66. 244 25
      RackPeek.Web/Components/Switches/SwitchCardComponent.razor
  67. 17 5
      RackPeek.Web/Components/Switches/SwitchListComponent.razor
  68. 2 1
      RackPeek.Web/Components/Systems/SystemCardComponent.razor
  69. 68 0
      RackPeek.Web/Components/Ups/AddUpsComponent.razor
  70. 85 0
      RackPeek.Web/Components/Ups/UpsCardComponent.razor
  71. 48 0
      RackPeek.Web/Components/Ups/UpsListComponent.razor
  72. 9 0
      RackPeek.Web/Components/Ups/UpsListPage.razor
  73. 25 0
      RackPeek.Web/Dockerfile
  74. 13 0
      RackPeek.Web/RackPeek.Web.csproj
  75. 1 8
      RackPeek/Commands/Desktops/Cpus/DesktopCpuSetCommand.cs
  76. 1 7
      RackPeek/Commands/Desktops/Drive/DesktopDriveAddCommand.cs
  77. 2 8
      RackPeek/Commands/Desktops/Drive/DesktopDriveSetCommand.cs
  78. 2 8
      RackPeek/Commands/Desktops/Gpus/DesktopGpuAddCommand.cs
  79. 1 7
      RackPeek/Commands/Desktops/Gpus/DesktopGpuSetCommand.cs
  80. 1 8
      RackPeek/Commands/Desktops/Nics/DesktopNicAddCommand.cs
  81. 2 9
      RackPeek/Commands/Desktops/Nics/DesktopNicSetCommand.cs
  82. 1 8
      RackPeek/Commands/Laptops/Cpus/LaptopCpuSetCommand.cs
  83. 2 8
      RackPeek/Commands/Laptops/Drive/LaptopDriveAddCommand.cs
  84. 2 8
      RackPeek/Commands/Laptops/Drive/LaptopDriveSetCommand.cs
  85. 1 7
      RackPeek/Commands/Laptops/Gpus/LaptopGpuAddCommand.cs
  86. 1 1
      RackPeek/Commands/Laptops/Gpus/LaptopGpuSetCommand.cs
  87. 2 1
      RackPeek/Yaml/YamlResourceRepository.cs

+ 25 - 0
.dockerignore

@@ -0,0 +1,25 @@
+**/.dockerignore
+**/.env
+**/.git
+**/.gitignore
+**/.project
+**/.settings
+**/.toolstarget
+**/.vs
+**/.vscode
+**/.idea
+**/*.*proj.user
+**/*.dbmdl
+**/*.jfm
+**/azds.yaml
+**/bin
+**/charts
+**/docker-compose*
+**/Dockerfile*
+**/node_modules
+**/npm-debug.log
+**/obj
+**/secrets.dev.yaml
+**/values.dev.yaml
+LICENSE
+README.md

+ 1 - 0
.idea/.idea.RackPeek/.idea/.name

@@ -0,0 +1 @@
+RackPeek

+ 1 - 1
RackPeek.Domain/Helpers/ThrowIfInvalid.cs

@@ -67,7 +67,7 @@ public static class ThrowIfInvalid
         return (double)commonChars / Math.Max(a.Length, b.Length);
     }
 
-    public static void NicSpeed(int speed)
+    public static void NicSpeed(double speed)
     {
         if (speed < 0) throw new ValidationException("NIC speed must be a non negative number of gigabits per second.");
     }

+ 10 - 2
RackPeek.Domain/Resources/Hardware/Desktops/Cpus/UpdateDesktopCpuUseCase.cs

@@ -5,7 +5,12 @@ namespace RackPeek.Domain.Resources.Hardware.Desktops.Cpus;
 
 public class UpdateDesktopCpuUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, int index, Cpu updated)
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? model,
+        int? cores,
+        int? threads)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -19,7 +24,10 @@ public class UpdateDesktopCpuUseCase(IHardwareRepository repository) : IUseCase
         if (desktop.Cpus == null || index < 0 || index >= desktop.Cpus.Count)
             throw new NotFoundException($"CPU index {index} not found on desktop '{name}'.");
 
-        desktop.Cpus[index] = updated;
+        var cpu = desktop.Cpus[index];
+        cpu.Model = model;
+        cpu.Cores = cores;
+        cpu.Threads = threads;
 
         await repository.UpdateAsync(desktop);
     }

+ 9 - 2
RackPeek.Domain/Resources/Hardware/Desktops/Drives/AddDesktopDriveUseCase.cs

@@ -5,7 +5,10 @@ namespace RackPeek.Domain.Resources.Hardware.Desktops.Drives;
 
 public class AddDesktopDriveUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, Drive drive)
+    public async Task ExecuteAsync(
+        string name,
+        string? type,
+        int? size)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -17,7 +20,11 @@ public class AddDesktopDriveUseCase(IHardwareRepository repository) : IUseCase
                       ?? throw new NotFoundException($"Desktop '{name}' not found.");
 
         desktop.Drives ??= new List<Drive>();
-        desktop.Drives.Add(drive);
+        desktop.Drives.Add(new Drive
+        {
+            Type = type,
+            Size = size
+        });
 
         await repository.UpdateAsync(desktop);
     }

+ 5 - 3
RackPeek.Domain/Resources/Hardware/Desktops/Drives/UpdateDesktopDriveUseCase.cs

@@ -5,7 +5,7 @@ namespace RackPeek.Domain.Resources.Hardware.Desktops.Drives;
 
 public class UpdateDesktopDriveUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, int index, Drive updated)
+    public async Task ExecuteAsync(string name, int index, string? type, int? size)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -19,8 +19,10 @@ public class UpdateDesktopDriveUseCase(IHardwareRepository repository) : IUseCas
         if (desktop.Drives == null || index < 0 || index >= desktop.Drives.Count)
             throw new NotFoundException($"Drive index {index} not found on desktop '{name}'.");
 
-        desktop.Drives[index] = updated;
-
+        var drive = desktop.Drives[index];
+        drive.Type = type;
+        drive.Size = size;
+        
         await repository.UpdateAsync(desktop);
     }
 }

+ 9 - 3
RackPeek.Domain/Resources/Hardware/Desktops/Gpus/AddDesktopGpuUseCase.cs

@@ -5,7 +5,10 @@ namespace RackPeek.Domain.Resources.Hardware.Desktops.Gpus;
 
 public class AddDesktopGpuUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, Gpu gpu)
+    public async Task ExecuteAsync(
+        string name,
+        string? model,
+        int? vram)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -17,8 +20,11 @@ public class AddDesktopGpuUseCase(IHardwareRepository repository) : IUseCase
                       ?? throw new NotFoundException($"Desktop '{name}' not found.");
 
         desktop.Gpus ??= new List<Gpu>();
-        desktop.Gpus.Add(gpu);
-
+        desktop.Gpus.Add(new Gpu
+        {
+            Model = model,
+            Vram = vram
+        });
         await repository.UpdateAsync(desktop);
     }
 }

+ 8 - 3
RackPeek.Domain/Resources/Hardware/Desktops/Gpus/UpdateDesktopGpuUseCase.cs

@@ -5,7 +5,11 @@ namespace RackPeek.Domain.Resources.Hardware.Desktops.Gpus;
 
 public class UpdateDesktopGpuUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, int index, Gpu updated)
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? model,
+        int? vram)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -19,8 +23,9 @@ public class UpdateDesktopGpuUseCase(IHardwareRepository repository) : IUseCase
         if (desktop.Gpus == null || index < 0 || index >= desktop.Gpus.Count)
             throw new NotFoundException($"GPU index {index} not found on desktop '{name}'.");
 
-        desktop.Gpus[index] = updated;
-
+        var gpu = desktop.Gpus[index];
+        gpu.Model = model;
+        gpu.Vram = vram;
         await repository.UpdateAsync(desktop);
     }
 }

+ 14 - 3
RackPeek.Domain/Resources/Hardware/Desktops/Nics/AddDesktopNicUseCase.cs

@@ -5,7 +5,11 @@ namespace RackPeek.Domain.Resources.Hardware.Desktops.Nics;
 
 public class AddDesktopNicUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, Nic nic)
+    public async Task ExecuteAsync(
+        string name,
+        string? type,
+        double? speed,
+        int? ports)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -13,12 +17,19 @@ public class AddDesktopNicUseCase(IHardwareRepository repository) : IUseCase
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
 
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
         var desktop = await repository.GetByNameAsync(name) as Desktop
                       ?? throw new NotFoundException($"Desktop '{name}' not found.");
 
         desktop.Nics ??= new List<Nic>();
-        desktop.Nics.Add(nic);
-
+        desktop.Nics.Add(new Nic
+        {
+            Type = nicType,
+            Speed = speed,
+            Ports = ports
+        });
         await repository.UpdateAsync(desktop);
     }
 }

+ 15 - 4
RackPeek.Domain/Resources/Hardware/Desktops/Nics/UpdateDesktopNicUseCase.cs

@@ -5,22 +5,33 @@ namespace RackPeek.Domain.Resources.Hardware.Desktops.Nics;
 
 public class UpdateDesktopNicUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, int index, Nic updated)
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? type,
+        double? speed,
+        int? ports)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
         
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
-
+        
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
         var desktop = await repository.GetByNameAsync(name) as Desktop
                       ?? throw new NotFoundException($"Desktop '{name}' not found.");
 
         if (desktop.Nics == null || index < 0 || index >= desktop.Nics.Count)
             throw new NotFoundException($"NIC index {index} not found on desktop '{name}'.");
 
-        desktop.Nics[index] = updated;
-
+        var nic = desktop.Nics[index];
+        nic.Type = nicType;
+        nic.Speed = speed;
+        nic.Ports = ports;
+        
         await repository.UpdateAsync(desktop);
     }
 }

+ 17 - 1
RackPeek.Domain/Resources/Hardware/Desktops/UpdateDesktopUseCase.cs

@@ -7,7 +7,9 @@ public class UpdateDesktopUseCase(IHardwareRepository repository) : IUseCase
 {
     public async Task ExecuteAsync(
         string name,
-        string? model = null
+        string? model = null,
+        int? ramGb = null,
+        int? ramMts = null
     )
     {
         // ToDo validate / normalize all inputs
@@ -21,6 +23,20 @@ public class UpdateDesktopUseCase(IHardwareRepository repository) : IUseCase
 
         if (!string.IsNullOrWhiteSpace(model))
             desktop.Model = model;
+        
+        // ---- RAM ----
+        if (ramGb.HasValue)
+        {
+            ThrowIfInvalid.RamGb(ramGb);
+            desktop.Ram ??= new Ram();
+            desktop.Ram.Size = ramGb.Value;
+        }
+        
+        if (ramMts.HasValue)
+        {
+            desktop.Ram ??= new Ram();
+            desktop.Ram.Mts = ramMts.Value;
+        }
 
         await repository.UpdateAsync(desktop);
     }

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Firewalls/DescribeFirewallUseCase.cs

@@ -9,7 +9,7 @@ public record FirewallDescription(
     bool? Managed,
     bool? Poe,
     int TotalPorts,
-    int TotalSpeedGb,
+    double TotalSpeedGb,
     string PortSummary
 );
 

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Firewalls/FirewallHardwareReport.cs

@@ -12,7 +12,7 @@ public record FirewallHardwareRow(
     bool Managed,
     bool Poe,
     int TotalPorts,
-    int MaxPortSpeedGb,
+    double MaxPortSpeedGb,
     string PortSummary
 );
 

+ 35 - 0
RackPeek.Domain/Resources/Hardware/Firewalls/Ports/AddFirewallPortUseCase.cs

@@ -0,0 +1,35 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Firewalls.Ports;
+
+public class AddFirewallPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(
+        string name,
+        string? type,
+        double? speed,
+        int? ports)
+    {
+        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
+        // ToDo validate / normalize all inputs
+        
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
+        var desktop = await repository.GetByNameAsync(name) as Firewall
+                      ?? throw new NotFoundException($"Firewall '{name}' not found.");
+
+        desktop.Ports ??= new List<Port>();
+        desktop.Ports.Add(new Port
+        {
+            Type = nicType,
+            Speed = speed,
+            Count = ports
+        });
+        await repository.UpdateAsync(desktop);
+    }
+}

+ 23 - 0
RackPeek.Domain/Resources/Hardware/Firewalls/Ports/RemoveFirewallPortUseCase.cs

@@ -0,0 +1,23 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Firewalls.Ports;
+
+public class RemoveFirewallPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(string name, int index)
+    {
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var firewall = await repository.GetByNameAsync(name) as Firewall
+                      ?? throw new NotFoundException($"Firewall '{name}' not found.");
+
+        if (firewall.Ports == null || index < 0 || index >= firewall.Ports.Count)
+            throw new NotFoundException($"Port index {index} not found on firewall '{name}'.");
+
+        firewall.Ports.RemoveAt(index);
+
+        await repository.UpdateAsync(firewall);
+    }
+}

+ 37 - 0
RackPeek.Domain/Resources/Hardware/Firewalls/Ports/UpdateFirewallPortUseCase.cs

@@ -0,0 +1,37 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Firewalls.Ports;
+
+public class UpdateFirewallPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? type,
+        double? speed,
+        int? ports)
+    {
+        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
+        // ToDo validate / normalize all inputs
+        
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+        
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
+        var firewall = await repository.GetByNameAsync(name) as Firewall
+                      ?? throw new NotFoundException($"Firewall '{name}' not found.");
+
+        if (firewall.Ports == null || index < 0 || index >= firewall.Ports.Count)
+            throw new NotFoundException($"Port index {index} not found on firewall '{name}'.");
+
+        var nic = firewall.Ports[index];
+        nic.Type = nicType;
+        nic.Speed = speed;
+        nic.Count = ports;
+        
+        await repository.UpdateAsync(firewall);
+    }
+}

+ 10 - 3
RackPeek.Domain/Resources/Hardware/Laptops/Cpus/UpdateDesktopCpuUseCase.cs

@@ -5,7 +5,12 @@ namespace RackPeek.Domain.Resources.Hardware.Laptops.Cpus;
 
 public class UpdateLaptopCpuUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, int index, Cpu updated)
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? model,
+        int? cores,
+        int? threads)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -18,8 +23,10 @@ public class UpdateLaptopCpuUseCase(IHardwareRepository repository) : IUseCase
         if (laptop.Cpus == null || index < 0 || index >= laptop.Cpus.Count)
             throw new NotFoundException($"CPU index {index} not found on Laptop '{name}'.");
 
-        laptop.Cpus[index] = updated;
-
+        var cpu = laptop.Cpus[index];
+        cpu.Model = model;
+        cpu.Cores = cores;
+        cpu.Threads = threads;
         await repository.UpdateAsync(laptop);
     }
 }

+ 9 - 3
RackPeek.Domain/Resources/Hardware/Laptops/Drives/AddDesktopDriveUseCase.cs

@@ -5,7 +5,10 @@ namespace RackPeek.Domain.Resources.Hardware.Laptops.Drives;
 
 public class AddLaptopDriveUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, Drive drive)
+    public async Task ExecuteAsync(
+        string name,
+        string? type,
+        int? size)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -17,8 +20,11 @@ public class AddLaptopDriveUseCase(IHardwareRepository repository) : IUseCase
                      ?? throw new NotFoundException($"Laptop '{name}' not found.");
 
         laptop.Drives ??= new List<Drive>();
-        laptop.Drives.Add(drive);
-
+        laptop.Drives.Add(new Drive
+        {
+            Type = type,
+            Size = size
+        });
         await repository.UpdateAsync(laptop);
     }
 }

+ 5 - 3
RackPeek.Domain/Resources/Hardware/Laptops/Drives/UpdateDesktopDriveUseCase.cs

@@ -5,7 +5,7 @@ namespace RackPeek.Domain.Resources.Hardware.Laptops.Drives;
 
 public class UpdateLaptopDriveUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, int index, Drive updated)
+    public async Task ExecuteAsync(string name, int index, string? type, int? size)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -19,8 +19,10 @@ public class UpdateLaptopDriveUseCase(IHardwareRepository repository) : IUseCase
         if (laptop.Drives == null || index < 0 || index >= laptop.Drives.Count)
             throw new NotFoundException($"Drive index {index} not found on Laptop '{name}'.");
 
-        laptop.Drives[index] = updated;
-
+        var drive = laptop.Drives[index];
+        drive.Type = type;
+        drive.Size = size;
+        
         await repository.UpdateAsync(laptop);
     }
 }

+ 9 - 3
RackPeek.Domain/Resources/Hardware/Laptops/Gpus/AddDesktopGpuUseCase.cs

@@ -5,7 +5,10 @@ namespace RackPeek.Domain.Resources.Hardware.Laptops.Gpus;
 
 public class AddLaptopGpuUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, Gpu gpu)
+    public async Task ExecuteAsync(
+        string name,
+        string? model,
+        int? vram)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -16,8 +19,11 @@ public class AddLaptopGpuUseCase(IHardwareRepository repository) : IUseCase
                      ?? throw new InvalidOperationException($"Laptop '{name}' not found.");
 
         laptop.Gpus ??= new List<Gpu>();
-        laptop.Gpus.Add(gpu);
-
+        laptop.Gpus.Add(new Gpu
+        {
+            Model = model,
+            Vram = vram
+        });
         await repository.UpdateAsync(laptop);
     }
 }

+ 8 - 3
RackPeek.Domain/Resources/Hardware/Laptops/Gpus/UpdateDesktopGpuUseCase.cs

@@ -5,7 +5,11 @@ namespace RackPeek.Domain.Resources.Hardware.Laptops.Gpus;
 
 public class UpdateLaptopGpuUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, int index, Gpu updated)
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? model,
+        int? vram)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
         // ToDo validate / normalize all inputs
@@ -19,8 +23,9 @@ public class UpdateLaptopGpuUseCase(IHardwareRepository repository) : IUseCase
         if (laptop.Gpus == null || index < 0 || index >= laptop.Gpus.Count)
             throw new NotFoundException($"GPU index {index} not found on Laptop '{name}'.");
 
-        laptop.Gpus[index] = updated;
-
+        var gpu = laptop.Gpus[index];
+        gpu.Model = model;
+        gpu.Vram = vram;
         await repository.UpdateAsync(laptop);
     }
 }

+ 43 - 0
RackPeek.Domain/Resources/Hardware/Laptops/UpdateLaptopUseCase.cs

@@ -0,0 +1,43 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Laptops;
+
+public class UpdateLaptopUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(
+        string name,
+        string? model = null,
+        int? ramGb = null,
+        int? ramMts = null
+    )
+    {
+        // ToDo validate / normalize all inputs
+        
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var laptop = await repository.GetByNameAsync(name) as Laptop;
+        if (laptop == null)
+            throw new NotFoundException($"Laptop '{name}' not found.");
+
+        if (!string.IsNullOrWhiteSpace(model))
+            laptop.Model = model;
+        
+        // ---- RAM ----
+        if (ramGb.HasValue)
+        {
+            ThrowIfInvalid.RamGb(ramGb);
+            laptop.Ram ??= new Ram();
+            laptop.Ram.Size = ramGb.Value;
+        }
+        
+        if (ramMts.HasValue)
+        {
+            laptop.Ram ??= new Ram();
+            laptop.Ram.Mts = ramMts.Value;
+        }
+
+        await repository.UpdateAsync(laptop);
+    }
+}

+ 2 - 1
RackPeek.Domain/Resources/Hardware/Models/Laptop.cs

@@ -6,7 +6,8 @@ public class Laptop : Hardware
     public Ram? Ram { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Gpu>? Gpus { get; set; }
-    
+    public string? Model { get; set; }
+
     public const string KindLabel = "Laptop";
 
 }

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Models/Nic.cs

@@ -30,6 +30,6 @@ public class Nic
     };
 
     public string? Type { get; set; }
-    public int? Speed { get; set; }
+    public double? Speed { get; set; }
     public int? Ports { get; set; }
 }

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Models/Port.cs

@@ -3,6 +3,6 @@ namespace RackPeek.Domain.Resources.Hardware.Models;
 public class Port
 {
     public string? Type { get; set; }
-    public int? Speed { get; set; }
+    public double? Speed { get; set; }
     public int? Count { get; set; }
 }

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Routers/DescribeRouterUseCase.cs

@@ -9,7 +9,7 @@ public record RouterDescription(
     bool? Managed,
     bool? Poe,
     int TotalPorts,
-    int TotalSpeedGb,
+    double TotalSpeedGb,
     string PortSummary
 );
 

+ 35 - 0
RackPeek.Domain/Resources/Hardware/Routers/Ports/AddRouterPortUseCase.cs

@@ -0,0 +1,35 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Routers.Ports;
+
+public class AddRouterPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(
+        string name,
+        string? type,
+        double? speed,
+        int? ports)
+    {
+        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
+        // ToDo validate / normalize all inputs
+        
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
+        var desktop = await repository.GetByNameAsync(name) as Router
+                      ?? throw new NotFoundException($"Router '{name}' not found.");
+
+        desktop.Ports ??= new List<Port>();
+        desktop.Ports.Add(new Port
+        {
+            Type = nicType,
+            Speed = speed,
+            Count = ports
+        });
+        await repository.UpdateAsync(desktop);
+    }
+}

+ 23 - 0
RackPeek.Domain/Resources/Hardware/Routers/Ports/RemoveRouterPortUseCase.cs

@@ -0,0 +1,23 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Routers.Ports;
+
+public class RemoveRouterPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(string name, int index)
+    {
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var Router = await repository.GetByNameAsync(name) as Router
+                      ?? throw new NotFoundException($"Router '{name}' not found.");
+
+        if (Router.Ports == null || index < 0 || index >= Router.Ports.Count)
+            throw new NotFoundException($"Port index {index} not found on Router '{name}'.");
+
+        Router.Ports.RemoveAt(index);
+
+        await repository.UpdateAsync(Router);
+    }
+}

+ 37 - 0
RackPeek.Domain/Resources/Hardware/Routers/Ports/UpdateRouterPortUseCase.cs

@@ -0,0 +1,37 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Routers.Ports;
+
+public class UpdateRouterPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? type,
+        double? speed,
+        int? ports)
+    {
+        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
+        // ToDo validate / normalize all inputs
+        
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+        
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
+        var Router = await repository.GetByNameAsync(name) as Router
+                      ?? throw new NotFoundException($"Router '{name}' not found.");
+
+        if (Router.Ports == null || index < 0 || index >= Router.Ports.Count)
+            throw new NotFoundException($"Port index {index} not found on Router '{name}'.");
+
+        var nic = Router.Ports[index];
+        nic.Type = nicType;
+        nic.Speed = speed;
+        nic.Count = ports;
+        
+        await repository.UpdateAsync(Router);
+    }
+}

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Routers/RouterHardwareReport.cs

@@ -12,7 +12,7 @@ public record RouterHardwareRow(
     bool Managed,
     bool Poe,
     int TotalPorts,
-    int MaxPortSpeedGb,
+    double MaxPortSpeedGb,
     string PortSummary
 );
 

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Servers/Nics/AddNicUseCase.cs

@@ -8,7 +8,7 @@ public class AddNicUseCase(IHardwareRepository repository) : IUseCase
     public async Task ExecuteAsync(
         string name,
         string? type,
-        int? speed,
+        double? speed,
         int? ports)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Servers/Nics/UpdateNicUseCase.cs

@@ -10,7 +10,7 @@ public class UpdateNicUseCase(IHardwareRepository repository) : IUseCase
         string name,
         int index,
         string? type,
-        int? speed,
+        double? speed,
         int? ports)
     {
         // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Servers/ServerHardwareReport.cs

@@ -16,7 +16,7 @@ public record ServerHardwareRow(
     int SsdStorageGb,
     int HddStorageGb,
     int TotalNicPorts,
-    int MaxNicSpeedGb,
+    double MaxNicSpeedGb,
     int GpuCount,
     int TotalGpuVramGb,
     string GpuSummary,

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Switches/DescribeSwitchUseCase.cs

@@ -9,7 +9,7 @@ public record SwitchDescription(
     bool? Managed,
     bool? Poe,
     int TotalPorts,
-    int TotalSpeedGb,
+    double TotalSpeedGb,
     string PortSummary
 );
 

+ 35 - 0
RackPeek.Domain/Resources/Hardware/Switches/Ports/AddSwitchPortUseCase.cs

@@ -0,0 +1,35 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Switches.Ports;
+
+public class AddSwitchPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(
+        string name,
+        string? type,
+        double? speed,
+        int? ports)
+    {
+        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
+        // ToDo validate / normalize all inputs
+        
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
+        var desktop = await repository.GetByNameAsync(name) as Switch
+                      ?? throw new NotFoundException($"Switch '{name}' not found.");
+
+        desktop.Ports ??= new List<Port>();
+        desktop.Ports.Add(new Port
+        {
+            Type = nicType,
+            Speed = speed,
+            Count = ports
+        });
+        await repository.UpdateAsync(desktop);
+    }
+}

+ 23 - 0
RackPeek.Domain/Resources/Hardware/Switches/Ports/RemoveSwitchPortUseCase.cs

@@ -0,0 +1,23 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Switches.Ports;
+
+public class RemoveSwitchPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(string name, int index)
+    {
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var Switch = await repository.GetByNameAsync(name) as Switch
+                      ?? throw new NotFoundException($"Switch '{name}' not found.");
+
+        if (Switch.Ports == null || index < 0 || index >= Switch.Ports.Count)
+            throw new NotFoundException($"Port index {index} not found on Switch '{name}'.");
+
+        Switch.Ports.RemoveAt(index);
+
+        await repository.UpdateAsync(Switch);
+    }
+}

+ 37 - 0
RackPeek.Domain/Resources/Hardware/Switches/Ports/UpdateSwitchPortUseCase.cs

@@ -0,0 +1,37 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Hardware.Models;
+
+namespace RackPeek.Domain.Resources.Hardware.Switches.Ports;
+
+public class UpdateSwitchPortUseCase(IHardwareRepository repository) : IUseCase
+{
+    public async Task ExecuteAsync(
+        string name,
+        int index,
+        string? type,
+        double? speed,
+        int? ports)
+    {
+        // ToDo pass in properties as inputs, construct the entity in the usecase, ensure optional inputs are nullable
+        // ToDo validate / normalize all inputs
+        
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+        
+        var nicType = Normalize.NicType(type);
+        ThrowIfInvalid.NicType(nicType);
+        
+        var Switch = await repository.GetByNameAsync(name) as Switch
+                      ?? throw new NotFoundException($"Switch '{name}' not found.");
+
+        if (Switch.Ports == null || index < 0 || index >= Switch.Ports.Count)
+            throw new NotFoundException($"Port index {index} not found on Switch '{name}'.");
+
+        var nic = Switch.Ports[index];
+        nic.Type = nicType;
+        nic.Speed = speed;
+        nic.Count = ports;
+        
+        await repository.UpdateAsync(Switch);
+    }
+}

+ 1 - 1
RackPeek.Domain/Resources/Hardware/Switches/SwitchHardwareReport.cs

@@ -12,7 +12,7 @@ public record SwitchHardwareRow(
     bool Managed,
     bool Poe,
     int TotalPorts,
-    int MaxPortSpeedGb,
+    double MaxPortSpeedGb,
     string PortSummary
 );
 

+ 20 - 0
RackPeek.Domain/Resources/Resource.cs

@@ -7,4 +7,24 @@ public abstract class Resource
     public required string Name { get; set; }
 
     public Dictionary<string, string>? Tags { get; set; }
+
+    public static string KindToPlural(string kind)
+    {
+        return KindToPluralDictionary.GetValueOrDefault(kind.ToLower().Trim(), kind);
+    }
+
+    private static readonly Dictionary<string, string> KindToPluralDictionary = new Dictionary<string, string>()
+    {
+        { "hardware", "hardware" },
+        { "server", "servers" },
+        { "switch", "switches" },
+        { "firewall", "firewalls" },
+        { "router", "routers" },
+        { "accesspoint", "accesspoints" },
+        { "desktop", "desktops" },
+        { "laptop", "laptops" },
+        { "ups", "ups" },
+        { "system", "systems" },
+        { "service", "services" },
+    };
 }

+ 61 - 10
RackPeek.Web/Components/AccessPoints/AccessPointCardComponent.razor

@@ -1,16 +1,35 @@
-@using RackPeek.Domain.Resources.Hardware.Models
+@inject DeleteAccessPointUseCase DeleteUseCase
+@using RackPeek.Domain.Resources.Hardware.AccessPoints
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Web.Components.Modals
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
-        <div class="text-zinc-100">
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{AccessPoint.Name}")" class="block">
+
             @AccessPoint.Name
-        </div>
+</NavLink>
+            </div>
 
-        @if (!string.IsNullOrWhiteSpace(AccessPoint.Model))
-        {
-            <span class="text-xs text-zinc-400">
-                @AccessPoint.Model
-            </span>
-        }
+
+        <div class="flex justify-between items-center mb-3">
+            @if (!string.IsNullOrWhiteSpace(AccessPoint.Model))
+            {
+                <span class="text-xs text-zinc-400">
+                    @AccessPoint.Model
+                </span>
+            }
+            <div class="flex items-center gap-2">
+                
+                <button
+                    class="text-xs text-red-400 hover:text-red-300 transition"
+                    title="Delete server"
+                    @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            </div>
+        </div>
+        
     </div>
 
     <div class="text-sm">
@@ -28,6 +47,38 @@
     </div>
 </div>
 
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@AccessPoint.Name</strong>?
+</ConfirmModal>
+
 @code {
     [Parameter] [EditorRequired] public AccessPoint AccessPoint { get; set; } = default!;
-}
+}
+
+@code {
+    private bool _confirmDeleteOpen;
+    
+    [Parameter]
+    public EventCallback<string> OnDeleted { get; set; }
+    
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteServer()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteUseCase.ExecuteAsync(AccessPoint.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(AccessPoint.Name);
+    }
+}

+ 17 - 4
RackPeek.Web/Components/AccessPoints/AccessPointsListComponent.razor

@@ -1,10 +1,14 @@
 @using RackPeek.Domain.Resources.Hardware.AccessPoints
 @using RackPeek.Domain.Resources.Hardware.Models
 @inject GetAccessPointsUseCase GetAccessPoints
+@inject NavigationManager Nav
 
 <PageTitle>Access Points</PageTitle>
 
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+    <AddAccessPointComponent OnCreated="NavigateToNewResource"/>
+
+    
     @if (_AccessPoints is null)
     {
         <div class="text-zinc-500">loading AccessPoints…</div>
@@ -18,9 +22,7 @@
         <div class="space-y-4">
             @foreach (var accessPoint in _AccessPoints.OrderBy(s => s.Name))
             {
-                <NavLink href="@($"/resources/hardware/{accessPoint.Name}")" class="block">
-                    <AccessPointCardComponent AccessPoint="accessPoint"/>
-                </NavLink>
+                    <AccessPointCardComponent AccessPoint="accessPoint" OnDeleted="Callback"/>
             }
         </div>
     }
@@ -34,4 +36,15 @@
         _AccessPoints = await GetAccessPoints.ExecuteAsync();
     }
 
+    private Task NavigateToNewResource(string serverName)
+    {
+        Nav.NavigateTo($"/resources/hardware/{serverName}");
+        return Task.CompletedTask;
+    }
+    
+    private async Task Callback(string obj)
+    {
+        _AccessPoints = await GetAccessPoints.ExecuteAsync();
+    }
+    
 }

+ 68 - 0
RackPeek.Web/Components/AccessPoints/AddAccessPointComponent.razor

@@ -0,0 +1,68 @@
+@using RackPeek.Domain.Resources.Hardware.AccessPoints
+@inject AddAccessPointUseCase AddAccessPoint
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="text-zinc-100 mb-3">
+        Add AP
+    </div>
+
+    <div class="flex gap-2">
+        <input
+            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+            placeholder="AP name"
+            @bind="_name"
+            @bind:event="oninput"/>
+
+        <button
+            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
+            disabled="@_isSubmitting"
+            @onclick="CreateAsync">
+            add
+        </button>
+    </div>
+
+    @if (_error is not null)
+    {
+        <div class="mt-2 text-sm text-red-400">
+            @_error
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public EventCallback<string> OnCreated { get; set; }
+
+    private string _name = string.Empty;
+    private string? _error;
+    private bool _isSubmitting;
+
+    private async Task CreateAsync()
+    {
+        _error = null;
+
+        if (string.IsNullOrWhiteSpace(_name))
+        {
+            _error = "name is required";
+            return;
+        }
+
+        try
+        {
+            _isSubmitting = true;
+            var name = _name.Trim();
+            await AddAccessPoint.ExecuteAsync(name);
+            _name = string.Empty;
+
+            await OnCreated.InvokeAsync(name);
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+        finally
+        {
+            _isSubmitting = false;
+        }
+    }
+
+}

+ 68 - 0
RackPeek.Web/Components/Desktops/AddDesktopComponent.razor

@@ -0,0 +1,68 @@
+@using RackPeek.Domain.Resources.Hardware.Desktops
+@inject AddDesktopUseCase AddDesktop
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="text-zinc-100 mb-3">
+        Add Desktop
+    </div>
+
+    <div class="flex gap-2">
+        <input
+            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+            placeholder="Desktop name"
+            @bind="_name"
+            @bind:event="oninput"/>
+
+        <button
+            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
+            disabled="@_isSubmitting"
+            @onclick="CreateAsync">
+            add
+        </button>
+    </div>
+
+    @if (_error is not null)
+    {
+        <div class="mt-2 text-sm text-red-400">
+            @_error
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public EventCallback<string> OnCreated { get; set; }
+
+    private string _name = string.Empty;
+    private string? _error;
+    private bool _isSubmitting;
+
+    private async Task CreateAsync()
+    {
+        _error = null;
+
+        if (string.IsNullOrWhiteSpace(_name))
+        {
+            _error = "name is required";
+            return;
+        }
+
+        try
+        {
+            _isSubmitting = true;
+            var name = _name.Trim();
+            await AddDesktop.ExecuteAsync(name);
+            _name = string.Empty;
+
+            await OnCreated.InvokeAsync(name);
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+        finally
+        {
+            _isSubmitting = false;
+        }
+    }
+
+}

+ 444 - 44
RackPeek.Web/Components/Desktops/DesktopCardComponent.razor

@@ -1,85 +1,485 @@
 @using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.Desktops
+@using RackPeek.Domain.Resources.Hardware.Desktops.Cpus
+@using RackPeek.Domain.Resources.Hardware.Desktops.Drives
+@using RackPeek.Domain.Resources.Hardware.Desktops.Gpus
+@using RackPeek.Domain.Resources.Hardware.Desktops.Nics
+@using RackPeek.Web.Components.Modals
+
+@inject GetDesktopUseCase GetDesktopUseCase
+@inject UpdateDesktopUseCase UpdateDesktopUseCase
+@inject DeleteDesktopUseCase DeleteDesktopUseCase
+
+@inject AddDesktopCpuUseCase AddCpuUseCase
+@inject UpdateDesktopCpuUseCase UpdateCpuUseCase
+@inject RemoveDesktopCpuUseCase RemoveCpuUseCase
+
+@inject AddDesktopDriveUseCase AddDriveUseCase
+@inject UpdateDesktopDriveUseCase UpdateDriveUseCase
+@inject RemoveDesktopDriveUseCase RemoveDriveUseCase
+
+@inject AddDesktopNicUseCase AddNicUseCase
+@inject UpdateDesktopNicUseCase UpdateNicUseCase
+@inject RemoveDesktopNicUseCase RemoveNicUseCase
+
+@inject AddDesktopGpuUseCase AddGpuUseCase
+@inject UpdateDesktopGpuUseCase UpdateGpuUseCase
+@inject RemoveDesktopGpuUseCase RemoveGpuUseCase
+
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
-        <div class="text-zinc-100">
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{Desktop.Name}")" class="block">
+
             @Desktop.Name
+            </NavLink>
         </div>
 
-        @if (!string.IsNullOrWhiteSpace(Desktop.Model))
-        {
-            <span class="text-xs text-zinc-400">
-                @Desktop.Model
-            </span>
-        }
+        <div class="flex justify-between items-center mb-3">
+            @if (!string.IsNullOrWhiteSpace(Desktop.Model))
+            {
+                <span class="text-xs text-zinc-400">
+                    @Desktop.Model
+                </span>
+            }
+            <div class="flex items-center gap-2">
+                
+                <button
+                    class="text-xs text-red-400 hover:text-red-300 transition"
+                    title="Delete server"
+                    @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            </div>
+        </div>
+        
+
     </div>
 
     <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
 
-        @if (Desktop.Cpus?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">CPU</div>
+        <!-- CPU -->
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    CPU
+                    <button
+                        class="hover:text-emerald-400 transition"
+                        title="Add CPU"
+                        @onclick="OpenAddCpu">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (Desktop.Cpus?.Any() == true)
+            {
                 @foreach (var cpu in Desktop.Cpus)
                 {
-                    <div class="text-zinc-300">
-                        @cpu.Model — @cpu.Cores cores / @cpu.Threads threads
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button
+                            class="hover:text-emerald-400"
+                            title="Edit CPU"
+                            @onclick="() => OpenEditCpu(cpu)">
+                            @cpu.Model — @cpu.Cores cores / @cpu.Threads threads
+                        </button>
                     </div>
                 }
+            }
+        </div>
+
+        <!-- RAM -->
+        <div>
+            <div class="text-zinc-400 mb-1">
+                RAM
+                <button
+                    class="hover:text-emerald-400 transition"
+                    title="Edit RAM"
+                    @onclick="EditRam">
+                    +
+                </button>
             </div>
-        }
 
-        @if (Desktop.Ram is not null)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">RAM</div>
-                <div class="text-zinc-300">
-                    @Desktop.Ram.Size GB @Desktop.Ram.Mts MT/s
+            @if (Desktop.Ram is not null)
+            {
+                <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                    <button
+                        class="hover:text-emerald-400"
+                        @onclick="EditRam">
+                        @($"{Desktop.Ram.Size} GB {Desktop.Ram.Mts} MT/s")
+                    </button>
+                </div>
+            }
+        </div>
+
+        <!-- Drives -->
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    Drives
+                    <button
+                        class="hover:text-emerald-400 transition"
+                        title="Add Drive"
+                        @onclick="OpenAddDrive">
+                        +
+                    </button>
                 </div>
             </div>
-        }
 
-        @if (Desktop.Drives?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">Drives</div>
+            @if (Desktop.Drives?.Any() == true)
+            {
                 @foreach (var drive in Desktop.Drives)
                 {
-                    <div class="text-zinc-300">
-                        @drive.Type — @drive.Size GB
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button
+                            class="hover:text-emerald-400"
+                            @onclick="() => OpenEditDrive(drive)">
+                            @drive.Type — @drive.Size GB
+                        </button>
                     </div>
                 }
+            }
+        </div>
+
+        <!-- NICs -->
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    NICs
+                    <button
+                        class="hover:text-emerald-400 transition"
+                        title="Add NIC"
+                        @onclick="OpenAddNic">
+                        +
+                    </button>
+                </div>
             </div>
-        }
 
-        @if (Desktop.Nics?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">NICs</div>
+            @if (Desktop.Nics?.Any() == true)
+            {
                 @foreach (var nic in Desktop.Nics)
                 {
-                    <div class="text-zinc-300">
-                        @nic.Type — @nic.Speed Gbps (@nic.Ports ports)
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button
+                            class="hover:text-emerald-400"
+                            @onclick="() => OpenEditNic(nic)">
+                            @nic.Type — @nic.Speed Gbps (@nic.Ports ports)
+                        </button>
                     </div>
                 }
+            }
+        </div>
+
+        <!-- GPUs -->
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    GPUs
+                    <button
+                        class="hover:text-emerald-400 transition"
+                        title="Add GPU"
+                        @onclick="OpenAddGpu">
+                        +
+                    </button>
+                </div>
             </div>
-        }
 
-        @if (Desktop.Gpus?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">GPU</div>
+            @if (Desktop.Gpus?.Any() == true)
+            {
                 @foreach (var gpu in Desktop.Gpus)
                 {
-                    <div class="text-zinc-300">
-                        @gpu.Model — @gpu.Vram GB VRAM
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button
+                            class="hover:text-emerald-400"
+                            @onclick="() => OpenEditGpu(gpu)">
+                            @gpu.Model — @gpu.Vram GB VRAM
+                        </button>
                     </div>
                 }
-            </div>
-        }
+            }
+        </div>
 
     </div>
 </div>
+<CpuModal
+    IsOpen="@_cpuModalOpen"
+    IsOpenChanged="v => _cpuModalOpen = v"
+    Value="@_editingCpu"
+    OnSubmit="HandleCpuSubmit" 
+    OnDelete="HandleCpuDelete"/>
+
+<RamModal
+    IsOpen="@_isRamModalOpen"
+    IsOpenChanged="v => _isRamModalOpen = v"
+    Value="@Desktop.Ram"
+    OnSubmit="HandleRamSubmit"/>
+
+<DriveModal
+    IsOpen="@_driveModalOpen"
+    IsOpenChanged="v => _driveModalOpen = v"
+    Value="@_editingDrive"
+    OnSubmit="HandleDriveSubmit" 
+    OnDelete="HandleDriveDelete"/>
+
+<NicModal
+    IsOpen="@_nicModalOpen"
+    IsOpenChanged="v => _nicModalOpen = v"
+    Value="@_editingNic"
+    OnSubmit="HandleNicSubmit"
+    OnDelete="HandleNicDelete" />
+
+<GpuModal
+    IsOpen="@_gpuModalOpen"
+    IsOpenChanged="v => _gpuModalOpen = v"
+    Value="@_editingGpu"
+    OnSubmit="HandleGpuSubmit"
+    OnDelete="HandleGpuDelete" />
+
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@Desktop.Name</strong>?
+    <br />
+    This will detach all dependent systems.
+</ConfirmModal>
 
 @code {
-    [Parameter] [EditorRequired] public Desktop Desktop { get; set; } = default!;
+    [Parameter] [EditorRequired]
+    public Desktop Desktop { get; set; } = default!;
+
+    #region RAM
+    private bool _isRamModalOpen;
+    private void EditRam()
+    {
+        _isRamModalOpen = true;
+    }
+
+    private async Task HandleRamSubmit(Ram value)
+    {
+        _isRamModalOpen = false;
+        await UpdateDesktopUseCase.ExecuteAsync(Desktop.Name, Desktop.Model, value.Size, value.Mts);
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+    }
+    
+    #endregion
+    
+    #region CPU
+    bool _cpuModalOpen;
+    int _editingCpuIndex;
+    Cpu? _editingCpu;
+    
+    void OpenAddCpu()
+    {
+        _editingCpuIndex = -1;
+        _editingCpu = null;
+        _cpuModalOpen = true;
+    }
+    
+    void OpenEditCpu(Cpu cpu)
+    {
+        _editingCpu = cpu;
+        Desktop.Cpus ??= new();
+        _editingCpuIndex = Desktop.Cpus.IndexOf(cpu);;
+        _cpuModalOpen = true;
+    }
+    
+    async Task HandleCpuSubmit(Cpu cpu)
+    {
+        Desktop.Cpus ??= new();
+
+        if (_editingCpuIndex < 0)
+        {
+            await AddCpuUseCase.ExecuteAsync(Desktop.Name, cpu.Model, cpu.Cores, cpu.Threads);
+        }
+        else
+        {
+            await UpdateCpuUseCase.ExecuteAsync(Desktop.Name, _editingCpuIndex, cpu.Model, cpu.Cores, cpu.Threads);
+        }
+        
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+    }
+
+    async Task HandleCpuDelete(Cpu cpu)
+    {
+        await RemoveCpuUseCase.ExecuteAsync(Desktop.Name, _editingCpuIndex);
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+    }
+    
+        
+    #endregion
+    
+    #region Drives
+    bool _driveModalOpen;
+    int _editingDriveIndex;
+    Drive? _editingDrive;
+    
+    void OpenAddDrive()
+    {
+        _editingDriveIndex = -1;
+        _editingDrive = null;
+        _driveModalOpen = true;
+    }
+    
+    void OpenEditDrive(Drive drive)
+    {
+        _editingDrive = drive;
+        Desktop.Drives ??= new();
+        _editingDriveIndex = Desktop.Drives.IndexOf(drive);;
+        _driveModalOpen = true;
+    }
+    
+    async Task HandleDriveSubmit(Drive drive)
+    {
+        Desktop.Drives ??= new();
+
+        if (_editingDriveIndex < 0)
+        {
+            await AddDriveUseCase.ExecuteAsync(Desktop.Name, drive.Type, drive.Size);
+        }
+        else
+        {
+            await UpdateDriveUseCase.ExecuteAsync(Desktop.Name, _editingDriveIndex,  drive.Type, drive.Size);
+        }
+        
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+        StateHasChanged();
+    }
+
+    async Task HandleDriveDelete(Drive drive)
+    {
+        await RemoveDriveUseCase.ExecuteAsync(Desktop.Name, _editingDriveIndex);
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+        StateHasChanged();
+    }
+    
+        
+    #endregion
+    
+    #region NICs
+    bool _nicModalOpen;
+    int _editingNicIndex;
+    Nic? _editingNic;
+
+    void OpenAddNic()
+    {
+        _editingNicIndex = -1;
+        _editingNic = null;
+        _nicModalOpen = true;
+    }
+
+    void OpenEditNic(Nic nic)
+    {
+        Desktop.Nics ??= new();
+        _editingNicIndex = Desktop.Nics.IndexOf(nic);
+        _editingNic = nic;
+        _nicModalOpen = true;
+    }
+
+    async Task HandleNicSubmit(Nic nic)
+    {
+        Desktop.Nics ??= new();
+
+        if (_editingNicIndex < 0)
+        {
+            await AddNicUseCase.ExecuteAsync(
+                Desktop.Name,
+                nic.Type,
+                nic.Speed,
+                nic.Ports);
+        }
+        else
+        {
+            await UpdateNicUseCase.ExecuteAsync(
+                Desktop.Name,
+                _editingNicIndex,
+                nic.Type,
+                nic.Speed,
+                nic.Ports);
+        }
+
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+    }
+
+    async Task HandleNicDelete(Nic nic)
+    {
+        await RemoveNicUseCase.ExecuteAsync(Desktop.Name, _editingNicIndex);
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+    }
+    #endregion
+
+    #region GPUs
+    bool _gpuModalOpen;
+    int _editingGpuIndex;
+    Gpu? _editingGpu;
+
+    void OpenAddGpu()
+    {
+        _editingGpuIndex = -1;
+        _editingGpu = null;
+        _gpuModalOpen = true;
+    }
+
+    void OpenEditGpu(Gpu gpu)
+    {
+        Desktop.Gpus ??= new();
+        _editingGpuIndex = Desktop.Gpus.IndexOf(gpu);
+        _editingGpu = gpu;
+        _gpuModalOpen = true;
+    }
+
+    async Task HandleGpuSubmit(Gpu gpu)
+    {
+        Desktop.Gpus ??= new();
+
+        if (_editingGpuIndex < 0)
+        {
+            await AddGpuUseCase.ExecuteAsync(
+                Desktop.Name,
+                gpu.Model,
+                gpu.Vram);
+        }
+        else
+        {
+            await UpdateGpuUseCase.ExecuteAsync(
+                Desktop.Name,
+                _editingGpuIndex,
+                gpu.Model,
+                gpu.Vram);
+        }
+
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+    }
+
+    async Task HandleGpuDelete(Gpu gpu)
+    {
+        await RemoveGpuUseCase.ExecuteAsync(Desktop.Name, _editingGpuIndex);
+        Desktop = await GetDesktopUseCase.ExecuteAsync(Desktop.Name);
+    }
+    #endregion
+
+}
+
+@code {
+    private bool _confirmDeleteOpen;
+    [Parameter]
+    public EventCallback<string> OnDeleted { get; set; }
+    
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteServer()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteDesktopUseCase.ExecuteAsync(Desktop.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(Desktop.Name);
+    }
 }

+ 16 - 4
RackPeek.Web/Components/Desktops/DesktopsListComponent.razor

@@ -1,10 +1,14 @@
 @using RackPeek.Domain.Resources.Hardware.Desktops
 @using RackPeek.Domain.Resources.Hardware.Models
 @inject GetDesktopsUseCase GetDesktops
+@inject NavigationManager Nav
 
 <PageTitle>Desktops</PageTitle>
 
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+    
+    <AddDesktopComponent OnCreated="NavigateToNewResource"/>
+
     @if (_desktops is null)
     {
         <div class="text-zinc-500">loading desktops…</div>
@@ -18,9 +22,7 @@
         <div class="space-y-4">
             @foreach (var desktop in _desktops.OrderBy(s => s.Name))
             {
-                <NavLink href="@($"/resources/hardware/{desktop.Name}")" class="block">
-                    <DesktopCardComponent Desktop="desktop"/>
-                </NavLink>
+                    <DesktopCardComponent Desktop="desktop" OnDeleted="Callback"/>
             }
         </div>
     }
@@ -33,5 +35,15 @@
     {
         _desktops = await GetDesktops.ExecuteAsync();
     }
+    
+    private Task NavigateToNewResource(string serverName)
+    {
+        Nav.NavigateTo($"/resources/hardware/{serverName}");
+        return Task.CompletedTask;
+    }
+    private async Task Callback(string obj)
+    {
+        _desktops = await GetDesktops.ExecuteAsync();
+    }
 
 }

+ 68 - 0
RackPeek.Web/Components/Firewalls/AddFirewallComponent.razor

@@ -0,0 +1,68 @@
+@using RackPeek.Domain.Resources.Hardware.Firewalls
+@inject AddFirewallUseCase AddFirewall
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="text-zinc-100 mb-3">
+        Add Firewall
+    </div>
+
+    <div class="flex gap-2">
+        <input
+            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+            placeholder="Firewall name"
+            @bind="_name"
+            @bind:event="oninput"/>
+
+        <button
+            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
+            disabled="@_isSubmitting"
+            @onclick="CreateAsync">
+            add
+        </button>
+    </div>
+
+    @if (_error is not null)
+    {
+        <div class="mt-2 text-sm text-red-400">
+            @_error
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public EventCallback<string> OnCreated { get; set; }
+
+    private string _name = string.Empty;
+    private string? _error;
+    private bool _isSubmitting;
+
+    private async Task CreateAsync()
+    {
+        _error = null;
+
+        if (string.IsNullOrWhiteSpace(_name))
+        {
+            _error = "name is required";
+            return;
+        }
+
+        try
+        {
+            _isSubmitting = true;
+            var name = _name.Trim();
+            await AddFirewall.ExecuteAsync(name);
+            _name = string.Empty;
+
+            await OnCreated.InvokeAsync(name);
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+        finally
+        {
+            _isSubmitting = false;
+        }
+    }
+
+}

+ 280 - 0
RackPeek.Web/Components/Firewalls/FirewallCardComponent.razor

@@ -0,0 +1,280 @@
+@inject UpdateFirewallUseCase UpdateFirewallUseCase
+@inject GetFirewallUseCase GetFirewallUseCase
+@inject AddFirewallPortUseCase AddFirewallPortUseCase
+@inject UpdateFirewallPortUseCase UpdateFirewallPortUseCase
+@inject RemoveFirewallPortUseCase RemoveFirewallPortUseCase
+@inject DeleteFirewallUseCase DeleteUseCase
+
+@using RackPeek.Domain.Resources.Hardware.Firewalls
+@using RackPeek.Domain.Resources.Hardware.Firewalls.Ports
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Web.Components.Modals
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="flex justify-between items-center mb-3">
+        
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{Firewall.Name}")" class="block">
+
+            @Firewall.Name
+            </NavLink>
+            </div>
+        
+        
+        <div class="flex gap-3 text-xs">
+            @if (!_isEditing)
+            {
+                <button class="text-zinc-400 hover:text-zinc-200"
+                        @onclick="BeginEdit">
+                    Edit
+                </button>
+                <button
+                    class="text-xs text-red-400 hover:text-red-300 transition"
+                    title="Delete server"
+                    @onclick="ConfirmDelete">
+                    Delete
+                </button>
+                
+            }
+            else
+            {
+                <button class="text-emerald-400 hover:text-emerald-300"
+                        @onclick="Save">
+                    Save
+                </button>
+                <button class="text-zinc-500 hover:text-zinc-300"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+            }
+        </div>
+    </div>
+
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
+
+        <!-- Model -->
+        <div>
+            <div class="text-zinc-400 mb-1">Model</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.Model" />
+            }
+            else if (!string.IsNullOrWhiteSpace(Firewall.Model))
+            {
+                <div class="text-zinc-300">@Firewall.Model</div>
+            }
+        </div>
+
+        <!-- Features -->
+        <div>
+            <div class="text-zinc-400 mb-1">Features</div>
+
+            @if (_isEditing)
+            {
+                <div class="flex gap-4">
+                    <label class="flex items-center gap-2 text-zinc-300">
+                        <input type="checkbox" @bind="_edit.Managed" />
+                        Managed
+                    </label>
+
+                    <label class="flex items-center gap-2 text-zinc-300">
+                        <input type="checkbox" @bind="_edit.Poe" />
+                        PoE
+                    </label>
+                </div>
+            }
+            else
+            {
+                <div class="flex gap-2 flex-wrap">
+                    @if (Firewall.Managed == true)
+                    {
+                        <span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-300">
+                            Managed
+                        </span>
+                    }
+                    @if (Firewall.Poe == true)
+                    {
+                        <span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-300">
+                            PoE
+                        </span>
+                    }
+                </div>
+            }
+        </div>
+
+        <!-- Ports -->
+        <div class="md:col-span-2">
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    Ports
+                    <button class="hover:text-emerald-400 ml-1"
+                            title="Add Port"
+                            @onclick="OpenAddPort">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (Firewall.Ports?.Any() == true)
+            {
+                @foreach (var port in Firewall.Ports)
+                {
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button class="hover:text-emerald-400"
+                                title="Edit Port"
+                                @onclick="() => OpenEditPort(port)">
+                            @port.Count× @port.Type — @port.Speed Gbps
+                        </button>
+                    </div>
+                }
+            }
+        </div>
+    </div>
+</div>
+
+<PortModal
+    IsOpen="@_portModalOpen"
+    IsOpenChanged="v => _portModalOpen = v"
+    Value="@_editingPort"
+    OnSubmit="HandlePortSubmit"
+    OnDelete="HandlePortDelete" />
+
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@Firewall.Name</strong>?
+</ConfirmModal>
+
+
+@code {
+    [Parameter, EditorRequired]
+    public Firewall Firewall { get; set; } = default!;
+
+    bool _isEditing;
+    FirewallEditModel _edit = new();
+
+    void BeginEdit()
+    {
+        _edit = FirewallEditModel.From(Firewall);
+        _isEditing = true;
+    }
+
+    async Task Save()
+    {
+        _isEditing = false;
+
+        await UpdateFirewallUseCase.ExecuteAsync(
+            Firewall.Name,
+            _edit.Model,
+            _edit.Managed,
+            _edit.Poe);
+
+        Firewall = await GetFirewallUseCase.ExecuteAsync(Firewall.Name);
+    }
+
+    void Cancel()
+    {
+        _isEditing = false;
+    }
+
+    #region Ports
+
+    bool _portModalOpen;
+    int _editingPortIndex;
+    Port? _editingPort;
+
+    void OpenAddPort()
+    {
+        _editingPortIndex = -1;
+        _editingPort = null;
+        _portModalOpen = true;
+    }
+
+    void OpenEditPort(Port port)
+    {
+        Firewall.Ports ??= new();
+        _editingPortIndex = Firewall.Ports.IndexOf(port);
+        _editingPort = port;
+        _portModalOpen = true;
+    }
+
+    async Task HandlePortSubmit(Port port)
+    {
+        if (_editingPortIndex < 0)
+        {
+            await AddFirewallPortUseCase.ExecuteAsync(
+                Firewall.Name,
+                port.Type,
+                port.Speed,
+                port.Count);
+        }
+        else
+        {
+            await UpdateFirewallPortUseCase.ExecuteAsync(
+                Firewall.Name,
+                _editingPortIndex,
+                port.Type,
+                port.Speed,
+                port.Count);
+        }
+
+        Firewall = await GetFirewallUseCase.ExecuteAsync(Firewall.Name);
+        StateHasChanged();
+    }
+
+    async Task HandlePortDelete(Port _)
+    {
+        await RemoveFirewallPortUseCase.ExecuteAsync(
+            Firewall.Name,
+            _editingPortIndex);
+
+        Firewall = await GetFirewallUseCase.ExecuteAsync(Firewall.Name);
+        StateHasChanged();
+    }
+
+    #endregion
+    public class FirewallEditModel
+    {
+        public string? Model { get; set; }
+        public bool? Managed { get; set; }
+        public bool? Poe { get; set; }
+
+        public static FirewallEditModel From(Firewall firewall)
+        {
+            return new FirewallEditModel
+            {
+                Model = firewall.Model,
+                Managed = firewall.Managed,
+                Poe = firewall.Poe
+            };
+        }
+    }
+    
+
+}
+
+@code {
+    private bool _confirmDeleteOpen;
+    [Parameter]
+    public EventCallback<string> OnDeleted { get; set; }
+    
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteServer()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteUseCase.ExecuteAsync(Firewall.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(Firewall.Name);
+    }
+}

+ 50 - 0
RackPeek.Web/Components/Firewalls/FirewallListComponent.razor

@@ -0,0 +1,50 @@
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.Firewalls
+@inject GetFirewallsUseCase GetFirewalls
+@inject NavigationManager Nav
+
+<PageTitle>Firewall</PageTitle>
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+    <AddFirewallComponent OnCreated="NavigateToNewResource"/>
+
+    
+    @if (_Firewall is null)
+    {
+        <div class="text-zinc-500">loading Firewall…</div>
+    }
+    else if (_Firewall.Count == 0)
+    {
+        <div class="text-zinc-500">no Firewall found</div>
+    }
+    else
+    {
+        <div class="space-y-4">
+            @foreach (var _switch in _Firewall.OrderBy(s => s.Name))
+            {
+                    <FirewallCardComponent Firewall="_switch" OnDeleted="Callback"/>
+            }
+        </div>
+    }
+</div>
+
+@code {
+    private IReadOnlyList<Firewall>? _Firewall;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _Firewall = await GetFirewalls.ExecuteAsync();
+    }
+    
+    private Task NavigateToNewResource(string serverName)
+    {
+        Nav.NavigateTo($"/resources/hardware/{serverName}");
+        return Task.CompletedTask;
+    }
+
+    private async Task Callback(string obj)
+    {
+        _Firewall = await GetFirewalls.ExecuteAsync();
+    }
+
+}

+ 9 - 0
RackPeek.Web/Components/Firewalls/FirewallListPage.razor

@@ -0,0 +1,9 @@
+@page "/firewalls/list"
+
+<PageTitle>Firewalls</PageTitle>
+
+<h1 class="text-lg text-zinc-100">
+    Firewalls
+</h1>
+
+<FirewallListComponent/>

+ 17 - 0
RackPeek.Web/Components/Hardware/HardwareDetailsPage.razor

@@ -7,6 +7,11 @@
 @using RackPeek.Web.Components.Switches
 @using RackPeek.Web.Components.Servers
 @using RackPeek.Web.Components.Systems
+@using RackPeek.Web.Components.Laptops
+@using RackPeek.Web.Components.Firewalls
+@using Router = RackPeek.Domain.Resources.Hardware.Models.Router
+@using RackPeek.Web.Components.Routers
+@using RackPeek.Web.Components.Ups
 @inject IHardwareRepository HardwareRepository
 @inject GetHardwareSystemTreeUseCase GetHardwareSystemTreeUseCase
 
@@ -46,6 +51,18 @@
         else if (_hardware is Switch _switch)
         {
             <SwitchCardComponent Switch="_switch"/>
+        }        else if (_hardware is Laptop laptop)
+        {
+            <LaptopCardComponent Laptop="laptop"/>
+        }     else if (_hardware is Firewall firewall)
+        {
+            <FirewallCardComponent Firewall="firewall"/>
+        }  else if (_hardware is Router router)
+        {
+            <RouterCardComponent Router="router"/>
+        } else if (_hardware is Ups ups)
+        {
+            <UpsCardComponent Ups="ups"/>
         }
         else
         {

+ 17 - 8
RackPeek.Web/Components/Hardware/HardwareTreePage.razor

@@ -15,12 +15,21 @@
         <NavLink href="/switches/list" class="hover:text-emerald-400" activeClass="text-emerald-400 font-semibold">
             Switches
         </NavLink>
-        <NavLink href="/desktops/list" class="hover:text-emerald-400" activeClass="text-emerald-400 font-semibold">
-            Desktops
+        <NavLink href="/firewalls/list" class="hover:text-emerald-400" activeClass="text-emerald-400 font-semibold">
+            Firewalls
+        </NavLink>
+        <NavLink href="/routers/list" class="hover:text-emerald-400" activeClass="text-emerald-400 font-semibold">
+            Routers
         </NavLink>
         <NavLink href="/accesspoints/list" class="hover:text-emerald-400" activeClass="text-emerald-400 font-semibold">
             AccessPoints
         </NavLink>
+        <NavLink href="/desktops/list" class="hover:text-emerald-400" activeClass="text-emerald-400 font-semibold">
+            Desktops
+        </NavLink>
+        <NavLink href="/laptops/list" class="hover:text-emerald-400" activeClass="text-emerald-400 font-semibold">
+            Laptops
+        </NavLink>
     </nav>
 
     @if (_tree is null)
@@ -54,13 +63,13 @@
 
                                 @if (hardware.Systems.Any())
                                 {
-                                    <div class="text-zinc-100">
+                                    <div class="text-zinc-100 hover:text-emerald-300">
                                         @hardware.HardwareName (@hardware.Systems.Count / @hardware.Systems.Sum(s => s.Services.Count))
                                     </div>
                                 }
                                 else
                                 {
-                                    <div class="text-zinc-100">
+                                    <div class="text-zinc-100 hover:text-emerald-300">
                                         @hardware.HardwareName
                                     </div>
                                 }
@@ -77,13 +86,13 @@
                                             <NavLink href="@($"/resources/systems/{system.SystemName}")" class="block">
                                                 @if (system.Services.Any())
                                                 {
-                                                    <div class="text-zinc-300">
+                                                    <div class="text-zinc-300 hover:text-emerald-300">
                                                         └─ @system.SystemName (@system.Services.Count)
                                                     </div>
                                                 }
                                                 else
                                                 {
-                                                    <div class="text-zinc-300">
+                                                    <div class="text-zinc-300 hover:text-emerald-300">
                                                         └─ @system.SystemName
                                                     </div>
                                                 }
@@ -96,9 +105,9 @@
                                                     @foreach (var service in system.Services.OrderBy(s => s))
                                                     {
                                                         <NavLink href="@($"/resources/services/{service}")"
-                                                                 class="block">
+                                                                 class="block hover:text-emerald-300">
 
-                                                            <li class="text-zinc-500">
+                                                            <li class="text-zinc-500 hover:text-emerald-300">
                                                                 > @service
                                                             </li>
                                                         </NavLink>

+ 68 - 0
RackPeek.Web/Components/Laptops/AddLaptopComponent.razor

@@ -0,0 +1,68 @@
+@using RackPeek.Domain.Resources.Hardware.Laptops
+@inject AddLaptopUseCase AddLaptop
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="text-zinc-100 mb-3">
+        Add Laptop
+    </div>
+
+    <div class="flex gap-2">
+        <input
+            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+            placeholder="Laptop name"
+            @bind="_name"
+            @bind:event="oninput"/>
+
+        <button
+            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
+            disabled="@_isSubmitting"
+            @onclick="CreateAsync">
+            add
+        </button>
+    </div>
+
+    @if (_error is not null)
+    {
+        <div class="mt-2 text-sm text-red-400">
+            @_error
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public EventCallback<string> OnCreated { get; set; }
+
+    private string _name = string.Empty;
+    private string? _error;
+    private bool _isSubmitting;
+
+    private async Task CreateAsync()
+    {
+        _error = null;
+
+        if (string.IsNullOrWhiteSpace(_name))
+        {
+            _error = "name is required";
+            return;
+        }
+
+        try
+        {
+            _isSubmitting = true;
+            var name = _name.Trim();
+            await AddLaptop.ExecuteAsync(name);
+            _name = string.Empty;
+
+            await OnCreated.InvokeAsync(name);
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+        finally
+        {
+            _isSubmitting = false;
+        }
+    }
+
+}

+ 395 - 0
RackPeek.Web/Components/Laptops/LaptopCardComponent.razor

@@ -0,0 +1,395 @@
+@using RackPeek.Domain.Resources.Hardware.Desktops
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.Laptops
+@using RackPeek.Domain.Resources.Hardware.Laptops.Cpus
+@using RackPeek.Domain.Resources.Hardware.Laptops.Drives
+@using RackPeek.Domain.Resources.Hardware.Laptops.Gpus
+@using RackPeek.Web.Components.Modals
+
+@inject GetLaptopUseCase GetLaptopUseCase
+@inject UpdateLaptopUseCase UpdateLaptopUseCase
+@inject DeleteLaptopUseCase DeleteLaptopUseCase
+
+@inject AddLaptopCpuUseCase AddCpuUseCase
+@inject UpdateLaptopCpuUseCase UpdateCpuUseCase
+@inject RemoveLaptopCpuUseCase RemoveCpuUseCase
+
+@inject AddLaptopDriveUseCase AddDriveUseCase
+@inject UpdateLaptopDriveUseCase UpdateDriveUseCase
+@inject RemoveLaptopDriveUseCase RemoveDriveUseCase
+
+@inject AddLaptopGpuUseCase AddGpuUseCase
+@inject UpdateLaptopGpuUseCase UpdateGpuUseCase
+@inject RemoveLaptopGpuUseCase RemoveGpuUseCase
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="flex justify-between items-center mb-3">
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{Laptop.Name}")" class="block">
+
+            @Laptop.Name
+</NavLink>
+            </div>
+
+
+        
+        <div class="flex justify-between items-center mb-3">
+            @if (!string.IsNullOrWhiteSpace(Laptop.Model))
+            {
+                <span class="text-xs text-zinc-400">
+                    @Laptop.Model
+                </span>
+            }
+            <div class="flex items-center gap-2">
+                
+                <button
+                    class="text-xs text-red-400 hover:text-red-300 transition"
+                    title="Delete server"
+                    @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            </div>
+        </div>
+        
+    </div>
+
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
+
+        <!-- CPU -->
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    CPU
+                    <button
+                        class="hover:text-emerald-400 transition"
+                        title="Add CPU"
+                        @onclick="OpenAddCpu">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (Laptop.Cpus?.Any() == true)
+            {
+                @foreach (var cpu in Laptop.Cpus)
+                {
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button
+                            class="hover:text-emerald-400"
+                            title="Edit CPU"
+                            @onclick="() => OpenEditCpu(cpu)">
+                            @cpu.Model — @cpu.Cores cores / @cpu.Threads threads
+                        </button>
+                    </div>
+                }
+            }
+        </div>
+
+        <!-- RAM -->
+        <div>
+            <div class="text-zinc-400 mb-1">
+                RAM
+                <button
+                    class="hover:text-emerald-400 transition"
+                    title="Edit RAM"
+                    @onclick="EditRam">
+                    +
+                </button>
+            </div>
+
+            @if (Laptop.Ram is not null)
+            {
+                <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                    <button
+                        class="hover:text-emerald-400"
+                        @onclick="EditRam">
+                        @($"{Laptop.Ram.Size} GB {Laptop.Ram.Mts} MT/s")
+                    </button>
+                </div>
+            }
+        </div>
+
+        <!-- Drives -->
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    Drives
+                    <button
+                        class="hover:text-emerald-400 transition"
+                        title="Add Drive"
+                        @onclick="OpenAddDrive">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (Laptop.Drives?.Any() == true)
+            {
+                @foreach (var drive in Laptop.Drives)
+                {
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button
+                            class="hover:text-emerald-400"
+                            @onclick="() => OpenEditDrive(drive)">
+                            @drive.Type — @drive.Size GB
+                        </button>
+                    </div>
+                }
+            }
+        </div>
+
+        <!-- GPUs -->
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    GPUs
+                    <button
+                        class="hover:text-emerald-400 transition"
+                        title="Add GPU"
+                        @onclick="OpenAddGpu">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (Laptop.Gpus?.Any() == true)
+            {
+                @foreach (var gpu in Laptop.Gpus)
+                {
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button
+                            class="hover:text-emerald-400"
+                            @onclick="() => OpenEditGpu(gpu)">
+                            @gpu.Model — @gpu.Vram GB VRAM
+                        </button>
+                    </div>
+                }
+            }
+        </div>
+
+    </div>
+</div>
+<CpuModal
+    IsOpen="@_cpuModalOpen"
+    IsOpenChanged="v => _cpuModalOpen = v"
+    Value="@_editingCpu"
+    OnSubmit="HandleCpuSubmit" 
+    OnDelete="HandleCpuDelete"/>
+
+<RamModal
+    IsOpen="@_isRamModalOpen"
+    IsOpenChanged="v => _isRamModalOpen = v"
+    Value="@Laptop.Ram"
+    OnSubmit="HandleRamSubmit"/>
+
+<DriveModal
+    IsOpen="@_driveModalOpen"
+    IsOpenChanged="v => _driveModalOpen = v"
+    Value="@_editingDrive"
+    OnSubmit="HandleDriveSubmit" 
+    OnDelete="HandleDriveDelete"/>
+
+<GpuModal
+    IsOpen="@_gpuModalOpen"
+    IsOpenChanged="v => _gpuModalOpen = v"
+    Value="@_editingGpu"
+    OnSubmit="HandleGpuSubmit"
+    OnDelete="HandleGpuDelete" />
+
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@Laptop.Name</strong>?
+    <br />
+    This will detach all dependent systems.
+</ConfirmModal>
+
+@code {
+    [Parameter] [EditorRequired]
+    public Laptop Laptop { get; set; } = default!;
+
+    #region RAM
+    private bool _isRamModalOpen;
+    private void EditRam()
+    {
+        _isRamModalOpen = true;
+    }
+
+    private async Task HandleRamSubmit(Ram value)
+    {
+        _isRamModalOpen = false;
+        await UpdateLaptopUseCase.ExecuteAsync(Laptop.Name, Laptop.Model, value.Size, value.Mts);
+        Laptop = await GetLaptopUseCase.ExecuteAsync(Laptop.Name);
+    }
+    
+    #endregion
+    
+    #region CPU
+    bool _cpuModalOpen;
+    int _editingCpuIndex;
+    Cpu? _editingCpu;
+    
+    void OpenAddCpu()
+    {
+        _editingCpuIndex = -1;
+        _editingCpu = null;
+        _cpuModalOpen = true;
+    }
+    
+    void OpenEditCpu(Cpu cpu)
+    {
+        _editingCpu = cpu;
+        Laptop.Cpus ??= new();
+        _editingCpuIndex = Laptop.Cpus.IndexOf(cpu);;
+        _cpuModalOpen = true;
+    }
+    
+    async Task HandleCpuSubmit(Cpu cpu)
+    {
+        Laptop.Cpus ??= new();
+
+        if (_editingCpuIndex < 0)
+        {
+            await AddCpuUseCase.ExecuteAsync(Laptop.Name, cpu.Model, cpu.Cores, cpu.Threads);
+        }
+        else
+        {
+            await UpdateCpuUseCase.ExecuteAsync(Laptop.Name, _editingCpuIndex, cpu.Model, cpu.Cores, cpu.Threads);
+        }
+        
+        Laptop = await GetLaptopUseCase.ExecuteAsync(Laptop.Name);
+    }
+
+    async Task HandleCpuDelete(Cpu cpu)
+    {
+        await RemoveCpuUseCase.ExecuteAsync(Laptop.Name, _editingCpuIndex);
+        Laptop = await GetLaptopUseCase.ExecuteAsync(Laptop.Name);
+    }
+    
+        
+    #endregion
+    
+    #region Drives
+    bool _driveModalOpen;
+    int _editingDriveIndex;
+    Drive? _editingDrive;
+    
+    void OpenAddDrive()
+    {
+        _editingDriveIndex = -1;
+        _editingDrive = null;
+        _driveModalOpen = true;
+    }
+    
+    void OpenEditDrive(Drive drive)
+    {
+        _editingDrive = drive;
+        Laptop.Drives ??= new();
+        _editingDriveIndex = Laptop.Drives.IndexOf(drive);;
+        _driveModalOpen = true;
+    }
+    
+    async Task HandleDriveSubmit(Drive drive)
+    {
+        Laptop.Drives ??= new();
+
+        if (_editingDriveIndex < 0)
+        {
+            await AddDriveUseCase.ExecuteAsync(Laptop.Name, drive.Type, drive.Size);
+        }
+        else
+        {
+            await UpdateDriveUseCase.ExecuteAsync(Laptop.Name, _editingDriveIndex,  drive.Type, drive.Size);
+        }
+        
+        Laptop = await GetLaptopUseCase.ExecuteAsync(Laptop.Name);
+        StateHasChanged();
+    }
+
+    async Task HandleDriveDelete(Drive drive)
+    {
+        await RemoveDriveUseCase.ExecuteAsync(Laptop.Name, _editingDriveIndex);
+        Laptop = await GetLaptopUseCase.ExecuteAsync(Laptop.Name);
+        StateHasChanged();
+    }
+    
+        
+    #endregion
+
+    #region GPUs
+    bool _gpuModalOpen;
+    int _editingGpuIndex;
+    Gpu? _editingGpu;
+
+    void OpenAddGpu()
+    {
+        _editingGpuIndex = -1;
+        _editingGpu = null;
+        _gpuModalOpen = true;
+    }
+
+    void OpenEditGpu(Gpu gpu)
+    {
+        Laptop.Gpus ??= new();
+        _editingGpuIndex = Laptop.Gpus.IndexOf(gpu);
+        _editingGpu = gpu;
+        _gpuModalOpen = true;
+    }
+
+    async Task HandleGpuSubmit(Gpu gpu)
+    {
+        Laptop.Gpus ??= new();
+
+        if (_editingGpuIndex < 0)
+        {
+            await AddGpuUseCase.ExecuteAsync(
+                Laptop.Name,
+                gpu.Model,
+                gpu.Vram);
+        }
+        else
+        {
+            await UpdateGpuUseCase.ExecuteAsync(
+                Laptop.Name,
+                _editingGpuIndex,
+                gpu.Model,
+                gpu.Vram);
+        }
+
+        Laptop = await GetLaptopUseCase.ExecuteAsync(Laptop.Name);
+    }
+
+    async Task HandleGpuDelete(Gpu gpu)
+    {
+        await RemoveGpuUseCase.ExecuteAsync(Laptop.Name, _editingGpuIndex);
+        Laptop = await GetLaptopUseCase.ExecuteAsync(Laptop.Name);
+    }
+    #endregion
+
+}
+
+@code {
+    private bool _confirmDeleteOpen;
+    [Parameter]
+    public EventCallback<string> OnDeleted { get; set; }
+    
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteServer()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteLaptopUseCase.ExecuteAsync(Laptop.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(Laptop.Name);
+    }
+}
+

+ 48 - 0
RackPeek.Web/Components/Laptops/LaptopsListComponent.razor

@@ -0,0 +1,48 @@
+@using RackPeek.Domain.Resources.Hardware.Laptops
+@using RackPeek.Domain.Resources.Hardware.Models
+@inject GetLaptopsUseCase GetLaptops
+@inject NavigationManager Nav
+
+<PageTitle>Laptops</PageTitle>
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+    <AddLaptopComponent OnCreated="NavigateToNewResource"/>
+
+    @if (_Laptops is null)
+    {
+        <div class="text-zinc-500">loading Laptops…</div>
+    }
+    else if (_Laptops.Count == 0)
+    {
+        <div class="text-zinc-500">no Laptops found</div>
+    }
+    else
+    {
+        <div class="space-y-4">
+            @foreach (var Laptop in _Laptops.OrderBy(s => s.Name))
+            {
+                    <LaptopCardComponent Laptop="Laptop" OnDeleted="Callback"/>
+            }
+        </div>
+    }
+</div>
+
+@code {
+    private IReadOnlyList<Laptop>? _Laptops;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _Laptops = await GetLaptops.ExecuteAsync();
+    }
+    
+    private Task NavigateToNewResource(string serverName)
+    {
+        Nav.NavigateTo($"/resources/hardware/{serverName}");
+        return Task.CompletedTask;
+    }
+    private async Task Callback(string obj)
+    {
+        _Laptops = await GetLaptops.ExecuteAsync();
+    }
+
+}

+ 9 - 0
RackPeek.Web/Components/Laptops/LaptopsListPage.razor

@@ -0,0 +1,9 @@
+@page "/laptops/list"
+
+<PageTitle>Laptops</PageTitle>
+
+<h1 class="text-lg text-zinc-100">
+    Laptops
+</h1>
+
+<LaptopsListComponent/>

+ 1 - 1
RackPeek.Web/Components/Modals/NicModal.razor

@@ -172,7 +172,7 @@
         public string? Type { get; set; }
 
         [Range(1, 400)]
-        public int? Speed { get; set; }
+        public double? Speed { get; set; }
 
         [Range(1, 128)]
         public int? Ports { get; set; }

+ 12 - 4
RackPeek.Web/Components/Modals/PortModal.razor

@@ -34,11 +34,19 @@
                             Type
                         </label>
 
-                        <InputText
+                        <InputSelect
                             class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
-                            @bind-Value="_model.Type" />
+                            @bind-Value="_model.Type">
+                            <option value="">Select type</option>
+
+                            @foreach (var type in Nic.ValidNicTypes)
+                            {
+                                <option value="@type">@type</option>
+                            }
+                        </InputSelect>
                     </div>
 
+
                     <!-- Speed -->
                     <div>
                         <label class="block text-zinc-400 mb-1">
@@ -164,8 +172,8 @@
         [Required]
         public string? Type { get; set; }
 
-        [Range(1, 400)]
-        public int? Speed { get; set; }
+        [Range(0, 400)]
+        public double? Speed { get; set; }
 
         [Range(1, 256)]
         public int? Count { get; set; }

+ 11 - 7
RackPeek.Web/Components/Pages/Home.razor

@@ -1,4 +1,5 @@
 @page "/"
+@using RackPeek.Domain.Resources
 @using RackPeek.Domain.Resources.Hardware
 @using RackPeek.Domain.Resources.Services.UseCases
 @using RackPeek.Domain.Resources.SystemResources.UseCases
@@ -23,19 +24,19 @@
                     Totals
                 </div>
 
-                <div class="grid grid-cols-2 gap-y-2">
+                <div class="grid grid-cols-2 gap-y-2 ">
 
-                    <div>
+                    <div class="hover:text-emerald-300">
                         <NavLink href="@("/hardware/tree")">Hardware</NavLink>
                     </div>
                     <div class="text-right">@_hardware!.TotalHardware</div>
 
-                    <div>
+                    <div class="hover:text-emerald-300">
                         <NavLink href="@("/systems/list")">Systems</NavLink>
                     </div>
                     <div class="text-right">@_system!.TotalSystems</div>
 
-                    <div>
+                    <div class="hover:text-emerald-300">
                         <NavLink href="@("/servers/list")">Services</NavLink>
                     </div>
                     <div class="text-right">@_service!.TotalServices</div>
@@ -62,9 +63,12 @@
                         <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
                             @foreach (var (kind, count) in _hardware.HardwareByKind.OrderByDescending(x => x.Value))
                             {
-                                <li class="text-zinc-500">
-                                    └─ @kind (@count)
-                                </li>
+                                var pluralKind = Resource.KindToPlural(kind);
+                                <NavLink href="@($"/{pluralKind}/list")" class="block">
+                                    <li class="text-zinc-500 hover:text-emerald-300">
+                                        └─ @pluralKind (@count)
+                                    </li>
+                                </NavLink>
                             }
                         </ul>
                     }

+ 68 - 0
RackPeek.Web/Components/Routers/AddRouterComponent.razor

@@ -0,0 +1,68 @@
+@using RackPeek.Domain.Resources.Hardware.Routers
+@inject AddRouterUseCase AddRouter
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="text-zinc-100 mb-3">
+        Add Router
+    </div>
+
+    <div class="flex gap-2">
+        <input
+            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+            placeholder="Router name"
+            @bind="_name"
+            @bind:event="oninput"/>
+
+        <button
+            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
+            disabled="@_isSubmitting"
+            @onclick="CreateAsync">
+            add
+        </button>
+    </div>
+
+    @if (_error is not null)
+    {
+        <div class="mt-2 text-sm text-red-400">
+            @_error
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public EventCallback<string> OnCreated { get; set; }
+
+    private string _name = string.Empty;
+    private string? _error;
+    private bool _isSubmitting;
+
+    private async Task CreateAsync()
+    {
+        _error = null;
+
+        if (string.IsNullOrWhiteSpace(_name))
+        {
+            _error = "name is required";
+            return;
+        }
+
+        try
+        {
+            _isSubmitting = true;
+            var name = _name.Trim();
+            await AddRouter.ExecuteAsync(name);
+            _name = string.Empty;
+
+            await OnCreated.InvokeAsync(name);
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+        finally
+        {
+            _isSubmitting = false;
+        }
+    }
+
+}

+ 277 - 0
RackPeek.Web/Components/Routers/RouterCardComponent.razor

@@ -0,0 +1,277 @@
+@inject UpdateRouterUseCase UpdateRouterUseCase
+@inject GetRouterUseCase GetRouterUseCase
+@inject AddRouterPortUseCase AddRouterPortUseCase
+@inject UpdateRouterPortUseCase UpdateRouterPortUseCase
+@inject RemoveRouterPortUseCase RemoveRouterPortUseCase
+@inject DeleteRouterUseCase DeleteUseCase
+
+@using RackPeek.Domain.Resources.Hardware.Routers
+@using RackPeek.Domain.Resources.Hardware.Routers.Ports
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Web.Components.Modals
+@using Router = RackPeek.Domain.Resources.Hardware.Models.Router
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="flex justify-between items-center mb-3">
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{Router.Name}")" class="block">
+                @Router.Name
+            </NavLink>
+            </div>
+
+        <div class="flex gap-3 text-xs">
+            @if (!_isEditing)
+            {
+                <button class="text-zinc-400 hover:text-zinc-200"
+                        @onclick="BeginEdit">
+                    Edit
+                </button>
+                <button
+                    class="text-xs text-red-400 hover:text-red-300 transition"
+                    title="Delete server"
+                    @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            }
+            else
+            {
+                <button class="text-emerald-400 hover:text-emerald-300"
+                        @onclick="Save">
+                    Save
+                </button>
+                <button class="text-zinc-500 hover:text-zinc-300"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+            }
+        </div>
+    </div>
+
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
+
+        <!-- Model -->
+        <div>
+            <div class="text-zinc-400 mb-1">Model</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.Model" />
+            }
+            else if (!string.IsNullOrWhiteSpace(Router.Model))
+            {
+                <div class="text-zinc-300">@Router.Model</div>
+            }
+        </div>
+
+        <!-- Features -->
+        <div>
+            <div class="text-zinc-400 mb-1">Features</div>
+
+            @if (_isEditing)
+            {
+                <div class="flex gap-4">
+                    <label class="flex items-center gap-2 text-zinc-300">
+                        <input type="checkbox" @bind="_edit.Managed" />
+                        Managed
+                    </label>
+
+                    <label class="flex items-center gap-2 text-zinc-300">
+                        <input type="checkbox" @bind="_edit.Poe" />
+                        PoE
+                    </label>
+                </div>
+            }
+            else
+            {
+                <div class="flex gap-2 flex-wrap">
+                    @if (Router.Managed == true)
+                    {
+                        <span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-300">
+                            Managed
+                        </span>
+                    }
+                    @if (Router.Poe == true)
+                    {
+                        <span class="text-xs px-2 py-0.5 rounded bg-zinc-800 text-zinc-300">
+                            PoE
+                        </span>
+                    }
+                </div>
+            }
+        </div>
+
+        <!-- Ports -->
+        <div class="md:col-span-2">
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    Ports
+                    <button class="hover:text-emerald-400 ml-1"
+                            title="Add Port"
+                            @onclick="OpenAddPort">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (Router.Ports?.Any() == true)
+            {
+                @foreach (var port in Router.Ports)
+                {
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button class="hover:text-emerald-400"
+                                title="Edit Port"
+                                @onclick="() => OpenEditPort(port)">
+                            @port.Count× @port.Type — @port.Speed Gbps
+                        </button>
+                    </div>
+                }
+            }
+        </div>
+    </div>
+</div>
+
+<PortModal
+    IsOpen="@_portModalOpen"
+    IsOpenChanged="v => _portModalOpen = v"
+    Value="@_editingPort"
+    OnSubmit="HandlePortSubmit"
+    OnDelete="HandlePortDelete" />
+
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@Router.Name</strong>?
+</ConfirmModal>
+
+@code {
+    [Parameter, EditorRequired]
+    public Router Router { get; set; } = default!;
+
+    bool _isEditing;
+    RouterEditModel _edit = new();
+
+    void BeginEdit()
+    {
+        _edit = RouterEditModel.From(Router);
+        _isEditing = true;
+    }
+
+    async Task Save()
+    {
+        _isEditing = false;
+
+        await UpdateRouterUseCase.ExecuteAsync(
+            Router.Name,
+            _edit.Model,
+            _edit.Managed,
+            _edit.Poe);
+
+        Router = await GetRouterUseCase.ExecuteAsync(Router.Name);
+    }
+
+    void Cancel()
+    {
+        _isEditing = false;
+    }
+
+    #region Ports
+
+    bool _portModalOpen;
+    int _editingPortIndex;
+    Port? _editingPort;
+
+    void OpenAddPort()
+    {
+        _editingPortIndex = -1;
+        _editingPort = null;
+        _portModalOpen = true;
+    }
+
+    void OpenEditPort(Port port)
+    {
+        Router.Ports ??= new();
+        _editingPortIndex = Router.Ports.IndexOf(port);
+        _editingPort = port;
+        _portModalOpen = true;
+    }
+
+    async Task HandlePortSubmit(Port port)
+    {
+        if (_editingPortIndex < 0)
+        {
+            await AddRouterPortUseCase.ExecuteAsync(
+                Router.Name,
+                port.Type,
+                port.Speed,
+                port.Count);
+        }
+        else
+        {
+            await UpdateRouterPortUseCase.ExecuteAsync(
+                Router.Name,
+                _editingPortIndex,
+                port.Type,
+                port.Speed,
+                port.Count);
+        }
+
+        Router = await GetRouterUseCase.ExecuteAsync(Router.Name);
+        StateHasChanged();
+    }
+
+    async Task HandlePortDelete(Port _)
+    {
+        await RemoveRouterPortUseCase.ExecuteAsync(
+            Router.Name,
+            _editingPortIndex);
+
+        Router = await GetRouterUseCase.ExecuteAsync(Router.Name);
+        StateHasChanged();
+    }
+
+    #endregion
+    public class RouterEditModel
+    {
+        public string? Model { get; set; }
+        public bool? Managed { get; set; }
+        public bool? Poe { get; set; }
+
+        public static RouterEditModel From(Router Router)
+        {
+            return new RouterEditModel
+            {
+                Model = Router.Model,
+                Managed = Router.Managed,
+                Poe = Router.Poe
+            };
+        }
+    }
+    
+
+}
+
+@code {
+    private bool _confirmDeleteOpen;
+    [Parameter]
+    public EventCallback<string> OnDeleted { get; set; }
+    
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteServer()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteUseCase.ExecuteAsync(Router.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(Router.Name);
+    }
+}
+

+ 50 - 0
RackPeek.Web/Components/Routers/RouterListComponent.razor

@@ -0,0 +1,50 @@
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.Routers
+@using Router = RackPeek.Domain.Resources.Hardware.Models.Router
+@inject GetRoutersUseCase GetRouters
+@inject NavigationManager Nav
+
+<PageTitle>Routers</PageTitle>
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+    <AddRouterComponent OnCreated="NavigateToNewResource"/>
+
+    
+    @if (_Routeres is null)
+    {
+        <div class="text-zinc-500">loading Routers…</div>
+    }
+    else if (_Routeres.Count == 0)
+    {
+        <div class="text-zinc-500">no Routers found</div>
+    }
+    else
+    {
+        <div class="space-y-4">
+            @foreach (var _Router in _Routeres.OrderBy(s => s.Name))
+            {
+                    <RouterCardComponent Router="_Router" OnDeleted="Callback"/>
+            }
+        </div>
+    }
+</div>
+
+@code {
+    private IReadOnlyList<Router>? _Routeres;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _Routeres = await GetRouters.ExecuteAsync();
+    }
+    
+    private Task NavigateToNewResource(string serverName)
+    {
+        Nav.NavigateTo($"/resources/hardware/{serverName}");
+        return Task.CompletedTask;
+    }
+    private async Task Callback(string obj)
+    {
+        _Routeres = await GetRouters.ExecuteAsync();
+    }
+
+}

+ 9 - 0
RackPeek.Web/Components/Routers/RouterListPage.razor

@@ -0,0 +1,9 @@
+@page "/routers/list"
+
+<PageTitle>Routers</PageTitle>
+
+<h1 class="text-lg text-zinc-100">
+    Routers
+</h1>
+
+<RouterListComponent/>

+ 7 - 5
RackPeek.Web/Components/Servers/ServerCardComponent.razor

@@ -28,11 +28,13 @@
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
-        <NavLink href="@($"/resources/hardware/{Server.Name}")" class="block">
-            <div class="text-zinc-100">
-                @Server.Name
-            </div>
-        </NavLink>
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{Server.Name}")" class="block">
+
+            @Server.Name
+            </NavLink>
+
+        </div>
 
         <div class="flex justify-between items-center mb-3">
    

+ 11 - 1
RackPeek.Web/Components/Services/ServiceCardComponent.razor

@@ -10,7 +10,7 @@
     <div class="flex justify-between items-center mb-3">
         <NavLink href="@($"/resources/services/{Service.Name}")" class="block">
 
-        <div class="text-zinc-100">
+        <div class="text-zinc-100 hover:text-emerald-300">
             @Service.Name
         </div>
         </NavLink>
@@ -148,6 +148,16 @@
     Value="@SelectedParentName"
     OnAccept="HandleParentSelected" />
 
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@Service.Name</strong>?
+</ConfirmModal>
+
 @code {
     [Parameter] [EditorRequired] public Service Service { get; set; } = default!;
 

+ 68 - 0
RackPeek.Web/Components/Switches/AddSwitchComponent.razor

@@ -0,0 +1,68 @@
+@using RackPeek.Domain.Resources.Hardware.Switches
+@inject AddSwitchUseCase AddSwitch
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="text-zinc-100 mb-3">
+        Add Switch
+    </div>
+
+    <div class="flex gap-2">
+        <input
+            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+            placeholder="Switch name"
+            @bind="_name"
+            @bind:event="oninput"/>
+
+        <button
+            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
+            disabled="@_isSubmitting"
+            @onclick="CreateAsync">
+            add
+        </button>
+    </div>
+
+    @if (_error is not null)
+    {
+        <div class="mt-2 text-sm text-red-400">
+            @_error
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public EventCallback<string> OnCreated { get; set; }
+
+    private string _name = string.Empty;
+    private string? _error;
+    private bool _isSubmitting;
+
+    private async Task CreateAsync()
+    {
+        _error = null;
+
+        if (string.IsNullOrWhiteSpace(_name))
+        {
+            _error = "name is required";
+            return;
+        }
+
+        try
+        {
+            _isSubmitting = true;
+            var name = _name.Trim();
+            await AddSwitch.ExecuteAsync(name);
+            _name = string.Empty;
+
+            await OnCreated.InvokeAsync(name);
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+        finally
+        {
+            _isSubmitting = false;
+        }
+    }
+
+}

+ 244 - 25
RackPeek.Web/Components/Switches/SwitchCardComponent.razor

@@ -1,24 +1,88 @@
-@using RackPeek.Domain.Resources.Hardware.Models
+@inject UpdateSwitchUseCase UpdateSwitchUseCase
+@inject GetSwitchUseCase GetSwitchUseCase
+@inject AddSwitchPortUseCase AddSwitchPortUseCase
+@inject UpdateSwitchPortUseCase UpdateSwitchPortUseCase
+@inject RemoveSwitchPortUseCase RemoveSwitchPortUseCase
+@inject DeleteSwitchUseCase DeleteUseCase
+
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.Switches
+@using RackPeek.Domain.Resources.Hardware.Switches.Ports
+@using RackPeek.Web.Components.Modals
+
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
-        <div class="text-zinc-100">
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{Switch.Name}")" class="block">
+
             @Switch.Name
-        </div>
+</NavLink>
+            </div>
 
-        @if (!string.IsNullOrWhiteSpace(Switch.Model))
-        {
-            <span class="text-xs text-zinc-400">
-                @Switch.Model
-            </span>
-        }
+        <div class="flex gap-3 text-xs">
+            @if (!_isEditing)
+            {
+                <button class="text-zinc-400 hover:text-zinc-200"
+                        @onclick="BeginEdit">
+                    Edit
+                </button>
+                <button
+                    class="text-xs text-red-400 hover:text-red-300 transition"
+                    title="Delete server"
+                    @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            }
+            else
+            {
+                <button class="text-emerald-400 hover:text-emerald-300"
+                        @onclick="Save">
+                    Save
+                </button>
+                <button class="text-zinc-500 hover:text-zinc-300"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+            }
+        </div>
     </div>
 
     <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
 
-        @if (Switch.Managed is not null || Switch.Poe is not null)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">Features</div>
+        <!-- Model -->
+        <div>
+            <div class="text-zinc-400 mb-1">Model</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.Model" />
+            }
+            else if (!string.IsNullOrWhiteSpace(Switch.Model))
+            {
+                <div class="text-zinc-300">@Switch.Model</div>
+            }
+        </div>
+
+        <!-- Features -->
+        <div>
+            <div class="text-zinc-400 mb-1">Features</div>
+
+            @if (_isEditing)
+            {
+                <div class="flex gap-4">
+                    <label class="flex items-center gap-2 text-zinc-300">
+                        <input type="checkbox" @bind="_edit.Managed" />
+                        Managed
+                    </label>
+
+                    <label class="flex items-center gap-2 text-zinc-300">
+                        <input type="checkbox" @bind="_edit.Poe" />
+                        PoE
+                    </label>
+                </div>
+            }
+            else
+            {
                 <div class="flex gap-2 flex-wrap">
                     @if (Switch.Managed == true)
                     {
@@ -33,25 +97,180 @@
                         </span>
                     }
                 </div>
+            }
+        </div>
+
+        <!-- Ports -->
+        <div class="md:col-span-2">
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    Ports
+                    <button class="hover:text-emerald-400 ml-1"
+                            title="Add Port"
+                            @onclick="OpenAddPort">
+                        +
+                    </button>
+                </div>
             </div>
-        }
 
-        @if (Switch.Ports?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">Ports</div>
+            @if (Switch.Ports?.Any() == true)
+            {
                 @foreach (var port in Switch.Ports)
                 {
-                    <div class="text-zinc-300">
-                        @port.Count× @port.Type — @port.Speed Gbps
+                    <div class="flex items-center justify-between text-zinc-300 group hover:bg-zinc-800/40 rounded px-1 py-0.5">
+                        <button class="hover:text-emerald-400"
+                                title="Edit Port"
+                                @onclick="() => OpenEditPort(port)">
+                            @port.Count× @port.Type — @port.Speed Gbps
+                        </button>
                     </div>
                 }
-            </div>
-        }
-
+            }
+        </div>
     </div>
 </div>
 
+<PortModal
+    IsOpen="@_portModalOpen"
+    IsOpenChanged="v => _portModalOpen = v"
+    Value="@_editingPort"
+    OnSubmit="HandlePortSubmit"
+    OnDelete="HandlePortDelete" />
+
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@Switch.Name</strong>?
+</ConfirmModal>
+
 @code {
-    [Parameter] [EditorRequired] public Switch Switch { get; set; } = default!;
-}
+    [Parameter, EditorRequired]
+    public Switch Switch { get; set; } = default!;
+
+    bool _isEditing;
+    SwitchEditModel _edit = new();
+
+    void BeginEdit()
+    {
+        _edit = SwitchEditModel.From(Switch);
+        _isEditing = true;
+    }
+
+    async Task Save()
+    {
+        _isEditing = false;
+
+        await UpdateSwitchUseCase.ExecuteAsync(
+            Switch.Name,
+            _edit.Model,
+            _edit.Managed,
+            _edit.Poe);
+
+        Switch = await GetSwitchUseCase.ExecuteAsync(Switch.Name);
+    }
+
+    void Cancel()
+    {
+        _isEditing = false;
+    }
+
+    #region Ports
+
+    bool _portModalOpen;
+    int _editingPortIndex;
+    Port? _editingPort;
+
+    void OpenAddPort()
+    {
+        _editingPortIndex = -1;
+        _editingPort = null;
+        _portModalOpen = true;
+    }
+
+    void OpenEditPort(Port port)
+    {
+        Switch.Ports ??= new();
+        _editingPortIndex = Switch.Ports.IndexOf(port);
+        _editingPort = port;
+        _portModalOpen = true;
+    }
+
+    async Task HandlePortSubmit(Port port)
+    {
+        if (_editingPortIndex < 0)
+        {
+            await AddSwitchPortUseCase.ExecuteAsync(
+                Switch.Name,
+                port.Type,
+                port.Speed,
+                port.Count);
+        }
+        else
+        {
+            await UpdateSwitchPortUseCase.ExecuteAsync(
+                Switch.Name,
+                _editingPortIndex,
+                port.Type,
+                port.Speed,
+                port.Count);
+        }
+
+        Switch = await GetSwitchUseCase.ExecuteAsync(Switch.Name);
+        StateHasChanged();
+    }
+
+    async Task HandlePortDelete(Port _)
+    {
+        await RemoveSwitchPortUseCase.ExecuteAsync(
+            Switch.Name,
+            _editingPortIndex);
+
+        Switch = await GetSwitchUseCase.ExecuteAsync(Switch.Name);
+        StateHasChanged();
+    }
+
+    #endregion
+    public class SwitchEditModel
+    {
+        public string? Model { get; set; }
+        public bool? Managed { get; set; }
+        public bool? Poe { get; set; }
+
+        public static SwitchEditModel From(Switch Switch)
+        {
+            return new SwitchEditModel
+            {
+                Model = Switch.Model,
+                Managed = Switch.Managed,
+                Poe = Switch.Poe
+            };
+        }
+    }
+    
+
+}
+
+@code {
+    private bool _confirmDeleteOpen;
+    [Parameter]
+    public EventCallback<string> OnDeleted { get; set; }
+    
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteServer()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteUseCase.ExecuteAsync(Switch.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(Switch.Name);
+    }
+}

+ 17 - 5
RackPeek.Web/Components/Switches/SwitchListComponent.razor

@@ -1,10 +1,15 @@
 @using RackPeek.Domain.Resources.Hardware.Models
 @using RackPeek.Domain.Resources.Hardware.Switches
 @inject GetSwitchesUseCase GetSwitches
+@inject NavigationManager Nav
 
 <PageTitle>Switches</PageTitle>
 
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+    
+    <AddSwitchComponent OnCreated="NavigateToNewResource"/>
+
+    
     @if (_Switches is null)
     {
         <div class="text-zinc-500">loading Switches…</div>
@@ -18,9 +23,7 @@
         <div class="space-y-4">
             @foreach (var _switch in _Switches.OrderBy(s => s.Name))
             {
-                <NavLink href="@($"/resources/hardware/{_switch.Name}")" class="block">
-                    <SwitchCardComponent Switch="_switch"/>
-                </NavLink>
+                    <SwitchCardComponent Switch="_switch" OnDeleted="Callback"/>
             }
         </div>
     }
@@ -33,5 +36,14 @@
     {
         _Switches = await GetSwitches.ExecuteAsync();
     }
-
+    
+    private Task NavigateToNewResource(string serverName)
+    {
+        Nav.NavigateTo($"/resources/hardware/{serverName}");
+        return Task.CompletedTask;
+    }
+    private async Task Callback(string obj)
+    {
+        _Switches = await GetSwitches.ExecuteAsync();
+    }
 }

+ 2 - 1
RackPeek.Web/Components/Systems/SystemCardComponent.razor

@@ -14,7 +14,7 @@
     <div class="flex justify-between items-center mb-3">
         <NavLink href="@($"/resources/systems/{System.Name}")" class="block">
 
-        <div class="text-zinc-100">
+        <div class="text-zinc-100 hover:text-emerald-300">
             @System.Name
         </div>
         </NavLink>
@@ -197,6 +197,7 @@
     <br />
     This will detach all dependent systems.
 </ConfirmModal>
+
 @code {
     [Parameter] [EditorRequired] public SystemResource System { get; set; } = default!;
 

+ 68 - 0
RackPeek.Web/Components/Ups/AddUpsComponent.razor

@@ -0,0 +1,68 @@
+@using RackPeek.Domain.Resources.Hardware.UpsUnits
+@inject AddUpsUseCase AddUps
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="text-zinc-100 mb-3">
+        Add Ups
+    </div>
+
+    <div class="flex gap-2">
+        <input
+            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
+            placeholder="Ups name"
+            @bind="_name"
+            @bind:event="oninput"/>
+
+        <button
+            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
+            disabled="@_isSubmitting"
+            @onclick="CreateAsync">
+            add
+        </button>
+    </div>
+
+    @if (_error is not null)
+    {
+        <div class="mt-2 text-sm text-red-400">
+            @_error
+        </div>
+    }
+</div>
+
+@code {
+    [Parameter] public EventCallback<string> OnCreated { get; set; }
+
+    private string _name = string.Empty;
+    private string? _error;
+    private bool _isSubmitting;
+
+    private async Task CreateAsync()
+    {
+        _error = null;
+
+        if (string.IsNullOrWhiteSpace(_name))
+        {
+            _error = "name is required";
+            return;
+        }
+
+        try
+        {
+            _isSubmitting = true;
+            var name = _name.Trim();
+            await AddUps.ExecuteAsync(name);
+            _name = string.Empty;
+
+            await OnCreated.InvokeAsync(name);
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+        finally
+        {
+            _isSubmitting = false;
+        }
+    }
+
+}

+ 85 - 0
RackPeek.Web/Components/Ups/UpsCardComponent.razor

@@ -0,0 +1,85 @@
+@inject DeleteUpsUseCase DeleteUseCase
+
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.UpsUnits
+@using RackPeek.Web.Components.Modals
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
+    <div class="flex justify-between items-center mb-3">
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"/resources/hardware/{Ups.Name}")" class="block">
+
+            @Ups.Name
+</NavLink>
+            </div>
+
+        <div class="flex justify-between items-center mb-3">
+            @if (!string.IsNullOrWhiteSpace(Ups.Model))
+            {
+                <span class="text-xs text-zinc-400">
+                    @Ups.Model
+                </span>
+            }
+            <div class="flex items-center gap-2">
+                
+                <button
+                    class="text-xs text-red-400 hover:text-red-300 transition"
+                    title="Delete server"
+                    @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            </div>
+        </div>
+
+        
+
+    </div>
+
+    <div class="text-sm">
+
+        @if (Ups.Va is not null)
+        {
+            <div>
+                <div class="text-zinc-400 mb-1">Speed</div>
+                <div class="text-zinc-300">
+                    @Ups.Va VA
+                </div>
+            </div>
+        }
+
+    </div>
+</div>
+
+<ConfirmModal
+    IsOpen="_confirmDeleteOpen"
+    IsOpenChanged="v => _confirmDeleteOpen = v"
+    Title="Delete server"
+    ConfirmText="Delete"
+    ConfirmClass="bg-red-600 hover:bg-red-500"
+    OnConfirm="DeleteServer">
+    Are you sure you want to delete <strong>@Ups.Name</strong>?
+</ConfirmModal>
+
+@code {
+    [Parameter] [EditorRequired] public Ups Ups { get; set; } = default!;
+}
+
+@code {
+    private bool _confirmDeleteOpen;
+    [Parameter]
+    public EventCallback<string> OnDeleted { get; set; }
+    
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteServer()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteUseCase.ExecuteAsync(Ups.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(Ups.Name);
+    }
+}

+ 48 - 0
RackPeek.Web/Components/Ups/UpsListComponent.razor

@@ -0,0 +1,48 @@
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.UpsUnits
+@inject GetUpsUseCase GetUpss
+@inject NavigationManager Nav
+
+<PageTitle>Ups</PageTitle>
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+    
+    <AddUpsComponent OnCreated="NavigateToNewResource"/>
+
+    @if (_Upss is null)
+    {
+        <div class="text-zinc-500">loading Ups…</div>
+    }
+    else if (_Upss.Count == 0)
+    {
+        <div class="text-zinc-500">no Ups found</div>
+    }
+    else
+    {
+        <div class="space-y-4">
+            @foreach (var Ups in _Upss.OrderBy(s => s.Name))
+            {
+                    <UpsCardComponent Ups="Ups" OnDeleted="Callback"/>
+            }
+        </div>
+    }
+</div>
+
+@code {
+    private IReadOnlyList<Ups>? _Upss;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _Upss = await GetUpss.ExecuteAsync();
+    }
+    
+    private Task NavigateToNewResource(string serverName)
+    {
+        Nav.NavigateTo($"/resources/hardware/{serverName}");
+        return Task.CompletedTask;
+    }
+    private async Task Callback(string obj)
+    {
+        _Upss = await GetUpss.ExecuteAsync();
+    }
+}

+ 9 - 0
RackPeek.Web/Components/Ups/UpsListPage.razor

@@ -0,0 +1,9 @@
+@page "/ups/list"
+
+<PageTitle>Ups</PageTitle>
+
+<h1 class="text-lg text-zinc-100">
+    Ups
+</h1>
+
+<UpsListComponent/>

+ 25 - 0
RackPeek.Web/Dockerfile

@@ -0,0 +1,25 @@
+FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base
+USER $APP_UID
+WORKDIR /app
+EXPOSE 8080
+EXPOSE 8081
+
+FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build
+ARG BUILD_CONFIGURATION=Release
+WORKDIR /src
+COPY ["RackPeek.Web/RackPeek.Web.csproj", "RackPeek.Web/"]
+COPY ["RackPeek.Domain/RackPeek.Domain.csproj", "RackPeek.Domain/"]
+COPY ["RackPeek/RackPeek.csproj", "RackPeek/"]
+RUN dotnet restore "RackPeek.Web/RackPeek.Web.csproj"
+COPY . .
+WORKDIR "/src/RackPeek.Web"
+RUN dotnet build "./RackPeek.Web.csproj" -c $BUILD_CONFIGURATION -o /app/build
+
+FROM build AS publish
+ARG BUILD_CONFIGURATION=Release
+RUN dotnet publish "./RackPeek.Web.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false
+
+FROM base AS final
+WORKDIR /app
+COPY --from=publish /app/publish .
+ENTRYPOINT ["dotnet", "RackPeek.Web.dll"]

+ 13 - 0
RackPeek.Web/RackPeek.Web.csproj

@@ -5,6 +5,7 @@
         <Nullable>enable</Nullable>
         <ImplicitUsings>enable</ImplicitUsings>
         <BlazorDisableThrowNavigationException>true</BlazorDisableThrowNavigationException>
+        <DockerDefaultTargetOS>Linux</DockerDefaultTargetOS>
     </PropertyGroup>
 
     <ItemGroup>
@@ -36,6 +37,18 @@
 
     <ItemGroup>
         <AdditionalFiles Include="Components\Components\HardwareDependencyTreeComponent.razor"/>
+        <AdditionalFiles Include="Components\Laptops\LaptopCardComponent.razor" />
+        <AdditionalFiles Include="Components\Laptops\LaptopsListComponent.razor" />
+        <AdditionalFiles Include="Components\Laptops\LaptopsListPage.razor" />
+        <AdditionalFiles Include="Components\Ups\UpCardComponent.razor" />
+        <AdditionalFiles Include="Components\Ups\UpsListComponent.razor" />
+        <AdditionalFiles Include="Components\Ups\UpsListPage.razor" />
+    </ItemGroup>
+
+    <ItemGroup>
+      <Content Include="..\.dockerignore">
+        <Link>.dockerignore</Link>
+      </Content>
     </ItemGroup>
 
 </Project>

+ 1 - 8
RackPeek/Commands/Desktops/Cpus/DesktopCpuSetCommand.cs

@@ -17,14 +17,7 @@ public class DesktopCpuSetCommand(IServiceProvider provider)
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateDesktopCpuUseCase>();
 
-        var cpu = new Cpu
-        {
-            Model = settings.Model,
-            Cores = settings.Cores,
-            Threads = settings.Threads
-        };
-
-        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, cpu);
+        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, settings.Model, settings.Cores, settings.Threads);
 
         AnsiConsole.MarkupLine($"[green]CPU #{settings.Index} updated on desktop '{settings.DesktopName}'.[/]");
         return 0;

+ 1 - 7
RackPeek/Commands/Desktops/Drive/DesktopDriveAddCommand.cs

@@ -16,13 +16,7 @@ public class DesktopDriveAddCommand(IServiceProvider provider)
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<AddDesktopDriveUseCase>();
 
-        var drive = new Domain.Resources.Hardware.Models.Drive
-        {
-            Type = settings.Type,
-            Size = settings.Size
-        };
-
-        await useCase.ExecuteAsync(settings.DesktopName, drive);
+        await useCase.ExecuteAsync(settings.DesktopName, settings.Type, settings.Size);
 
         AnsiConsole.MarkupLine($"[green]Drive added to desktop '{settings.DesktopName}'.[/]");
         return 0;

+ 2 - 8
RackPeek/Commands/Desktops/Drive/DesktopDriveSetCommand.cs

@@ -15,14 +15,8 @@ public class DesktopDriveSetCommand(IServiceProvider provider)
     {
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateDesktopDriveUseCase>();
-
-        var drive = new Domain.Resources.Hardware.Models.Drive
-        {
-            Type = settings.Type,
-            Size = settings.Size
-        };
-
-        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, drive);
+        
+        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, settings.Type, settings.Size);
 
         AnsiConsole.MarkupLine($"[green]Drive #{settings.Index} updated on desktop '{settings.DesktopName}'.[/]");
         return 0;

+ 2 - 8
RackPeek/Commands/Desktops/Gpus/DesktopGpuAddCommand.cs

@@ -16,14 +16,8 @@ public class DesktopGpuAddCommand(IServiceProvider provider)
     {
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<AddDesktopGpuUseCase>();
-
-        var gpu = new Gpu
-        {
-            Model = settings.Model,
-            Vram = settings.Vram
-        };
-
-        await useCase.ExecuteAsync(settings.DesktopName, gpu);
+        
+        await useCase.ExecuteAsync(settings.DesktopName, settings.Model, settings.Vram);
 
         AnsiConsole.MarkupLine($"[green]GPU added to desktop '{settings.DesktopName}'.[/]");
         return 0;

+ 1 - 7
RackPeek/Commands/Desktops/Gpus/DesktopGpuSetCommand.cs

@@ -17,13 +17,7 @@ public class DesktopGpuSetCommand(IServiceProvider provider)
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateDesktopGpuUseCase>();
 
-        var gpu = new Gpu
-        {
-            Model = settings.Model,
-            Vram = settings.Vram
-        };
-
-        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, gpu);
+        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, settings.Model, settings.Vram);
 
         AnsiConsole.MarkupLine($"[green]GPU #{settings.Index} updated on desktop '{settings.DesktopName}'.[/]");
         return 0;

+ 1 - 8
RackPeek/Commands/Desktops/Nics/DesktopNicAddCommand.cs

@@ -17,14 +17,7 @@ public class DesktopNicAddCommand(IServiceProvider provider)
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<AddDesktopNicUseCase>();
 
-        var nic = new Nic
-        {
-            Type = settings.Type,
-            Speed = settings.Speed,
-            Ports = settings.Ports
-        };
-
-        await useCase.ExecuteAsync(settings.DesktopName, nic);
+        await useCase.ExecuteAsync(settings.DesktopName, settings.Type, settings.Speed, settings.Ports);
 
         AnsiConsole.MarkupLine($"[green]NIC added to desktop '{settings.DesktopName}'.[/]");
         return 0;

+ 2 - 9
RackPeek/Commands/Desktops/Nics/DesktopNicSetCommand.cs

@@ -16,15 +16,8 @@ public class DesktopNicSetCommand(IServiceProvider provider)
     {
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateDesktopNicUseCase>();
-
-        var nic = new Nic
-        {
-            Type = settings.Type,
-            Speed = settings.Speed,
-            Ports = settings.Ports
-        };
-
-        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, nic);
+        
+        await useCase.ExecuteAsync(settings.DesktopName, settings.Index, settings.Type, settings.Speed, settings.Ports);
 
         AnsiConsole.MarkupLine($"[green]NIC #{settings.Index} updated on desktop '{settings.DesktopName}'.[/]");
         return 0;

+ 1 - 8
RackPeek/Commands/Laptops/Cpus/LaptopCpuSetCommand.cs

@@ -17,14 +17,7 @@ public class LaptopCpuSetCommand(IServiceProvider provider)
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateLaptopCpuUseCase>();
 
-        var cpu = new Cpu
-        {
-            Model = settings.Model,
-            Cores = settings.Cores,
-            Threads = settings.Threads
-        };
-
-        await useCase.ExecuteAsync(settings.LaptopName, settings.Index, cpu);
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Index, settings.Model, settings.Cores, settings.Threads);
 
         AnsiConsole.MarkupLine($"[green]CPU #{settings.Index} updated on Laptop '{settings.LaptopName}'.[/]");
         return 0;

+ 2 - 8
RackPeek/Commands/Laptops/Drive/LaptopDriveAddCommand.cs

@@ -15,14 +15,8 @@ public class LaptopDriveAddCommand(IServiceProvider provider)
     {
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<AddLaptopDriveUseCase>();
-
-        var drive = new Domain.Resources.Hardware.Models.Drive
-        {
-            Type = settings.Type,
-            Size = settings.Size
-        };
-
-        await useCase.ExecuteAsync(settings.LaptopName, drive);
+        
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Type, settings.Size);
 
         AnsiConsole.MarkupLine($"[green]Drive added to Laptop '{settings.LaptopName}'.[/]");
         return 0;

+ 2 - 8
RackPeek/Commands/Laptops/Drive/LaptopDriveSetCommand.cs

@@ -15,14 +15,8 @@ public class LaptopDriveSetCommand(IServiceProvider provider)
     {
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateLaptopDriveUseCase>();
-
-        var drive = new Domain.Resources.Hardware.Models.Drive
-        {
-            Type = settings.Type,
-            Size = settings.Size
-        };
-
-        await useCase.ExecuteAsync(settings.LaptopName, settings.Index, drive);
+        
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Index, settings.Type, settings.Size);
 
         AnsiConsole.MarkupLine($"[green]Drive #{settings.Index} updated on Laptop '{settings.LaptopName}'.[/]");
         return 0;

+ 1 - 7
RackPeek/Commands/Laptops/Gpus/LaptopGpuAddCommand.cs

@@ -17,13 +17,7 @@ public class LaptopGpuAddCommand(IServiceProvider provider)
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<AddLaptopGpuUseCase>();
 
-        var gpu = new Gpu
-        {
-            Model = settings.Model,
-            Vram = settings.Vram
-        };
-
-        await useCase.ExecuteAsync(settings.LaptopName, gpu);
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Model, settings.Vram);
 
         AnsiConsole.MarkupLine($"[green]GPU added to Laptop '{settings.LaptopName}'.[/]");
         return 0;

+ 1 - 1
RackPeek/Commands/Laptops/Gpus/LaptopGpuSetCommand.cs

@@ -23,7 +23,7 @@ public class LaptopGpuSetCommand(IServiceProvider provider)
             Vram = settings.Vram
         };
 
-        await useCase.ExecuteAsync(settings.LaptopName, settings.Index, gpu);
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Index, settings.Model, settings.Vram);
 
         AnsiConsole.MarkupLine($"[green]GPU #{settings.Index} updated on Laptop '{settings.LaptopName}'.[/]");
         return 0;

+ 2 - 1
RackPeek/Yaml/YamlResourceRepository.cs

@@ -2,7 +2,8 @@ using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Hardware.Models;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
-using RackPeek.Yaml;
+
+namespace RackPeek.Yaml;
 
 public class YamlResourceRepository(YamlResourceCollection resources) : IResourceRepository
 {