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

Merge pull request #68 from Timmoth/WebUI-Modals

Added System / Service modals
Tim Jones 1 месяц назад
Родитель
Сommit
a18168bbd8
52 измененных файлов с 1590 добавлено и 543 удалено
  1. 3 29
      RackPeek.Domain/Helpers/ThrowIfInvalid.cs
  2. 4 4
      RackPeek.Domain/Resources/Hardware/Laptops/Cpus/AddDesktopCpuUseCase.cs
  3. 3 0
      RackPeek.Domain/Resources/Hardware/Models/AccessPoint.cs
  4. 3 0
      RackPeek.Domain/Resources/Hardware/Models/Desktop.cs
  5. 3 0
      RackPeek.Domain/Resources/Hardware/Models/Firewall.cs
  6. 3 0
      RackPeek.Domain/Resources/Hardware/Models/Laptop.cs
  7. 27 0
      RackPeek.Domain/Resources/Hardware/Models/Nic.cs
  8. 3 0
      RackPeek.Domain/Resources/Hardware/Models/Router.cs
  9. 3 0
      RackPeek.Domain/Resources/Hardware/Models/Server.cs
  10. 3 0
      RackPeek.Domain/Resources/Hardware/Models/Switch.cs
  11. 3 0
      RackPeek.Domain/Resources/Hardware/Models/Ups.cs
  12. 2 2
      RackPeek.Domain/Resources/Hardware/Servers/Gpus/AddGpuUseCase.cs
  13. 2 2
      RackPeek.Domain/Resources/Hardware/Servers/Gpus/UpdateGpuUseCase.cs
  14. 12 5
      RackPeek.Domain/Resources/Hardware/Servers/Nics/AddNicUseCase.cs
  15. 11 5
      RackPeek.Domain/Resources/Hardware/Servers/Nics/UpdateNicUseCase.cs
  16. 3 0
      RackPeek.Domain/Resources/Services/Service.cs
  17. 11 2
      RackPeek.Domain/Resources/Services/UseCases/AddServiceUseCase.cs
  18. 2 0
      RackPeek.Domain/Resources/SystemResources/SystemResource.cs
  19. 16 3
      RackPeek.Domain/Resources/SystemResources/UseCases/AddSystemUseCase.cs
  20. 6 0
      RackPeek.Web/Components/Hardware/HardwareDetailsPage.razor
  21. 157 0
      RackPeek.Web/Components/Modals/GpuModal.razor
  22. 197 0
      RackPeek.Web/Components/Modals/HardwareSelectionModal.razor
  23. 180 0
      RackPeek.Web/Components/Modals/NicModal.razor
  24. 173 0
      RackPeek.Web/Components/Modals/PortModal.razor
  25. 194 0
      RackPeek.Web/Components/Modals/SystemSelectionModal.razor
  26. 180 15
      RackPeek.Web/Components/Servers/ServerCardComponent.razor
  27. 6 2
      RackPeek.Web/Components/Services/AddServiceComponent.razor
  28. 47 4
      RackPeek.Web/Components/Services/ServiceCardComponent.razor
  29. 1 1
      RackPeek.Web/Components/Services/ServicesListComponent.razor
  30. 6 1
      RackPeek.Web/Components/Systems/AddSystemComponent.razor
  31. 132 13
      RackPeek.Web/Components/Systems/SystemCardComponent.razor
  32. 6 0
      RackPeek.Web/Components/Systems/SystemsDetailsPage.razor
  33. 6 12
      RackPeek.Web/Program.cs
  34. 7 13
      RackPeek/CliBootstrap.cs
  35. 2 9
      RackPeek/Commands/Laptops/Cpus/LaptopCpuAddCommand.cs
  36. 1 1
      RackPeek/Program.cs
  37. 1 8
      RackPeek/Yaml/YamlHardwareRepository.cs
  38. 94 337
      RackPeek/Yaml/YamlResourceCollection.cs
  39. 14 35
      RackPeek/Yaml/YamlResourceRepository.cs
  40. 1 8
      RackPeek/Yaml/YamlServiceRepository.cs
  41. 1 8
      RackPeek/Yaml/YamlSystemRepository.cs
  42. 2 1
      Tests/EndToEnd/AccessPointE2ETests.cs
  43. 2 1
      Tests/EndToEnd/DesktopYamlE2ETest.cs
  44. 3 2
      Tests/EndToEnd/Infra/YamlCliTestHost.cs
  45. 2 1
      Tests/EndToEnd/ServerYamlE2ETests.cs
  46. 10 10
      Tests/EndToEnd/ServiceYamlE2ETests.cs
  47. 2 1
      Tests/EndToEnd/SwitchYamlE2ETests.cs
  48. 2 1
      Tests/EndToEnd/SystemYamlE2ETests.cs
  49. 2 1
      Tests/EndToEnd/UpsYamlE2ETests.cs
  50. 12 2
      Tests/Yaml/HardwareDeserializationTests.cs
  51. 12 2
      Tests/Yaml/ServiceDeserializationTests.cs
  52. 12 2
      Tests/Yaml/SystemDeserializationTests.cs

+ 3 - 29
RackPeek.Domain/Helpers/ThrowIfInvalid.cs

@@ -30,40 +30,14 @@ public static class ThrowIfInvalid
 
     #region Nics
 
-    public static readonly string[] ValidNicTypes =
-    {
-        // Copper Ethernet
-        "rj45",
-
-        // SFP family
-        "sfp", // 1G
-        "sfp+", // 10G
-        "sfp28", // 25G
-        "sfp56", // 50G
-
-        // QSFP family
-        "qsfp+", // 40G
-        "qsfp28", // 100G
-        "qsfp56", // 200G
-        "qsfp-dd", // 400G (QSFP Double Density)
-
-        // OSFP (400G+)
-        "osfp",
-
-        // Legacy / niche but still seen
-        "xfp", "cx4",
-
-        // Management / special-purpose
-        "mgmt" // Dedicated management NIC (IPMI/BMC)
-    };
-
+    
     public static void NicType(string nicType)
     {
         if (string.IsNullOrWhiteSpace(nicType)) throw new ValidationException("NIC type is required.");
 
         var normalized = nicType.Trim().ToLowerInvariant();
 
-        if (ValidNicTypes.Contains(normalized)) return;
+        if (Nic.ValidNicTypes.Contains(normalized)) return;
 
         var suggestions = GetNicTypeSuggestions(normalized).ToList();
 
@@ -76,7 +50,7 @@ public static class ThrowIfInvalid
 
     private static IEnumerable<string> GetNicTypeSuggestions(string input)
     {
-        return ValidNicTypes.Select(type => new { Type = type, Score = SimilarityScore(input, type) })
+        return Nic.ValidNicTypes.Select(type => new { Type = type, Score = SimilarityScore(input, type) })
             .Where(x => x.Score >= 0.5)
             .OrderByDescending(x => x.Score)
             .Take(3)

+ 4 - 4
RackPeek.Domain/Resources/Hardware/Laptops/Cpus/AddDesktopCpuUseCase.cs

@@ -5,18 +5,18 @@ namespace RackPeek.Domain.Resources.Hardware.Laptops.Cpus;
 
 public class AddLaptopCpuUseCase(IHardwareRepository repository) : IUseCase
 {
-    public async Task ExecuteAsync(string name, Cpu cpu)
+    public async Task ExecuteAsync(string name, 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
         
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
+        
         var laptop = await repository.GetByNameAsync(name) as Laptop
-                     ?? throw new InvalidOperationException($"Laptop '{name}' not found.");
+                     ?? throw new NotFoundException($"Laptop '{name}' not found.");
 
         laptop.Cpus ??= new List<Cpu>();
-        laptop.Cpus.Add(cpu);
+        laptop.Cpus.Add(new Cpu { Model = model, Cores = cores, Threads = threads });
 
         await repository.UpdateAsync(laptop);
     }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/AccessPoint.cs

@@ -4,4 +4,7 @@ public class AccessPoint : Hardware
 {
     public string? Model { get; set; }
     public double? Speed { get; set; }
+    
+    public const string KindLabel = "AccessPoint";
+
 }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/Desktop.cs

@@ -8,4 +8,7 @@ public class Desktop : Hardware
     public List<Nic>? Nics { get; set; }
     public List<Gpu>? Gpus { get; set; }
     public string Model { get; set; }
+    
+    public const string KindLabel = "Desktop";
+
 }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/Firewall.cs

@@ -6,4 +6,7 @@ public class Firewall : Hardware
     public bool? Managed { get; set; }
     public bool? Poe { get; set; }
     public List<Port>? Ports { get; set; }
+    
+    public const string KindLabel = "Firewall";
+
 }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/Laptop.cs

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

+ 27 - 0
RackPeek.Domain/Resources/Hardware/Models/Nic.cs

@@ -2,6 +2,33 @@ namespace RackPeek.Domain.Resources.Hardware.Models;
 
 public class Nic
 {
+    public static readonly string[] ValidNicTypes =
+    {
+        // Copper Ethernet
+        "rj45",
+
+        // SFP family
+        "sfp", // 1G
+        "sfp+", // 10G
+        "sfp28", // 25G
+        "sfp56", // 50G
+
+        // QSFP family
+        "qsfp+", // 40G
+        "qsfp28", // 100G
+        "qsfp56", // 200G
+        "qsfp-dd", // 400G (QSFP Double Density)
+
+        // OSFP (400G+)
+        "osfp",
+
+        // Legacy / niche but still seen
+        "xfp", "cx4",
+
+        // Management / special-purpose
+        "mgmt" // Dedicated management NIC (IPMI/BMC)
+    };
+
     public string? Type { get; set; }
     public int? Speed { get; set; }
     public int? Ports { get; set; }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/Router.cs

@@ -6,4 +6,7 @@ public class Router : Hardware
     public bool? Managed { get; set; }
     public bool? Poe { get; set; }
     public List<Port>? Ports { get; set; }
+    
+    public const string KindLabel = "Router";
+
 }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/Server.cs

@@ -8,4 +8,7 @@ public class Server : Hardware
     public List<Nic>? Nics { get; set; }
     public List<Gpu>? Gpus { get; set; }
     public bool? Ipmi { get; set; }
+    
+    public const string KindLabel = "Server";
+
 }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/Switch.cs

@@ -6,4 +6,7 @@ public class Switch : Hardware
     public bool? Managed { get; set; }
     public bool? Poe { get; set; }
     public List<Port>? Ports { get; set; }
+    
+    public const string KindLabel = "Switch";
+
 }

+ 3 - 0
RackPeek.Domain/Resources/Hardware/Models/Ups.cs

@@ -4,4 +4,7 @@ public class Ups : Hardware
 {
     public string? Model { get; set; }
     public int? Va { get; set; }
+    
+    public const string KindLabel = "Ups";
+
 }

+ 2 - 2
RackPeek.Domain/Resources/Hardware/Servers/Gpus/AddGpuUseCase.cs

@@ -7,8 +7,8 @@ public class AddGpuUseCase(IHardwareRepository repository) : IUseCase
 {
     public async Task ExecuteAsync(
         string name,
-        string model,
-        int vram)
+        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

+ 2 - 2
RackPeek.Domain/Resources/Hardware/Servers/Gpus/UpdateGpuUseCase.cs

@@ -8,8 +8,8 @@ public class UpdateGpuUseCase(IHardwareRepository repository) : IUseCase
     public async Task ExecuteAsync(
         string name,
         int index,
-        string model,
-        int vram)
+        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

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

@@ -7,17 +7,24 @@ public class AddNicUseCase(IHardwareRepository repository) : IUseCase
 {
     public async Task ExecuteAsync(
         string name,
-        string type,
-        int speed,
-        int ports)
+        string? type,
+        int? 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);
-        ThrowIfInvalid.NicSpeed(speed);
-        ThrowIfInvalid.NicPorts(ports);
+        if (speed.HasValue)
+        {
+            ThrowIfInvalid.NicSpeed(speed.Value);
+        }
+        if (ports.HasValue)
+        {
+            ThrowIfInvalid.NicPorts(ports.Value);
+        }
+
 
         var nicType = Normalize.NicType(type);
         ThrowIfInvalid.NicType(nicType);

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

@@ -9,17 +9,23 @@ public class UpdateNicUseCase(IHardwareRepository repository) : IUseCase
     public async Task ExecuteAsync(
         string name,
         int index,
-        string type,
-        int speed,
-        int ports)
+        string? type,
+        int? 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);
-        ThrowIfInvalid.NicSpeed(speed);
-        ThrowIfInvalid.NicPorts(ports);
+        if (speed.HasValue)
+        {
+            ThrowIfInvalid.NicSpeed(speed.Value);
+        }
+        if (ports.HasValue)
+        {
+            ThrowIfInvalid.NicPorts(ports.Value);
+        }
 
         var nicType = Normalize.NicType(type);
         ThrowIfInvalid.NicType(nicType);

+ 3 - 0
RackPeek.Domain/Resources/Services/Service.cs

@@ -6,6 +6,7 @@ public class Service : Resource
 {
     public Network? Network { get; set; }
     public string? RunsOn { get; set; }
+    public const string KindLabel = "Service";
 
     public string NetworkString()
     {
@@ -37,4 +38,6 @@ public class Network
     public int? Port { get; set; }
     public string? Protocol { get; set; }
     public string? Url { get; set; }
+    
+    
 }

+ 11 - 2
RackPeek.Domain/Resources/Services/UseCases/AddServiceUseCase.cs

@@ -4,7 +4,7 @@ namespace RackPeek.Domain.Resources.Services.UseCases;
 
 public class AddServiceUseCase(IServiceRepository repository, IResourceRepository resourceRepo) : IUseCase
 {
-    public async Task ExecuteAsync(string name)
+    public async Task ExecuteAsync(string name, string? runsOn = null)
     {
         name = Normalize.ServiceName(name);
         ThrowIfInvalid.ResourceName(name);
@@ -13,9 +13,18 @@ public class AddServiceUseCase(IServiceRepository repository, IResourceRepositor
         if (!string.IsNullOrEmpty(existingResourceKind))
             throw new ConflictException($"{existingResourceKind} resource '{name}' already exists.");
 
+        if (!string.IsNullOrEmpty(runsOn))
+        {
+            runsOn = Normalize.SystemName(runsOn);
+            var parentResourceKind = await resourceRepo.GetResourceKindAsync(runsOn);
+            if (string.IsNullOrEmpty(parentResourceKind))
+                throw new ConflictException($"Parent resource '{runsOn}' does not exist.");
+        }
+        
         var service = new Service
         {
-            Name = name
+            Name = name,
+            RunsOn = runsOn,
         };
 
         await repository.AddAsync(service);

+ 2 - 0
RackPeek.Domain/Resources/SystemResources/SystemResource.cs

@@ -11,4 +11,6 @@ public class SystemResource : Resource
     public List<Drive>? Drives { get; set; }
 
     public string? RunsOn { get; set; }
+
+    public const string KindLabel = "System";
 }

+ 16 - 3
RackPeek.Domain/Resources/SystemResources/UseCases/AddSystemUseCase.cs

@@ -1,21 +1,34 @@
 using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources.Services;
 
 namespace RackPeek.Domain.Resources.SystemResources.UseCases;
 
 public class AddSystemUseCase(ISystemRepository repository, IResourceRepository resourceRepo) : IUseCase
 {
-    public async Task ExecuteAsync(string name)
+    public async Task ExecuteAsync(string name, string? runsOn = null)
     {
         name = Normalize.SystemName(name);
         ThrowIfInvalid.ResourceName(name);
-
+        
         var existingResourceKind = await resourceRepo.GetResourceKindAsync(name);
         if (!string.IsNullOrEmpty(existingResourceKind))
             throw new ConflictException($"{existingResourceKind} resource '{name}' already exists.");
 
+        if (!string.IsNullOrEmpty(runsOn))
+        {
+            runsOn = Normalize.HardwareName(runsOn);
+            var parentResourceKind = await resourceRepo.GetResourceKindAsync(runsOn);
+            if (string.IsNullOrEmpty(parentResourceKind))
+                throw new NotFoundException($"Parent resource '{runsOn}' does not exist.");
+            
+            if (parentResourceKind is Service.KindLabel or SystemResource.KindLabel)
+                throw new NotFoundException($"Parent resource '{runsOn}' must be hardware.");
+        }
+        
         var system = new SystemResource
         {
-            Name = name
+            Name = name,
+            RunsOn = runsOn
         };
 
         await repository.AddAsync(system);

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

@@ -6,8 +6,10 @@
 @using RackPeek.Web.Components.Desktops
 @using RackPeek.Web.Components.Switches
 @using RackPeek.Web.Components.Servers
+@using RackPeek.Web.Components.Systems
 @inject IHardwareRepository HardwareRepository
 @inject GetHardwareSystemTreeUseCase GetHardwareSystemTreeUseCase
+
 <PageTitle>Hardware Details</PageTitle>
 
 <ResourceBreadCrumbComponent
@@ -62,6 +64,10 @@
                 No child systems / services
             </div>
         }
+        
+        <div class="m-4">
+            <AddSystemComponent RunsOn="@HardwareName"/>
+        </div>
     }
 </div>
 

+ 157 - 0
RackPeek.Web/Components/Modals/GpuModal.razor

@@ -0,0 +1,157 @@
+@using System.ComponentModel.DataAnnotations
+@using RackPeek.Domain.Resources.Hardware.Models
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center">
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70" @onclick="Cancel"></div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-md p-4">
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-4">
+                <div class="text-zinc-100 text-sm font-medium">
+                    @(IsEdit ? "Modify GPU" : "Add GPU")
+                </div>
+
+                <button
+                    class="text-zinc-400 hover:text-zinc-200"
+                    @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            <!-- Form -->
+            <EditForm Model="_model" OnValidSubmit="HandleValidSubmit">
+                <DataAnnotationsValidator />
+                <ValidationSummary class="text-xs text-red-400 mb-3" />
+
+                <div class="space-y-3 text-sm">
+                    <!-- Model -->
+                    <div>
+                        <label class="block text-zinc-400 mb-1">
+                            Model
+                        </label>
+
+                        <InputText
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Model" />
+                    </div>
+
+                    <!-- VRAM -->
+                    <div>
+                        <label class="block text-zinc-400 mb-1">
+                            VRAM (GB)
+                        </label>
+
+                        <InputNumber
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Vram" />
+                    </div>
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-between items-center mt-5">
+                    @if (IsEdit)
+                    {
+                        <button
+                            type="button"
+                            class="text-red-400 hover:text-red-300 text-sm"
+                            @onclick="HandleDelete">
+                            Delete GPU
+                        </button>
+                    }
+                    else
+                    {
+                        <span></span>
+                    }
+
+                    <div class="flex gap-2">
+                        <button
+                            type="button"
+                            class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                            @onclick="Cancel">
+                            Cancel
+                        </button>
+
+                        <button
+                            type="submit"
+                            class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500">
+                            @(IsEdit ? "Update" : "Add")
+                        </button>
+                    </div>
+                </div>
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter] public Gpu? Value { get; set; }
+
+    [Parameter] public EventCallback<Gpu> OnSubmit { get; set; }
+    [Parameter] public EventCallback<Gpu> OnDelete { get; set; }
+
+    private GpuFormModel _model = new();
+
+    private bool IsEdit => Value is not null;
+
+    protected override void OnParametersSet()
+    {
+        if (IsOpen)
+        {
+            _model = Value is null
+                ? new GpuFormModel()
+                : new GpuFormModel
+                {
+                    Model = Value.Model,
+                    Vram = Value.Vram
+                };
+        }
+    }
+
+    private async Task HandleValidSubmit()
+    {
+        var gpu = new Gpu
+        {
+            Model = _model.Model,
+            Vram = _model.Vram
+        };
+
+        await OnSubmit.InvokeAsync(gpu);
+        await Close();
+    }
+
+    private async Task HandleDelete()
+    {
+        if (Value is not null)
+        {
+            await OnDelete.InvokeAsync(Value);
+            await Close();
+        }
+    }
+
+    private async Task Cancel()
+    {
+        await Close();
+    }
+
+    private async Task Close()
+    {
+        _model = new();
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    private class GpuFormModel
+    {
+        [Required]
+        public string? Model { get; set; }
+
+        [Range(1, 256)]
+        public int? Vram { get; set; }
+    }
+}

+ 197 - 0
RackPeek.Web/Components/Modals/HardwareSelectionModal.razor

@@ -0,0 +1,197 @@
+@using System.ComponentModel.DataAnnotations
+@using RackPeek.Domain.Resources.Hardware
+@using RackPeek.Domain.Resources.Hardware.Models
+@inject IHardwareRepository HardwareRepository
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center">
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70" @onclick="Cancel"></div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-lg p-4">
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-4">
+                <div class="text-zinc-100 text-sm font-medium">
+                    @Title
+                </div>
+
+                <button
+                    class="text-zinc-400 hover:text-zinc-200"
+                    @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            <!-- Form -->
+            <EditForm Model="_model" OnValidSubmit="HandleAccept">
+                <DataAnnotationsValidator />
+
+                <div class="space-y-3 text-sm">
+                    <!-- Search -->
+                    <div>
+                        <InputText
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            placeholder="Search hardware…"
+                            Value="@_search"
+                            ValueChanged="OnSearchChanged"
+                            ValueExpression="() => _search"
+                            @oninput="OnSearchInput" />
+
+                        
+                    </div>
+
+                    <!-- Selection -->
+                    <div>
+                        <InputSelect
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Value">
+                            <option value="">Select hardware…</option>
+
+                            @foreach (var group in FilteredHardware)
+                            {
+                                <optgroup label="@group.Key">
+                                    @foreach (var hw in group.Value)
+                                    {
+                                        <option value="@hw.Name">@hw.Name</option>
+                                    }
+                                </optgroup>
+                            }
+                        </InputSelect>
+                    </div>
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-end items-center mt-5 gap-2">
+                    <button
+                        type="button"
+                        class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                        @onclick="Cancel">
+                        Cancel
+                    </button>
+
+                    <button
+                        type="submit"
+                        class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+                        disabled="@(!CanAccept)">
+                        Accept
+                    </button>
+                </div>
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    /* ---------- Parameters ---------- */
+
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter, EditorRequired]
+    public string Title { get; set; } = "Select hardware";
+
+    /// <summary>
+    /// Default / pre-selected hardware name (may be null)
+    /// </summary>
+    [Parameter]
+    public string? Value { get; set; }
+
+    /// <summary>
+    /// Called with the selected hardware name
+    /// </summary>
+    [Parameter]
+    public EventCallback<string?> OnAccept { get; set; }
+
+    /* ---------- State ---------- */
+
+    private SelectionFormModel _model = new();
+    private string _search = string.Empty;
+
+    private List<Hardware> _hardware = new();
+
+    private bool CanAccept => !string.IsNullOrWhiteSpace(_model.Value);
+
+    /* ---------- Lifecycle ---------- */
+
+    protected override async Task OnParametersSetAsync()
+    {
+        if (IsOpen)
+        {
+            _hardware = (await HardwareRepository.GetAllAsync())
+                .OrderBy(h => h.Kind)
+                .ThenBy(h => h.Name)
+                .ToList();
+
+            _model = new SelectionFormModel
+            {
+                Value = Value
+            };
+
+            _search = string.Empty;
+        }
+    }
+
+    protected override void OnAfterRender(bool firstRender)
+    {
+        
+    }
+
+
+    
+    /* ---------- Computed ---------- */
+
+    private IReadOnlyDictionary<string, List<Hardware>> FilteredHardware =>
+        _hardware
+            .Where(h =>
+                string.IsNullOrWhiteSpace(_search) ||
+                h.Name.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase))
+            .GroupBy(h => h.Kind)
+            .ToDictionary(
+                g => g.Key,
+                g => g.OrderBy(h => h.Name).ToList());
+
+
+    /* ---------- Actions ---------- */
+
+    private async Task HandleAccept()
+    {
+        await OnAccept.InvokeAsync(_model.Value);
+        await Close();
+    }
+
+    private async Task Cancel()
+    {
+        await Close();
+    }
+
+    private async Task Close()
+    {
+        _model = new();
+        _search = string.Empty;
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    /* ---------- Model ---------- */
+
+    private sealed class SelectionFormModel
+    {
+        [Required]
+        public string? Value { get; set; }
+    }
+    
+    private void OnSearchChanged(string? value)
+    {
+        _search = value ?? string.Empty;
+    }
+    private void OnSearchInput(ChangeEventArgs e)
+    {
+        _search = e.Value?.ToString() ?? string.Empty;
+
+        if (FilteredHardware.SelectMany(g => g.Value).All(h => h.Name != _model.Value))
+        {
+            _model.Value = null;
+        }
+    }
+}

+ 180 - 0
RackPeek.Web/Components/Modals/NicModal.razor

@@ -0,0 +1,180 @@
+@using System.ComponentModel.DataAnnotations
+@using RackPeek.Domain.Resources.Hardware.Models
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center">
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70" @onclick="Cancel"></div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-md p-4">
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-4">
+                <div class="text-zinc-100 text-sm font-medium">
+                    @(IsEdit ? "Modify NIC" : "Add NIC")
+                </div>
+
+                <button
+                    class="text-zinc-400 hover:text-zinc-200"
+                    @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            <!-- Form -->
+            <EditForm Model="_model" OnValidSubmit="HandleValidSubmit">
+                <DataAnnotationsValidator />
+                <ValidationSummary class="text-xs text-red-400 mb-3" />
+
+                <div class="space-y-3 text-sm">
+                    <!-- Type -->
+                    <div>
+                        <label class="block text-zinc-400 mb-1">
+                            Type
+                        </label>
+
+                        <InputSelect
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @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">
+                            Speed (Gbps)
+                        </label>
+
+                        <InputNumber
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Speed" />
+                    </div>
+
+                    <!-- Ports -->
+                    <div>
+                        <label class="block text-zinc-400 mb-1">
+                            Ports
+                        </label>
+
+                        <InputNumber
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Ports" />
+                    </div>
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-between items-center mt-5">
+                    @if (IsEdit)
+                    {
+                        <button
+                            type="button"
+                            class="text-red-400 hover:text-red-300 text-sm"
+                            @onclick="HandleDelete">
+                            Delete NIC
+                        </button>
+                    }
+                    else
+                    {
+                        <span></span>
+                    }
+
+                    <div class="flex gap-2">
+                        <button
+                            type="button"
+                            class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                            @onclick="Cancel">
+                            Cancel
+                        </button>
+
+                        <button
+                            type="submit"
+                            class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500">
+                            @(IsEdit ? "Update" : "Add")
+                        </button>
+                    </div>
+                </div>
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter] public Nic? Value { get; set; }
+
+    [Parameter] public EventCallback<Nic> OnSubmit { get; set; }
+    [Parameter] public EventCallback<Nic> OnDelete { get; set; }
+
+    private NicFormModel _model = new();
+
+    private bool IsEdit => Value is not null;
+
+    protected override void OnParametersSet()
+    {
+        if (IsOpen)
+        {
+            _model = Value is null
+                ? new NicFormModel()
+                : new NicFormModel
+                {
+                    Type = Value.Type,
+                    Speed = Value.Speed,
+                    Ports = Value.Ports
+                };
+        }
+    }
+
+    private async Task HandleValidSubmit()
+    {
+        var nic = new Nic
+        {
+            Type = _model.Type,
+            Speed = _model.Speed,
+            Ports = _model.Ports
+        };
+
+        await OnSubmit.InvokeAsync(nic);
+        await Close();
+    }
+
+    private async Task HandleDelete()
+    {
+        if (Value is not null)
+        {
+            await OnDelete.InvokeAsync(Value);
+            await Close();
+        }
+    }
+
+    private async Task Cancel()
+    {
+        await Close();
+    }
+
+    private async Task Close()
+    {
+        _model = new();
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    private class NicFormModel
+    {
+        [Required]
+        public string? Type { get; set; }
+
+        [Range(1, 400)]
+        public int? Speed { get; set; }
+
+        [Range(1, 128)]
+        public int? Ports { get; set; }
+    }
+}

+ 173 - 0
RackPeek.Web/Components/Modals/PortModal.razor

@@ -0,0 +1,173 @@
+@using System.ComponentModel.DataAnnotations
+@using RackPeek.Domain.Resources.Hardware.Models
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center">
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70" @onclick="Cancel"></div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-md p-4">
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-4">
+                <div class="text-zinc-100 text-sm font-medium">
+                    @(IsEdit ? "Modify Port" : "Add Port")
+                </div>
+
+                <button
+                    class="text-zinc-400 hover:text-zinc-200"
+                    @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            <!-- Form -->
+            <EditForm Model="_model" OnValidSubmit="HandleValidSubmit">
+                <DataAnnotationsValidator />
+                <ValidationSummary class="text-xs text-red-400 mb-3" />
+
+                <div class="space-y-3 text-sm">
+                    <!-- Type -->
+                    <div>
+                        <label class="block text-zinc-400 mb-1">
+                            Type
+                        </label>
+
+                        <InputText
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Type" />
+                    </div>
+
+                    <!-- Speed -->
+                    <div>
+                        <label class="block text-zinc-400 mb-1">
+                            Speed
+                        </label>
+
+                        <InputNumber
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Speed" />
+                    </div>
+
+                    <!-- Count -->
+                    <div>
+                        <label class="block text-zinc-400 mb-1">
+                            Count
+                        </label>
+
+                        <InputNumber
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Count" />
+                    </div>
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-between items-center mt-5">
+                    @if (IsEdit)
+                    {
+                        <button
+                            type="button"
+                            class="text-red-400 hover:text-red-300 text-sm"
+                            @onclick="HandleDelete">
+                            Delete Port
+                        </button>
+                    }
+                    else
+                    {
+                        <span></span>
+                    }
+
+                    <div class="flex gap-2">
+                        <button
+                            type="button"
+                            class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                            @onclick="Cancel">
+                            Cancel
+                        </button>
+
+                        <button
+                            type="submit"
+                            class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500">
+                            @(IsEdit ? "Update" : "Add")
+                        </button>
+                    </div>
+                </div>
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter] public Port? Value { get; set; }
+
+    [Parameter] public EventCallback<Port> OnSubmit { get; set; }
+    [Parameter] public EventCallback<Port> OnDelete { get; set; }
+
+    private PortFormModel _model = new();
+
+    private bool IsEdit => Value is not null;
+
+    protected override void OnParametersSet()
+    {
+        if (IsOpen)
+        {
+            _model = Value is null
+                ? new PortFormModel()
+                : new PortFormModel
+                {
+                    Type = Value.Type,
+                    Speed = Value.Speed,
+                    Count = Value.Count
+                };
+        }
+    }
+
+    private async Task HandleValidSubmit()
+    {
+        var port = new Port
+        {
+            Type = _model.Type,
+            Speed = _model.Speed,
+            Count = _model.Count
+        };
+
+        await OnSubmit.InvokeAsync(port);
+        await Close();
+    }
+
+    private async Task HandleDelete()
+    {
+        if (Value is not null)
+        {
+            await OnDelete.InvokeAsync(Value);
+            await Close();
+        }
+    }
+
+    private async Task Cancel()
+    {
+        await Close();
+    }
+
+    private async Task Close()
+    {
+        _model = new();
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    private class PortFormModel
+    {
+        [Required]
+        public string? Type { get; set; }
+
+        [Range(1, 400)]
+        public int? Speed { get; set; }
+
+        [Range(1, 256)]
+        public int? Count { get; set; }
+    }
+}

+ 194 - 0
RackPeek.Web/Components/Modals/SystemSelectionModal.razor

@@ -0,0 +1,194 @@
+@using System.ComponentModel.DataAnnotations
+@using RackPeek.Domain.Resources.SystemResources
+@inject ISystemRepository SystemRepository
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center">
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70" @onclick="Cancel"></div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-lg p-4">
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-4">
+                <div class="text-zinc-100 text-sm font-medium">
+                    @Title
+                </div>
+
+                <button
+                    class="text-zinc-400 hover:text-zinc-200"
+                    @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            <!-- Form -->
+            <EditForm Model="_model" OnValidSubmit="HandleAccept">
+                <DataAnnotationsValidator />
+
+                <div class="space-y-3 text-sm">
+                    <!-- Search -->
+                    <div>
+                        <InputText
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            placeholder="Search systems…"
+                            Value="@_search"
+                            ValueChanged="OnSearchChanged"
+                            ValueExpression="() => _search"
+                            @oninput="OnSearchInput" />
+                    </div>
+
+                    <!-- Selection -->
+                    <div>
+                        <InputSelect
+                            class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                            @bind-Value="_model.Value">
+                            <option value="">Select system…</option>
+
+                            @foreach (var group in FilteredSystems)
+                            {
+                                <optgroup label="@group.Key">
+                                    @foreach (var system in group.Value)
+                                    {
+                                        <option value="@system.Name">
+                                            @system.Name
+                                        </option>
+                                    }
+                                </optgroup>
+                            }
+                        </InputSelect>
+                    </div>
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-end items-center mt-5 gap-2">
+                    <button
+                        type="button"
+                        class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                        @onclick="Cancel">
+                        Cancel
+                    </button>
+
+                    <button
+                        type="submit"
+                        class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+                        disabled="@(!CanAccept)">
+                        Accept
+                    </button>
+                </div>
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    /* ---------- Parameters ---------- */
+
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter, EditorRequired]
+    public string Title { get; set; } = "Select system";
+
+    /// <summary>
+    /// Default / pre-selected system name (may be null)
+    /// </summary>
+    [Parameter]
+    public string? Value { get; set; }
+
+    /// <summary>
+    /// Called with the selected system name
+    /// </summary>
+    [Parameter]
+    public EventCallback<string?> OnAccept { get; set; }
+
+    /* ---------- State ---------- */
+
+    private SelectionFormModel _model = new();
+    private string _search = string.Empty;
+
+    private List<SystemResource> _systems = new();
+
+    private bool CanAccept => !string.IsNullOrWhiteSpace(_model.Value);
+
+    /* ---------- Lifecycle ---------- */
+
+    protected override async Task OnParametersSetAsync()
+    {
+        if (IsOpen)
+        {
+            _systems = (await SystemRepository.GetAllAsync())
+                .OrderBy(s => s.Type)
+                .ThenBy(s => s.Name)
+                .ToList();
+
+            _model = new SelectionFormModel
+            {
+                Value = Value
+            };
+
+            _search = string.Empty;
+        }
+    }
+
+    /* ---------- Computed ---------- */
+
+    private Dictionary<string, List<SystemResource>> FilteredSystems =>
+        _systems
+            .Where(s =>
+                string.IsNullOrWhiteSpace(_search) ||
+                s.Name.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase))
+            .GroupBy(s => s.Type ?? "None")
+            .ToDictionary(
+                g => g.Key,
+                g => g.OrderBy(s => s.Name).ToList());
+
+    /* ---------- Actions ---------- */
+
+    private async Task HandleAccept()
+    {
+        await OnAccept.InvokeAsync(_model.Value);
+        await Close();
+    }
+
+    private async Task Cancel()
+    {
+        await Close();
+    }
+
+    private async Task Close()
+    {
+        _model = new();
+        _search = string.Empty;
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    /* ---------- Model ---------- */
+
+    private sealed class SelectionFormModel
+    {
+        [Required]
+        public string? Value { get; set; }
+    }
+
+    /* ---------- Search ---------- */
+
+    private void OnSearchChanged(string? value)
+    {
+        _search = value ?? string.Empty;
+    }
+
+    private void OnSearchInput(ChangeEventArgs e)
+    {
+        _search = e.Value?.ToString() ?? string.Empty;
+
+        // Clear selection if it is no longer visible
+        if (FilteredSystems
+            .SelectMany(g => g.Value)
+            .All(s => s.Name != _model.Value))
+        {
+            _model.Value = null;
+        }
+    }
+}

+ 180 - 15
RackPeek.Web/Components/Servers/ServerCardComponent.razor

@@ -2,6 +2,8 @@
 @using RackPeek.Domain.Resources.Hardware.Servers
 @using RackPeek.Domain.Resources.Hardware.Servers.Cpus
 @using RackPeek.Domain.Resources.Hardware.Servers.Drives
+@using RackPeek.Domain.Resources.Hardware.Servers.Gpus
+@using RackPeek.Domain.Resources.Hardware.Servers.Nics
 @using RackPeek.Web.Components.Modals
 @inject AddCpuUseCase AddCpuUseCase
 @inject RemoveCpuUseCase RemoveCpuUseCase
@@ -11,6 +13,15 @@
 @inject RemoveDriveUseCase RemoveDriveUseCase
 @inject UpdateDriveUseCase UpdateDriveUseCase
 
+@inject AddNicUseCase AddNicUseCase
+@inject UpdateNicUseCase UpdateNicUseCase
+@inject RemoveNicUseCase RemoveNicUseCase
+
+@inject AddGpuUseCase AddGpuUseCase
+@inject UpdateGpuUseCase UpdateGpuUseCase
+@inject RemoveGpuUseCase RemoveGpuUseCase
+
+
 @inject GetServerUseCase GetServerUseCase
 @inject UpdateServerUseCase UpdateServerUseCase
 
@@ -124,31 +135,67 @@
             }
         </div>
             
-        @if (Server.Nics?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">NICs</div>
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    NICs
+                    <button
+                        class="hover:text-emerald-400 group-hover:opacity-100 transition"
+                        title="Add NIC"
+                        @onclick="OpenAddNic">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (Server.Nics?.Any() == true)
+            {
                 @foreach (var nic in Server.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"
+                            title="Edit NIC"
+                            @onclick="() => OpenEditNic(nic)">
+                            @nic.Type — @nic.Speed Gbps (@nic.Ports ports)
+                        </button>
                     </div>
                 }
+            }
+        </div>
+
+
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">
+                    GPUs
+                    <button
+                        class="hover:text-emerald-400 group-hover:opacity-100 transition"
+                        title="Add GPU"
+                        @onclick="OpenAddGpu">
+                        +
+                    </button>
+                </div>
             </div>
-        }
 
-        @if (Server.Gpus?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">GPU</div>
+            @if (Server.Gpus?.Any() == true)
+            {
                 @foreach (var gpu in Server.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"
+                            title="Edit GPU"
+                            @onclick="() => OpenEditGpu(gpu)">
+                            @gpu.Model — @gpu.Vram GB VRAM
+                        </button>
                     </div>
                 }
-            </div>
-        }
+            }
+        </div>
+
 
     </div>
 </div>
@@ -173,6 +220,21 @@
     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" />
+
+
 @code {
     [Parameter] [EditorRequired]
     public Server Server { get; set; } = default!;
@@ -284,4 +346,107 @@
     
         
     #endregion
+    
+    #region NICs
+    bool _nicModalOpen;
+    int _editingNicIndex;
+    Nic? _editingNic;
+
+    void OpenAddNic()
+    {
+        _editingNicIndex = -1;
+        _editingNic = null;
+        _nicModalOpen = true;
+    }
+
+    void OpenEditNic(Nic nic)
+    {
+        Server.Nics ??= new();
+        _editingNicIndex = Server.Nics.IndexOf(nic);
+        _editingNic = nic;
+        _nicModalOpen = true;
+    }
+
+    async Task HandleNicSubmit(Nic nic)
+    {
+        Server.Nics ??= new();
+
+        if (_editingNicIndex < 0)
+        {
+            await AddNicUseCase.ExecuteAsync(
+                Server.Name,
+                nic.Type,
+                nic.Speed,
+                nic.Ports);
+        }
+        else
+        {
+            await UpdateNicUseCase.ExecuteAsync(
+                Server.Name,
+                _editingNicIndex,
+                nic.Type,
+                nic.Speed,
+                nic.Ports);
+        }
+
+        Server = await GetServerUseCase.ExecuteAsync(Server.Name);
+    }
+
+    async Task HandleNicDelete(Nic nic)
+    {
+        await RemoveNicUseCase.ExecuteAsync(Server.Name, _editingNicIndex);
+        Server = await GetServerUseCase.ExecuteAsync(Server.Name);
+    }
+    #endregion
+
+    #region GPUs
+    bool _gpuModalOpen;
+    int _editingGpuIndex;
+    Gpu? _editingGpu;
+
+    void OpenAddGpu()
+    {
+        _editingGpuIndex = -1;
+        _editingGpu = null;
+        _gpuModalOpen = true;
+    }
+
+    void OpenEditGpu(Gpu gpu)
+    {
+        Server.Gpus ??= new();
+        _editingGpuIndex = Server.Gpus.IndexOf(gpu);
+        _editingGpu = gpu;
+        _gpuModalOpen = true;
+    }
+
+    async Task HandleGpuSubmit(Gpu gpu)
+    {
+        Server.Gpus ??= new();
+
+        if (_editingGpuIndex < 0)
+        {
+            await AddGpuUseCase.ExecuteAsync(
+                Server.Name,
+                gpu.Model,
+                gpu.Vram);
+        }
+        else
+        {
+            await UpdateGpuUseCase.ExecuteAsync(
+                Server.Name,
+                _editingGpuIndex,
+                gpu.Model,
+                gpu.Vram);
+        }
+
+        Server = await GetServerUseCase.ExecuteAsync(Server.Name);
+    }
+
+    async Task HandleGpuDelete(Gpu gpu)
+    {
+        await RemoveGpuUseCase.ExecuteAsync(Server.Name, _editingGpuIndex);
+        Server = await GetServerUseCase.ExecuteAsync(Server.Name);
+    }
+    #endregion
+
 }

+ 6 - 2
RackPeek.Web/Components/Services/AddServiceComponent.razor

@@ -1,6 +1,8 @@
 @using RackPeek.Domain.Resources.Services.UseCases
 @inject AddServiceUseCase AddService
 
+@inject NavigationManager Nav
+
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="text-zinc-100 mb-3">
         Add service
@@ -31,6 +33,7 @@
 
 @code {
     [Parameter] public EventCallback<string> OnCreated { get; set; }
+    [Parameter] public string? RunsOn { get; set; }
 
     private string _name = string.Empty;
     private string? _error;
@@ -39,6 +42,7 @@
     private async Task CreateAsync()
     {
         _error = null;
+        var name = _name.Trim();
 
         if (string.IsNullOrWhiteSpace(_name))
         {
@@ -49,11 +53,11 @@
         try
         {
             _isSubmitting = true;
-            var name = _name.Trim();
-            await AddService.ExecuteAsync(name);
+            await AddService.ExecuteAsync(name, RunsOn);
             _name = string.Empty;
 
             await OnCreated.InvokeAsync(name);
+            Nav.NavigateTo($"/resources/services/{name}");
         }
         catch (Exception ex)
         {

+ 47 - 4
RackPeek.Web/Components/Services/ServiceCardComponent.razor

@@ -1,4 +1,10 @@
-@typeparam TService where TService : RackPeek.Domain.Resources.Services.Service
+@using RackPeek.Domain.Resources.Services
+@using RackPeek.Domain.Resources.Services.UseCases
+@using RackPeek.Domain.Resources.SystemResources.UseCases
+@using RackPeek.Web.Components.Systems
+@using RackPeek.Web.Components.Modals
+@inject UpdateServiceUseCase UpdateServiceUseCase
+@inject GetServiceUseCase GetServiceUseCase
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
@@ -97,8 +103,21 @@
             <div class="text-zinc-400 mb-1">Runs On</div>
             @if (_isEditing)
             {
-                <input class="input"
-                       @bind="_edit.RunsOn"/>
+                <button
+                    class="hover:text-emerald-400"
+                    title="Edit Runs On"
+                    @onclick="() => _selectParentOpen = true">
+                    @if (!string.IsNullOrWhiteSpace(Service.RunsOn))
+                    {
+                        @($"{Service.RunsOn} +")
+                    }
+                    else
+                    {
+                        @("Edit parent")
+                    }
+                   
+                </button>
+
             }
             else if (!string.IsNullOrWhiteSpace(Service.RunsOn))
             {
@@ -112,8 +131,15 @@
     </div>
 </div>
 
+<SystemSelectionModal
+    IsOpen="@_selectParentOpen"
+    IsOpenChanged="v => _selectParentOpen = v"
+    Title="Select a parent"
+    Value="@SelectedParentName"
+    OnAccept="HandleParentSelected" />
+
 @code {
-    [Parameter] [EditorRequired] public TService Service { get; set; } = default!;
+    [Parameter] [EditorRequired] public Service Service { get; set; } = default!;
 
     [Parameter] public EventCallback<ServiceEditModel> OnSave { get; set; }
 
@@ -136,5 +162,22 @@
     {
         _isEditing = false;
     }
+    
+    bool _selectParentOpen;
+    string? SelectedParentName;
+
+    async Task HandleParentSelected(string? name)
+    {
+        SelectedParentName = name;
+        await UpdateServiceUseCase.ExecuteAsync(
+            Service.Name, 
+            Service.Network?.Ip,
+            Service.Network?.Port,
+            Service.Network?.Protocol,
+            Service.Network?.Url,
+            name);
+        Service = await GetServiceUseCase.ExecuteAsync(Service.Name);
+        _edit = ServiceEditModel.From(Service);
+    }
 
 }

+ 1 - 1
RackPeek.Web/Components/Services/ServicesListComponent.razor

@@ -25,7 +25,7 @@
                 foreach (var svc in group)
                 {
                     <NavLink href="@($"/resources/services/{svc.Name}")" class="block">
-                        <ServiceCardComponent TService="Service" Service="svc" OnSave="UpdateService"/>
+                        <ServiceCardComponent Service="svc" OnSave="UpdateService"/>
                     </NavLink>
                 }
             }

+ 6 - 1
RackPeek.Web/Components/Systems/AddSystemComponent.razor

@@ -1,5 +1,6 @@
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @inject AddSystemUseCase AddSystemResource
+@inject NavigationManager Nav
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="text-zinc-100 mb-3">
@@ -31,6 +32,7 @@
 
 @code {
     [Parameter] public EventCallback<string> OnCreated { get; set; }
+    [Parameter] public string? RunsOn { get; set; }
 
     private string _name = string.Empty;
     private string? _error;
@@ -50,10 +52,11 @@
         {
             _isSubmitting = true;
             var name = _name.Trim();
-            await AddSystemResource.ExecuteAsync(name);
+            await AddSystemResource.ExecuteAsync(name, RunsOn);
             _name = string.Empty;
 
             await OnCreated.InvokeAsync(name);
+            Nav.NavigateTo($"/resources/systems/{name}");
         }
         catch (Exception ex)
         {
@@ -63,6 +66,8 @@
         {
             _isSubmitting = false;
         }
+        
+
     }
 
 }

+ 132 - 13
RackPeek.Web/Components/Systems/SystemCardComponent.razor

@@ -1,5 +1,15 @@
-@typeparam TSystem where TSystem : RackPeek.Domain.Resources.SystemResources.SystemResource
+@inject UpdateSystemUseCase UpdateSystemUseCase
+@inject GetSystemUseCase GetSystemUseCase
+@inject AddSystemDriveUseCase AddSystemDriveUseCase
+@inject RemoveSystemDriveUseCase RemoveSystemDriveUseCase
+@inject UpdateSystemDriveUseCase UpdateSystemDriveUseCase
 
+@using RackPeek.Domain.Resources.Hardware.Models
+@using RackPeek.Domain.Resources.Hardware.Servers
+@using RackPeek.Domain.Resources.Hardware.Servers.Drives
+@using RackPeek.Domain.Resources.SystemResources
+@using RackPeek.Domain.Resources.SystemResources.UseCases
+@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">
@@ -93,8 +103,21 @@
             <div class="text-zinc-400 mb-1">Runs On</div>
             @if (_isEditing)
             {
-                <input class="input"
-                       @bind="_edit.RunsOn"/>
+                <button
+                    class="hover:text-emerald-400"
+                    title="Edit Runs On"
+                    @onclick="() => _selectParentOpen = true">
+                    @if (!string.IsNullOrWhiteSpace(System.RunsOn))
+                    {
+                        @($"{System.RunsOn} +")
+                    }
+                    else
+                    {
+                        @("Edit parent")
+                    }
+                   
+                </button>
+
             }
             else if (!string.IsNullOrWhiteSpace(System.RunsOn))
             {
@@ -105,25 +128,56 @@
             }
         </div>
 
-        <!-- Drives (read-only) -->
-        @if (!_isEditing && System.Drives?.Any() == true)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">Drives</div>
+        <div>
+            <div class="flex items-center justify-between mb-1 group">
+                <div class="text-zinc-400">Drives
+                    <button
+                        class="hover:text-emerald-400 group-hover:opacity-100 transition"
+                        title="Add Drive"
+                        @onclick="OpenAddDrive">
+                        +
+                    </button>
+                </div>
+            </div>
+
+            @if (System.Drives?.Any() == true)
+            {
                 @foreach (var drive in System.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">
+
+                        <div class="flex gap-2 group-hover:opacity-100 transition">
+                            <button
+                                class="hover:text-emerald-400"
+                                title="Edit Drive"
+                                @onclick="() => OpenEditDrives(drive)">
+                                @drive.Type — @drive.Size GB
+                            </button>
+                        </div>
                     </div>
                 }
-            </div>
-        }
+            }
+        </div>
 
     </div>
 </div>
 
+<HardwareSelectionModal
+    IsOpen="@_selectParentOpen"
+    IsOpenChanged="v => _selectParentOpen = v"
+    Title="Select a parent"
+    Value="@SelectedParentName"
+    OnAccept="HandleParentSelected" />
+<DriveModal
+    IsOpen="@_driveModalOpen"
+    IsOpenChanged="v => _driveModalOpen = v"
+    Value="@_editingDrive"
+    OnSubmit="HandleDriveSubmit" 
+    OnDelete="HandleDriveDelete"/>
+
 @code {
-    [Parameter] [EditorRequired] public TSystem System { get; set; } = default!;
+    [Parameter] [EditorRequired] public SystemResource System { get; set; } = default!;
 
     [Parameter] public EventCallback<SystemEditModel> OnSave { get; set; }
 
@@ -147,4 +201,69 @@
         _isEditing = false;
     }
 
+    bool _selectParentOpen;
+    string? SelectedParentName;
+
+    async Task HandleParentSelected(string? name)
+    {
+        SelectedParentName = name;
+        await UpdateSystemUseCase.ExecuteAsync(
+            System.Name, 
+            System.Type,
+            System.Os,
+            System.Cores,
+            System.Ram,
+            name);
+        System = await GetSystemUseCase.ExecuteAsync(System.Name);
+        _edit =  SystemEditModel.From(System);
+    }
+    
+       
+    #region Drives
+    bool _driveModalOpen;
+    int _editingDriveIndex;
+    Drive? _editingDrive;
+    
+    void OpenAddDrive()
+    {
+        _editingDriveIndex = -1;
+        _editingDrive = null;
+        _driveModalOpen = true;
+    }
+    
+    void OpenEditDrives(Drive drive)
+    {
+        _editingDrive = drive;
+        System.Drives ??= new();
+        _editingDriveIndex = System.Drives.IndexOf(drive);;
+        _driveModalOpen = true;
+    }
+    
+    async Task HandleDriveSubmit(Drive drive)
+    {
+        System.Drives ??= new();
+
+        if (_editingDriveIndex < 0)
+        {
+            await AddSystemDriveUseCase.ExecuteAsync(System.Name, drive.Type ?? "hdd", drive.Size ?? 0);
+        }
+        else
+        {
+            await UpdateSystemDriveUseCase.ExecuteAsync(System.Name, _editingDriveIndex,  drive.Type ?? "hdd", drive.Size ?? 0);
+        }
+        
+        System = await GetSystemUseCase.ExecuteAsync(System.Name);
+        StateHasChanged();
+    }
+
+    async Task HandleDriveDelete(Drive drive)
+    {
+        await RemoveSystemDriveUseCase.ExecuteAsync(System.Name, _editingDriveIndex);
+        System = await GetSystemUseCase.ExecuteAsync(System.Name);
+        StateHasChanged();
+    }
+    
+        
+    #endregion
+
 }

+ 6 - 0
RackPeek.Web/Components/Systems/SystemsDetailsPage.razor

@@ -3,6 +3,7 @@
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @using RackPeek.Web.Components.Components
+@using RackPeek.Web.Components.Services
 @inject ISystemRepository SystemRepository
 @inject UpdateSystemUseCase UpdateSystemUseCase
 @inject GetSystemServiceTreeUseCase GetSystemServiceTreeUseCase
@@ -39,6 +40,11 @@
                 No child systems / services
             </div>
         }
+        
+        <div class="m-4">
+            <AddServiceComponent RunsOn="@SystemName"/>
+        </div>
+        
     }
 </div>
 

+ 6 - 12
RackPeek.Web/Program.cs

@@ -21,8 +21,6 @@ public class Program
         );
 
         var yamlDir = "./config";
-
-        var collection = new YamlResourceCollection(false);
         var basePath = Directory.GetCurrentDirectory();
 
         // Resolve yamlDir as relative to basePath
@@ -34,19 +32,15 @@ public class Program
             throw new DirectoryNotFoundException(
                 $"YAML directory not found: {yamlPath}"
             );
+        
+        var collection = new YamlResourceCollection(Path.Combine(yamlDir, "config.yaml"));
 
-        // Load all .yml and .yaml files
-        var yamlFiles = Directory.EnumerateFiles(yamlPath, "*.yml")
-            .Concat(Directory.EnumerateFiles(yamlPath, "*.yaml"))
-            .ToArray();
-
-        collection.LoadFiles(yamlFiles.Select(f => Path.Combine(basePath, f)));
 
         // Infrastructure
-        builder.Services.AddScoped<IHardwareRepository>(_ => new YamlHardwareRepository(collection));
-        builder.Services.AddScoped<ISystemRepository>(_ => new YamlSystemRepository(collection));
-        builder.Services.AddScoped<IServiceRepository>(_ => new YamlServiceRepository(collection));
-        builder.Services.AddScoped<IResourceRepository>(_ => new YamlResourceRepository(collection));
+        builder.Services.AddSingleton<IHardwareRepository>(_ => new YamlHardwareRepository(collection));
+        builder.Services.AddSingleton<ISystemRepository>(_ => new YamlSystemRepository(collection));
+        builder.Services.AddSingleton<IServiceRepository>(_ => new YamlServiceRepository(collection));
+        builder.Services.AddSingleton<IResourceRepository>(_ => new YamlResourceRepository(collection));
 
 
         builder.Services.AddUseCases();

+ 7 - 13
RackPeek/CliBootstrap.cs

@@ -41,12 +41,11 @@ public static class CliBootstrap
         IServiceCollection services,
         IConfiguration configuration,
         string yamlDir,
-        bool watch = false
+        string yamlFile
     )
     {
         services.AddSingleton(configuration);
 
-        var collection = new YamlResourceCollection(watch);
         var basePath = configuration["HardwarePath"] ?? Directory.GetCurrentDirectory();
 
         // Resolve yamlDir as relative to basePath
@@ -58,19 +57,14 @@ public static class CliBootstrap
             throw new DirectoryNotFoundException(
                 $"YAML directory not found: {yamlPath}"
             );
-
-        // Load all .yml and .yaml files
-        var yamlFiles = Directory.EnumerateFiles(yamlPath, "*.yml")
-            .Concat(Directory.EnumerateFiles(yamlPath, "*.yaml"))
-            .ToArray();
-
-        collection.LoadFiles(yamlFiles.Select(f => Path.Combine(basePath, f)));
+        
+        services.AddSingleton(new YamlResourceCollection(Path.Combine(yamlDir, yamlFile)));
 
         // Infrastructure
-        services.AddScoped<IHardwareRepository>(_ => new YamlHardwareRepository(collection));
-        services.AddScoped<ISystemRepository>(_ => new YamlSystemRepository(collection));
-        services.AddScoped<IServiceRepository>(_ => new YamlServiceRepository(collection));
-        services.AddScoped<IResourceRepository>(_ => new YamlResourceRepository(collection));
+        services.AddScoped<IHardwareRepository, YamlHardwareRepository>();
+        services.AddScoped<ISystemRepository, YamlSystemRepository>();
+        services.AddScoped<IServiceRepository, YamlServiceRepository>();
+        services.AddScoped<IResourceRepository, YamlResourceRepository>();
         
         // Application
         services.AddUseCases();

+ 2 - 9
RackPeek/Commands/Laptops/Cpus/LaptopCpuAddCommand.cs

@@ -16,15 +16,8 @@ public class LaptopCpuAddCommand(IServiceProvider provider)
     {
         using var scope = provider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<AddLaptopCpuUseCase>();
-
-        var cpu = new Cpu
-        {
-            Model = settings.Model,
-            Cores = settings.Cores,
-            Threads = settings.Threads
-        };
-
-        await useCase.ExecuteAsync(settings.LaptopName, cpu);
+        
+        await useCase.ExecuteAsync(settings.LaptopName, settings.Model, settings.Cores, settings.Threads);
 
         AnsiConsole.MarkupLine($"[green]CPU added to Laptop '{settings.LaptopName}'.[/]");
         return 0;

+ 1 - 1
RackPeek/Program.cs

@@ -22,7 +22,7 @@ public static class Program
         var registrar = new TypeRegistrar(services);
         var app = new CommandApp(registrar);
 
-        CliBootstrap.BuildApp(app, services, configuration, "./config");
+        CliBootstrap.BuildApp(app, services, configuration, "./config", "config.yaml");
 
         services.AddLogging(configure =>
             configure

+ 1 - 8
RackPeek/Yaml/YamlHardwareRepository.cs

@@ -81,12 +81,7 @@ public class YamlHardwareRepository(YamlResourceCollection resources) : IHardwar
             throw new InvalidOperationException(
                 $"Hardware with name '{hardware.Name}' already exists.");
 
-        // Use first file as default for new resources
-        var targetFile = resources.SourceFiles.FirstOrDefault()
-                         ?? throw new InvalidOperationException("No YAML file loaded.");
-
-        resources.Add(hardware, targetFile);
-        resources.SaveAll();
+        resources.Add(hardware);
 
         return Task.CompletedTask;
     }
@@ -100,7 +95,6 @@ public class YamlHardwareRepository(YamlResourceCollection resources) : IHardwar
             throw new InvalidOperationException($"Hardware '{hardware.Name}' not found.");
 
         resources.Update(hardware);
-        resources.SaveAll();
 
         return Task.CompletedTask;
     }
@@ -114,7 +108,6 @@ public class YamlHardwareRepository(YamlResourceCollection resources) : IHardwar
             throw new InvalidOperationException($"Hardware '{name}' not found.");
 
         resources.Delete(name);
-        resources.SaveAll();
 
         return Task.CompletedTask;
     }

+ 94 - 337
RackPeek/Yaml/YamlResourceCollection.cs

@@ -1,327 +1,101 @@
-using System.Collections.Concurrent;
 using System.Collections.Specialized;
 using RackPeek.Domain.Resources;
 using RackPeek.Domain.Resources.Hardware.Models;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
+using YamlDotNet.Core;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
 
 namespace RackPeek.Yaml;
 
-public sealed class YamlResourceCollection(bool watch) : IDisposable
+public sealed class YamlResourceCollection(string filePath)
 {
-    private static readonly TimeSpan ReloadDebounce = TimeSpan.FromMilliseconds(300);
+    private readonly Lock _fileLock = new();
+    private readonly List<Resource> _resources = LoadFromFile(filePath);
 
-    private readonly object _sync = new();
+    public IReadOnlyList<Hardware> HardwareResources =>
+        _resources.OfType<Hardware>().ToList();
 
-    private readonly List<ResourceEntry> _entries = [];
-    private readonly List<string> _knownFiles = [];
-    private readonly ConcurrentDictionary<string, DateTime> _reloadQueue = [];
-    private readonly bool _watch = watch;
-    private readonly Dictionary<string, FileSystemWatcher> _watchers = [];
+    public IReadOnlyList<SystemResource> SystemResources =>
+        _resources.OfType<SystemResource>().ToList();
 
-    public IReadOnlyList<string> SourceFiles
-    {
-        get
-        {
-            lock (_sync)
-                return _knownFiles.ToList();
-        }
-    }
-
-    public IReadOnlyList<Hardware> HardwareResources
-    {
-        get
-        {
-            lock (_sync)
-            {
-                return _entries
-                    .Select(e => e.Resource)
-                    .OfType<Hardware>()
-                    .ToList();
-            }
-        }
-    }
-
-    public IReadOnlyList<SystemResource> SystemResources
-    {
-        get
-        {
-            lock (_sync)
-            {
-                return _entries
-                    .Select(e => e.Resource)
-                    .OfType<SystemResource>()
-                    .ToList();
-            }
-        }
-    }
-
-    public IReadOnlyList<Service> ServiceResources
-    {
-        get
-        {
-            lock (_sync)
-            {
-                return _entries
-                    .Select(e => e.Resource)
-                    .OfType<Service>()
-                    .ToList();
-            }
-        }
-    }
-
-    public void Dispose()
-    {
-        lock (_sync)
-        {
-            foreach (var watcher in _watchers.Values)
-                watcher.Dispose();
-
-            _watchers.Clear();
-        }
-    }
-
-    // ----------------------------
-    // Loading
-    // ----------------------------
-
-    public void LoadFiles(IEnumerable<string> filePaths)
-    {
-        foreach (var file in filePaths)
-        {
-            TrackFile(file);
-            LoadFile(file);
-        }
-    }
+    public IReadOnlyList<Service> ServiceResources =>
+        _resources.OfType<Service>().ToList();
 
-    public void Load(string yaml, string file)
-    {
-        TrackFile(file);
-
-        var newEntries = Deserialize(yaml)
-            .Where(r => r != null)
-            .Select(r => new ResourceEntry(r!, file))
-            .ToList();
-
-        lock (_sync)
-        {
-            RemoveEntriesFromFile(file);
-            _entries.AddRange(newEntries);
-        }
-    }
-
-    private void LoadFile(string file)
-    {
-        var yaml = File.Exists(file)
-            ? SafeReadAllText(file)
-            : string.Empty;
-
-        var newEntries = Deserialize(yaml)
-            .Where(r => r != null)
-            .Select(r => new ResourceEntry(r!, file))
-            .ToList();
-
-        lock (_sync)
-        {
-            RemoveEntriesFromFile(file);
-            _entries.AddRange(newEntries);
-        }
-    }
-
-    // ----------------------------
-    // Watching
-    // ----------------------------
-
-    private void TrackFile(string file)
-    {
-        lock (_sync)
-        {
-            if (!_knownFiles.Contains(file))
-                _knownFiles.Add(file);
+    // --- CRUD ---
 
-            var directory = Path.GetDirectoryName(file);
-            if (directory == null || !_watch || _watchers.ContainsKey(directory))
-                return;
-
-            var watcher = new FileSystemWatcher(directory)
-            {
-                EnableRaisingEvents = true,
-                NotifyFilter = NotifyFilters.LastWrite
-                               | NotifyFilters.FileName
-                               | NotifyFilters.Size
-            };
-
-            watcher.Changed += OnFileChanged;
-            watcher.Created += OnFileChanged;
-            watcher.Deleted += OnFileChanged;
-            watcher.Renamed += OnFileRenamed;
-
-            _watchers[directory] = watcher;
-        }
-    }
-
-    private void OnFileChanged(object sender, FileSystemEventArgs e)
+    public void Add(Resource resource)
     {
-        lock (_sync)
+        UpdateWithLock(list =>
         {
-            if (!_knownFiles.Contains(e.FullPath))
-                return;
-        }
+            if (list.Any(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)))
+                throw new InvalidOperationException($"'{resource.Name}' already exists.");
 
-        QueueReload(e.FullPath);
-    }
-
-    private void OnFileRenamed(object sender, RenamedEventArgs e)
-    {
-        lock (_sync)
-        {
-            if (_knownFiles.Contains(e.OldFullPath))
-            {
-                RemoveEntriesFromFile(e.OldFullPath);
-                _knownFiles.Remove(e.OldFullPath);
-            }
-        }
-
-        lock (_sync)
-        {
-            if (_knownFiles.Contains(e.FullPath))
-                QueueReload(e.FullPath);
-        }
-    }
-
-    private void QueueReload(string file)
-    {
-        _reloadQueue[file] = DateTime.UtcNow;
-
-        Task.Delay(ReloadDebounce).ContinueWith(_ =>
-        {
-            if (_reloadQueue.TryGetValue(file, out var timestamp) &&
-                DateTime.UtcNow - timestamp >= ReloadDebounce)
-            {
-                _reloadQueue.TryRemove(file, out var _);
-                LoadFile(file);
-            }
+            resource.Kind = GetKind(resource);
+            list.Add(resource);
         });
     }
 
-    // ----------------------------
-    // CRUD
-    // ----------------------------
-
-    public void Add(Resource resource, string sourceFile)
-    {
-        TrackFile(sourceFile);
-
-        lock (_sync)
-        {
-            _entries.Add(new ResourceEntry(resource, sourceFile));
-        }
-    }
-
     public void Update(Resource resource)
     {
-        lock (_sync)
+        UpdateWithLock(list =>
         {
-            var existing = _entries.FirstOrDefault(e =>
-                e.Resource.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
-
-            if (existing == null)
-                throw new InvalidOperationException($"Resource '{resource.Name}' not found.");
-
-            _entries.Remove(existing);
-            _entries.Add(new ResourceEntry(resource, existing.SourceFile));
-        }
+            var index = list.FindIndex(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
+            if (index == -1) throw new InvalidOperationException("Not found.");
+            list[index] = resource;
+        });
     }
 
     public void Delete(string name)
     {
-        lock (_sync)
-        {
-            var existing = _entries.FirstOrDefault(e =>
-                e.Resource.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
-
-            if (existing == null)
-                throw new InvalidOperationException($"Resource '{name}' not found.");
-
-            _entries.Remove(existing);
-        }
+        UpdateWithLock(list => { list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); });
     }
-
-    public Resource? GetByName(string name)
+    
+    
+    private void UpdateWithLock(Action<List<Resource>> action)
     {
-        lock (_sync)
-        {
-            return _entries
-                .Select(e => e.Resource)
-                .FirstOrDefault(r =>
-                    r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
-        }
-    }
-
-    private void RemoveEntriesFromFile(string file)
-    {
-        _entries.RemoveAll(e => e.SourceFile == file);
-    }
-
-    // ----------------------------
-    // Serialization
-    // ----------------------------
-
-    public void SaveAll()
-    {
-        List<string> files;
-        List<ResourceEntry> snapshot;
-
-        lock (_sync)
+        lock (_fileLock)
         {
-            files = _knownFiles.ToList();
-            snapshot = _entries.ToList();
-        }
+            action(_resources);
+            
+            var serializer = new SerializerBuilder()
+                .WithNamingConvention(CamelCaseNamingConvention.Instance)
+                .Build();
 
-        foreach (var file in files)
-        {
-            var resources = snapshot
-                .Where(e => e.SourceFile == file)
-                .Select(e => e.Resource);
+            var payload = new OrderedDictionary
+            {
+                ["resources"] = _resources.Select(SerializeResource).ToList()
+            };
 
-            SaveToFile(file, resources);
+            File.WriteAllText(filePath, serializer.Serialize(payload));
+            
         }
     }
 
-    private static void SaveToFile(string filePath, IEnumerable<Resource> resources)
+    private string GetKind(Resource resource)
     {
-        var serializer = new SerializerBuilder()
-            .WithNamingConvention(CamelCaseNamingConvention.Instance)
-            .Build();
-
-        var payload = new OrderedDictionary
+        return resource switch
         {
-            ["resources"] = resources.Select(SerializeResource).ToList()
+            Server => "Server",
+            Switch => "Switch",
+            Firewall => "Firewall",
+            Router => "Router",
+            Desktop => "Desktop",
+            Laptop => "Laptop",
+            AccessPoint => "AccessPoint",
+            Ups => "Ups",
+            SystemResource => "System",
+            Service => "Service",
+            _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
         };
-
-        File.WriteAllText(filePath, serializer.Serialize(payload));
     }
 
-    private static OrderedDictionary SerializeResource(Resource resource)
+    private OrderedDictionary SerializeResource(Resource resource)
     {
         var map = new OrderedDictionary
         {
-            ["kind"] = resource switch
-            {
-                Server => "Server",
-                Switch => "Switch",
-                Firewall => "Firewall",
-                Router => "Router",
-                Desktop => "Desktop",
-                Laptop => "Laptop",
-                AccessPoint => "AccessPoint",
-                Ups => "Ups",
-                SystemResource => "System",
-                Service => "Service",
-                _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
-            }
+            ["kind"] = GetKind(resource)
         };
 
         var serializer = new SerializerBuilder()
@@ -332,7 +106,7 @@ public sealed class YamlResourceCollection(bool watch) : IDisposable
 
         var props = new DeserializerBuilder()
             .Build()
-            .Deserialize<Dictionary<string, object?>>(yaml) ?? new();
+            .Deserialize<Dictionary<string, object?>>(yaml);
 
         foreach (var (key, value) in props)
             if (key != "kind")
@@ -340,75 +114,58 @@ public sealed class YamlResourceCollection(bool watch) : IDisposable
 
         return map;
     }
-
-    private static List<Resource> Deserialize(string yaml)
+    
+    private static List<Resource> LoadFromFile(string filePath)
     {
-        if (string.IsNullOrWhiteSpace(yaml))
-            return [];
+        // 1. Robustness: Handle missing or empty files immediately
+        if (!File.Exists(filePath)) return new List<Resource>();
+        var yaml = File.ReadAllText(filePath);
+        if (string.IsNullOrWhiteSpace(yaml)) return new List<Resource>();
 
         var deserializer = new DeserializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithCaseInsensitivePropertyMatching()
             .WithTypeConverter(new StorageSizeYamlConverter())
+            // 2. The "Pragmatic" Fix: Automatically choose the class based on the "kind" key
+            .WithTypeDiscriminatingNodeDeserializer(options =>
+            {
+                options.AddKeyValueTypeDiscriminator<Resource>("kind", new Dictionary<string, Type>
+                {
+                    { Server.KindLabel, typeof(Server) },
+                    { Switch.KindLabel, typeof(Switch) },
+                    { Firewall.KindLabel, typeof(Firewall) },
+                    { Router.KindLabel, typeof(Router) },
+                    { Desktop.KindLabel, typeof(Desktop) },
+                    { Laptop.KindLabel, typeof(Laptop) },
+                    { AccessPoint.KindLabel, typeof(AccessPoint) },
+                    { Ups.KindLabel, typeof(Ups) },
+                    { SystemResource.KindLabel, typeof(SystemResource) },
+                    { Service.KindLabel, typeof(Service) }
+                });
+            })
             .Build();
 
-        var raw = deserializer.Deserialize<
-            Dictionary<string, List<Dictionary<string, object>>>>(yaml);
-
-        if (raw == null || !raw.TryGetValue("resources", out var items))
-            return [];
-
-        var resources = new List<Resource>();
-
-        foreach (var item in items)
+        try
         {
-            if (!item.TryGetValue("kind", out var kindObj) || kindObj == null)
-                continue;
-
-            var kind = kindObj.ToString();
-            var typedYaml = new SerializerBuilder()
-                .WithNamingConvention(CamelCaseNamingConvention.Instance)
-                .Build()
-                .Serialize(item);
-
-            Resource resource = kind switch
-            {
-                "Server" => deserializer.Deserialize<Server>(typedYaml),
-                "Switch" => deserializer.Deserialize<Switch>(typedYaml),
-                "Firewall" => deserializer.Deserialize<Firewall>(typedYaml),
-                "Router" => deserializer.Deserialize<Router>(typedYaml),
-                "Desktop" => deserializer.Deserialize<Desktop>(typedYaml),
-                "Laptop" => deserializer.Deserialize<Laptop>(typedYaml),
-                "AccessPoint" => deserializer.Deserialize<AccessPoint>(typedYaml),
-                "Ups" => deserializer.Deserialize<Ups>(typedYaml),
-                "System" => deserializer.Deserialize<SystemResource>(typedYaml),
-                "Service" => deserializer.Deserialize<Service>(typedYaml),
-                _ => null
-            };
-
-            if (resource != null)
-                resources.Add(resource);
+            // 3. Deserialize into a wrapper class to handle the "resources:" root key
+            var root = deserializer.Deserialize<YamlRoot>(yaml);
+            return root?.Resources ?? new List<Resource>();
         }
-
-        return resources;
-    }
-
-    private static string SafeReadAllText(string file)
-    {
-        for (var i = 0; i < 5; i++)
+        catch (YamlException)
         {
-            try
-            {
-                return File.ReadAllText(file);
-            }
-            catch (IOException)
-            {
-                Thread.Sleep(50);
-            }
+            // Handle malformed YAML here or return empty list
+            return new List<Resource>();
         }
+    }
 
-        return string.Empty;
+    // Simple wrapper class to match the YAML structure
+    private class YamlRoot
+    {
+        public List<Resource>? Resources { get; set; }
     }
 
-    private sealed record ResourceEntry(Resource Resource, string SourceFile);
-}
+    public Resource? GetByName(string name)
+    {
+        return _resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+    }
+}

+ 14 - 35
RackPeek/Yaml/YamlResourceRepository.cs

@@ -1,49 +1,28 @@
 using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Hardware.Models;
+using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
-
-namespace RackPeek.Yaml;
+using RackPeek.Yaml;
 
 public class YamlResourceRepository(YamlResourceCollection resources) : IResourceRepository
 {
     public Task<string?> GetResourceKindAsync(string name)
     {
-        var hardware = resources.HardwareResources.FirstOrDefault(r => r.Name == name);
-        if (hardware != null)
-        {
-            return Task.FromResult<string?>(hardware.Kind);
-        }
-        var systemResource = resources.SystemResources.FirstOrDefault(r => r.Name == name);
-        if (systemResource != null)
-        {
-            return Task.FromResult<string?>(systemResource.Kind);
-        }
-        var service = resources.ServiceResources.FirstOrDefault(r => r.Name == name);
-        if (service != null)
-        {
-            return Task.FromResult<string?>(service.Kind);
-        }
+        // Use the centralized GetByName which handles casing correctly
+        var resource = resources.GetByName(name);
         
-        return Task.FromResult<string?>(null);
+        // Return the Kind label if it exists
+        return Task.FromResult(resource switch
+        {
+            Hardware h => h.Kind,
+            SystemResource s => SystemResource.KindLabel,
+            Service svc => Service.KindLabel,
+            _ => null
+        });
     }
 
     public Task<bool> ResourceExistsAsync(string name)
     {
-        var hardware = resources.HardwareResources.FirstOrDefault(r => r.Name == name);
-        if (hardware != null)
-        {
-            return Task.FromResult(true);
-        }
-        var systemResource = resources.SystemResources.FirstOrDefault(r => r.Name == name);
-        if (systemResource != null)
-        {
-            return Task.FromResult(true);
-        }
-        var service = resources.ServiceResources.FirstOrDefault(r => r.Name == name);
-        if (service != null)
-        {
-            return Task.FromResult(true);
-        }
-        
-        return Task.FromResult(false);
+        return Task.FromResult(resources.GetByName(name) != null);
     }
 }

+ 1 - 8
RackPeek/Yaml/YamlServiceRepository.cs

@@ -43,12 +43,7 @@ public class YamlServiceRepository(YamlResourceCollection resources) : IServiceR
             throw new InvalidOperationException(
                 $"Service with name '{service.Name}' already exists.");
 
-        // Use first file as default for new resources
-        var targetFile = resources.SourceFiles.FirstOrDefault()
-                         ?? throw new InvalidOperationException("No YAML file loaded.");
-
-        resources.Add(service, targetFile);
-        resources.SaveAll();
+        resources.Add(service);
 
         return Task.CompletedTask;
     }
@@ -62,7 +57,6 @@ public class YamlServiceRepository(YamlResourceCollection resources) : IServiceR
             throw new InvalidOperationException($"Service '{service.Name}' not found.");
 
         resources.Update(service);
-        resources.SaveAll();
 
         return Task.CompletedTask;
     }
@@ -76,7 +70,6 @@ public class YamlServiceRepository(YamlResourceCollection resources) : IServiceR
             throw new InvalidOperationException($"Service '{name}' not found.");
 
         resources.Delete(name);
-        resources.SaveAll();
 
         return Task.CompletedTask;
     }

+ 1 - 8
RackPeek/Yaml/YamlSystemRepository.cs

@@ -50,12 +50,7 @@ public class YamlSystemRepository(YamlResourceCollection resources) : ISystemRep
             throw new InvalidOperationException(
                 $"System with name '{systemResource.Name}' already exists.");
 
-        // Use first file as default for new resources
-        var targetFile = resources.SourceFiles.FirstOrDefault()
-                         ?? throw new InvalidOperationException("No YAML file loaded.");
-
-        resources.Add(systemResource, targetFile);
-        resources.SaveAll();
+        resources.Add(systemResource);
 
         return Task.CompletedTask;
     }
@@ -69,7 +64,6 @@ public class YamlSystemRepository(YamlResourceCollection resources) : ISystemRep
             throw new InvalidOperationException($"System '{systemResource.Name}' not found.");
 
         resources.Update(systemResource);
-        resources.SaveAll();
 
         return Task.CompletedTask;
     }
@@ -83,7 +77,6 @@ public class YamlSystemRepository(YamlResourceCollection resources) : ISystemRep
             throw new InvalidOperationException($"System '{name}' not found.");
 
         resources.Delete(name);
-        resources.SaveAll();
 
         return Task.CompletedTask;
     }

+ 2 - 1
Tests/EndToEnd/AccessPointE2ETests.cs

@@ -15,7 +15,8 @@ public class AccessPointYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper ou
         var output = await YamlCliTestHost.RunAsync(
             inputArgs,
             fs.Root,
-            outputHelper);
+            outputHelper,
+            "config.yaml");
 
         outputHelper.WriteLine(output);
 

+ 2 - 1
Tests/EndToEnd/DesktopYamlE2ETest.cs

@@ -15,7 +15,8 @@ public class DesktopYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
         var output = await YamlCliTestHost.RunAsync(
             inputArgs,
             fs.Root,
-            outputHelper
+            outputHelper,
+            "config.yaml"
         );
 
         outputHelper.WriteLine(output);

+ 3 - 2
Tests/EndToEnd/Infra/YamlCliTestHost.cs

@@ -15,7 +15,8 @@ public static class YamlCliTestHost
     public static async Task<string> RunAsync(
         string[] args,
         string hardwarePath,
-        ITestOutputHelper output)
+        ITestOutputHelper output,
+        string yamlFile)
     {
         var services = new ServiceCollection();
 
@@ -34,7 +35,7 @@ public static class YamlCliTestHost
         AnsiConsole.Console = console;
         app.Configure(c => c.Settings.Console = console);
 
-        CliBootstrap.BuildApp(app, services, config, "./");
+        CliBootstrap.BuildApp(app, services, config, hardwarePath, yamlFile);
 
         services.AddLogging(builder =>
         {

+ 2 - 1
Tests/EndToEnd/ServerYamlE2ETests.cs

@@ -15,7 +15,8 @@ public class ServerYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
         var output = await YamlCliTestHost.RunAsync(
             inputArgs,
             fs.Root,
-            outputHelper
+            outputHelper,
+            "config.yaml"
         );
 
         outputHelper.WriteLine(output);

+ 10 - 10
Tests/EndToEnd/ServiceYamlE2ETests.cs

@@ -12,7 +12,7 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
         outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
 
         var inputArgs = args.ToArray();
-        var output = await YamlCliTestHost.RunAsync(inputArgs, fs.Root, outputHelper);
+        var output = await YamlCliTestHost.RunAsync(inputArgs, fs.Root, outputHelper, "config.yaml");
 
         outputHelper.WriteLine(output);
 
@@ -48,15 +48,6 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
                      resources:
-                     - kind: System
-                       type: 
-                       os: 
-                       cores: 
-                       ram: 
-                       drives: 
-                       runsOn: 
-                       name: vm01
-                       tags: 
                      - kind: Service
                        network:
                          ip: 192.168.10.14
@@ -66,6 +57,15 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
                        runsOn: vm01
                        name: immich
                        tags: 
+                     - kind: System
+                       type: 
+                       os: 
+                       cores: 
+                       ram: 
+                       drives: 
+                       runsOn: 
+                       name: vm01
+                       tags: 
 
                      """, yaml);
 

+ 2 - 1
Tests/EndToEnd/SwitchYamlE2ETests.cs

@@ -15,7 +15,8 @@ public class SwitchYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
         var output = await YamlCliTestHost.RunAsync(
             inputArgs,
             fs.Root,
-            outputHelper);
+            outputHelper,
+            "config.yaml");
 
         outputHelper.WriteLine(output);
 

+ 2 - 1
Tests/EndToEnd/SystemYamlE2ETests.cs

@@ -15,7 +15,8 @@ public class SystemYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
         var output = await YamlCliTestHost.RunAsync(
             inputArgs,
             fs.Root,
-            outputHelper);
+            outputHelper,
+            "config.yaml");
 
         outputHelper.WriteLine(output);
 

+ 2 - 1
Tests/EndToEnd/UpsYamlE2ETests.cs

@@ -15,7 +15,8 @@ public class UpsYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
         var output = await YamlCliTestHost.RunAsync(
             inputArgs,
             fs.Root,
-            outputHelper);
+            outputHelper,
+            "config.yaml");
 
         outputHelper.WriteLine(output);
 

+ 12 - 2
Tests/Yaml/HardwareDeserializationTests.cs

@@ -8,11 +8,21 @@ public class HardwareDeserializationTests
 {
     public static IHardwareRepository CreateSut(string yaml)
     {
-        var yamlResourceCollection = new YamlResourceCollection(false);
-        yamlResourceCollection.Load(yaml, "test.yaml");
+        var tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "RackPeekTests",
+            Guid.NewGuid().ToString("N"));
+
+        Directory.CreateDirectory(tempDir);
+
+        var filePath = Path.Combine(tempDir, "config.yaml");
+        File.WriteAllText(filePath, yaml);
+
+        var yamlResourceCollection = new YamlResourceCollection(filePath);
         return new YamlHardwareRepository(yamlResourceCollection);
     }
 
+
     [Theory]
     [InlineData("Server", typeof(Server))]
     [InlineData("Switch", typeof(Switch))]

+ 12 - 2
Tests/Yaml/ServiceDeserializationTests.cs

@@ -7,11 +7,21 @@ public class ServiceDeserializationTests
 {
     public static IServiceRepository CreateSut(string yaml)
     {
-        var yamlResourceCollection = new YamlResourceCollection(false);
-        yamlResourceCollection.Load(yaml, "test.yaml");
+        var tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "RackPeekTests",
+            Guid.NewGuid().ToString("N"));
+
+        Directory.CreateDirectory(tempDir);
+
+        var filePath = Path.Combine(tempDir, "config.yaml");
+        File.WriteAllText(filePath, yaml);
+
+        var yamlResourceCollection = new YamlResourceCollection(filePath);
         return new YamlServiceRepository(yamlResourceCollection);
     }
 
+
     [Fact]
     public async Task deserialize_yaml_kind_Service()
     {

+ 12 - 2
Tests/Yaml/SystemDeserializationTests.cs

@@ -5,10 +5,20 @@ namespace Tests.Yaml;
 
 public class SystemDeserializationTests
 {
+    
     public static ISystemRepository CreateSut(string yaml)
     {
-        var yamlResourceCollection = new YamlResourceCollection(false);
-        yamlResourceCollection.Load(yaml, "test.yaml");
+        var tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "RackPeekTests",
+            Guid.NewGuid().ToString("N"));
+
+        Directory.CreateDirectory(tempDir);
+
+        var filePath = Path.Combine(tempDir, "config.yaml");
+        File.WriteAllText(filePath, yaml);
+
+        var yamlResourceCollection = new YamlResourceCollection(filePath);
         return new YamlSystemRepository(yamlResourceCollection);
     }