Răsfoiți Sursa

Merge branch 'main' into ISSUE-149

David Walshe 1 lună în urmă
părinte
comite
b9da4a7b03
49 a modificat fișierele cu 1170 adăugiri și 65 ștergeri
  1. 1 1
      RackPeek.Domain/Helpers/ThrowIfInvalid.cs
  2. 1 1
      RackPeek.Domain/Resources/Desktops/DesktopHardwareReport.cs
  3. 19 1
      RackPeek.Domain/Resources/Desktops/UpdateDesktopUseCase.cs
  4. 1 1
      RackPeek.Domain/Resources/Laptops/LaptopHardwareReportUseCase.cs
  5. 20 1
      RackPeek.Domain/Resources/Laptops/UpdateLaptopUseCase.cs
  6. 1 1
      RackPeek.Domain/Resources/Servers/DescribeServerUseCase.cs
  7. 1 1
      RackPeek.Domain/Resources/Servers/ServerHardwareReport.cs
  8. 19 1
      RackPeek.Domain/Resources/Servers/UpdateServerUseCase.cs
  9. 1 1
      RackPeek.Domain/Resources/SubResources/Ram.cs
  10. 1 1
      RackPeek.Domain/Resources/SystemResources/SystemResource.cs
  11. 1 1
      RackPeek.Domain/Resources/SystemResources/UseCases/DescribeSystemUseCase.cs
  12. 1 1
      RackPeek.Domain/Resources/SystemResources/UseCases/SystemReportUseCase.cs
  13. 1 1
      RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs
  14. 2 1
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  15. 34 25
      Shared.Rcl/Components/ResourceTagEditor.razor
  16. 45 3
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  17. 3 1
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  18. 17 7
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  19. 171 0
      Shared.Rcl/Modals/CommaSeparatedStringModal.razor
  20. 24 3
      Shared.Rcl/Modals/RamModal.razor
  21. 3 1
      Shared.Rcl/Routers/RouterCardComponent.razor
  22. 5 3
      Shared.Rcl/Servers/ServerCardComponent.razor
  23. 3 1
      Shared.Rcl/Services/ServiceCardComponent.razor
  24. 3 1
      Shared.Rcl/Switches/SwitchCardComponent.razor
  25. 3 1
      Shared.Rcl/Systems/SystemCardComponent.razor
  26. 1 1
      Shared.Rcl/Systems/SystemEditModel.cs
  27. 3 1
      Shared.Rcl/Ups/UpsCardComponent.razor
  28. 67 0
      Tests.E2e/AccessPointCardTests.cs
  29. 70 0
      Tests.E2e/DesktopCardTests.cs
  30. 67 0
      Tests.E2e/FirewallCardTests.cs
  31. 68 0
      Tests.E2e/LaptopCardTests.cs
  32. 2 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  33. 3 1
      Tests.E2e/PageObjectModels/DesktopCardPom.cs
  34. 2 0
      Tests.E2e/PageObjectModels/FirewallCardPom.cs
  35. 2 0
      Tests.E2e/PageObjectModels/LaptopCardPom.cs
  36. 2 0
      Tests.E2e/PageObjectModels/RouterCardPom.cs
  37. 2 0
      Tests.E2e/PageObjectModels/ServerCardPom.cs
  38. 2 0
      Tests.E2e/PageObjectModels/ServiceCardPom.cs
  39. 2 0
      Tests.E2e/PageObjectModels/SwitchCardPom.cs
  40. 2 0
      Tests.E2e/PageObjectModels/SystemCardPom.cs
  41. 101 0
      Tests.E2e/PageObjectModels/TagsPom.cs
  42. 2 0
      Tests.E2e/PageObjectModels/UpsCardPom.cs
  43. 66 0
      Tests.E2e/RouterCardTests.cs
  44. 68 0
      Tests.E2e/ServerCardTests.cs
  45. 60 0
      Tests.E2e/ServiceCardTests.cs
  46. 67 0
      Tests.E2e/SwitchCardTests.cs
  47. 60 0
      Tests.E2e/SystemCardTests.cs
  48. 68 0
      Tests.E2e/UpsCardTests.cs
  49. 2 2
      Tests/Yaml/SystemDeserializationTests.cs

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

@@ -22,7 +22,7 @@ public static class ThrowIfInvalid
             throw new ValidationException("Model name is too long.");
     }
 
-    public static void RamGb(int? value)
+    public static void RamGb(double? value)
     {
         if (value is null) throw new ValidationException("RAM value must be specified.");
 

+ 1 - 1
RackPeek.Domain/Resources/Desktops/DesktopHardwareReport.cs

@@ -11,7 +11,7 @@ public record DesktopHardwareRow(
     string CpuSummary,
     int TotalCores,
     int TotalThreads,
-    int RamGb,
+    double RamGb,
     int TotalStorageGb,
     int SsdStorageGb,
     int HddStorageGb,

+ 19 - 1
RackPeek.Domain/Resources/Desktops/UpdateDesktopUseCase.cs

@@ -9,7 +9,7 @@ public class UpdateDesktopUseCase(IResourceCollection repository) : IUseCase
     public async Task ExecuteAsync(
         string name,
         string? model = null,
-        int? ramGb = null,
+        double? ramGb = null,
         int? ramMts = null,
         string? notes = null
     )
@@ -39,6 +39,24 @@ public class UpdateDesktopUseCase(IResourceCollection repository) : IUseCase
             desktop.Ram ??= new Ram();
             desktop.Ram.Mts = ramMts.Value;
         }
+        
+        if (desktop.Ram != null)
+        {
+            if (desktop.Ram.Size == 0)
+            {
+                desktop.Ram.Size = null;
+            }
+            
+            if (desktop.Ram.Mts == 0)
+            {
+                desktop.Ram.Mts = null;
+            }
+
+            if (desktop.Ram.Size == null && desktop.Ram.Mts == null)
+            {
+                desktop.Ram = null;
+            }
+        }
 
         if (notes != null) desktop.Notes = notes;
         await repository.UpdateAsync(desktop);

+ 1 - 1
RackPeek.Domain/Resources/Laptops/LaptopHardwareReportUseCase.cs

@@ -63,7 +63,7 @@ public record LaptopHardwareRow(
     string CpuSummary,
     int TotalCores,
     int TotalThreads,
-    int RamGb,
+    double RamGb,
     int TotalStorageGb,
     int SsdStorageGb,
     int HddStorageGb,

+ 20 - 1
RackPeek.Domain/Resources/Laptops/UpdateLaptopUseCase.cs

@@ -9,7 +9,7 @@ public class UpdateLaptopUseCase(IResourceCollection repository) : IUseCase
     public async Task ExecuteAsync(
         string name,
         string? model = null,
-        int? ramGb = null,
+        double? ramGb = null,
         int? ramMts = null,
         string? notes = null
     )
@@ -39,6 +39,25 @@ public class UpdateLaptopUseCase(IResourceCollection repository) : IUseCase
             laptop.Ram ??= new Ram();
             laptop.Ram.Mts = ramMts.Value;
         }
+        
+        if (laptop.Ram != null)
+        {
+            if (laptop.Ram.Size == 0)
+            {
+                laptop.Ram.Size = null;
+            }
+            
+            if (laptop.Ram.Mts == 0)
+            {
+                laptop.Ram.Mts = null;
+            }
+
+            if (laptop.Ram.Size == null && laptop.Ram.Mts == null)
+            {
+                laptop.Ram = null;
+            }
+        }
+
 
         if (notes != null) laptop.Notes = notes;
         await repository.UpdateAsync(laptop);

+ 1 - 1
RackPeek.Domain/Resources/Servers/DescribeServerUseCase.cs

@@ -8,7 +8,7 @@ public record ServerDescription(
     string CpuSummary,
     int TotalCores,
     int TotalThreads,
-    int RamGb,
+    double RamGb,
     int TotalStorageGb,
     int NicPorts,
     bool Ipmi

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

@@ -11,7 +11,7 @@ public record ServerHardwareRow(
     string CpuSummary,
     int TotalCores,
     int TotalThreads,
-    int RamGb,
+    double RamGb,
     int TotalStorageGb,
     int SsdStorageGb,
     int HddStorageGb,

+ 19 - 1
RackPeek.Domain/Resources/Servers/UpdateServerUseCase.cs

@@ -8,7 +8,7 @@ public class UpdateServerUseCase(IResourceCollection repository) : IUseCase
 {
     public async Task ExecuteAsync(
         string name,
-        int? ramGb = null,
+        double? ramGb = null,
         int? ramMts = null,
         bool? ipmi = null,
         string? notes = null
@@ -38,6 +38,24 @@ public class UpdateServerUseCase(IResourceCollection repository) : IUseCase
             server.Ram.Mts = ramMts.Value;
         }
 
+        if (server.Ram != null)
+        {
+            if (server.Ram.Size == 0)
+            {
+                server.Ram.Size = null;
+            }
+            
+            if (server.Ram.Mts == 0)
+            {
+                server.Ram.Mts = null;
+            }
+
+            if (server.Ram.Size == null && server.Ram.Mts == null)
+            {
+                server.Ram = null;
+            }
+        }
+
         // ---- IPMI ----
         if (ipmi.HasValue) server.Ipmi = ipmi.Value;
         if (notes != null) server.Notes = notes;

+ 1 - 1
RackPeek.Domain/Resources/SubResources/Ram.cs

@@ -2,6 +2,6 @@ namespace RackPeek.Domain.Resources.SubResources;
 
 public class Ram
 {
-    public int? Size { get; set; }
+    public double? Size { get; set; }
     public int? Mts { get; set; }
 }

+ 1 - 1
RackPeek.Domain/Resources/SystemResources/SystemResource.cs

@@ -21,6 +21,6 @@ public class SystemResource : Resource, IDriveResource
     public string? Type { get; set; }
     public string? Os { get; set; }
     public int? Cores { get; set; }
-    public int? Ram { get; set; }
+    public double? Ram { get; set; }
     public List<Drive>? Drives { get; set; }
 }

+ 1 - 1
RackPeek.Domain/Resources/SystemResources/UseCases/DescribeSystemUseCase.cs

@@ -8,7 +8,7 @@ public record SystemDescription(
     string? Type,
     string? Os,
     int Cores,
-    int RamGb,
+    double RamGb,
     int TotalStorageGb,
     string? RunsOn
 );

+ 1 - 1
RackPeek.Domain/Resources/SystemResources/UseCases/SystemReportUseCase.cs

@@ -11,7 +11,7 @@ public record SystemReportRow(
     string? Type,
     string? Os,
     int Cores,
-    int RamGb,
+    double RamGb,
     int TotalStorageGb,
     string? RunsOn
 );

+ 1 - 1
RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs

@@ -10,7 +10,7 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
         string? type = null,
         string? os = null,
         int? cores = null,
-        int? ram = null,
+        double? ram = null,
         string? runsOn = null,
         string? notes = null
     )

+ 2 - 1
Shared.Rcl/AccessPoints/AccessPointCardComponent.razor

@@ -104,7 +104,8 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="AccessPoint"/>
+        <ResourceTagEditor Resource="AccessPoint"
+                           TestIdPrefix="accesspoint" />
 
         <div class="md:col-span-2"
              data-testid="accesspoint-notes-section">

+ 34 - 25
Shared.Rcl/Components/ResourceTagEditor.razor

@@ -1,16 +1,20 @@
 @using RackPeek.Domain.UseCases.Tags
 @typeparam TResource where TResource : RackPeek.Domain.Resources.Resource
 @inject NavigationManager Nav
-
 @inject IAddTagUseCase<TResource> AddTagUseCase
 @inject IRemoveTagUseCase<TResource> RemoveTagUseCase
 
-<div class="md:col-span-2">
-    <div class="flex items-center justify-between mb-1 group">
+<div class="md:col-span-2"
+     data-testid="@BaseTestId">
+
+    <div class="flex items-center justify-between mb-1 group"
+         data-testid="@($"{BaseTestId}-header")">
+
         <div class="text-zinc-400">
             Tags
             <button class="hover:text-emerald-400 ml-1"
                     title="Add Tag"
+                    data-testid="@($"{BaseTestId}-add")"
                     @onclick="OpenAddTag">
                 +
             </button>
@@ -19,23 +23,26 @@
 
     @if (Resource.Tags.Any())
     {
-        <div class="flex flex-wrap gap-2">
+        <div class="flex flex-wrap gap-2"
+             data-testid="@($"{BaseTestId}-list")">
+
             @foreach (var tag in Resource.Tags.OrderBy(t => t))
             {
-                <div class="flex text-xs rounded overflow-hidden border border-zinc-700">
+                <div class="flex text-xs rounded overflow-hidden border border-zinc-700"
+                     data-testid="@($"{BaseTestId}-tag-{tag}")">
 
-                    <!-- LEFT SIDE (Navigate) -->
                     <button type="button"
                             class="px-2 py-0.5 bg-zinc-800 text-zinc-300 hover:bg-emerald-800 hover:text-emerald-200 transition"
                             title="View tag"
+                            data-testid="@($"{BaseTestId}-tag-{tag}-view")"
                             @onclick="() => NavigateToTag(tag)">
                         @tag
                     </button>
 
-                    <!-- RIGHT SIDE (Delete) -->
                     <button type="button"
                             class="px-1 py-0.5 bg-zinc-800 text-zinc-400 hover:bg-red-800 hover:text-red-200 transition border-l border-zinc-700"
                             title="Remove tag"
+                            data-testid="@($"{BaseTestId}-tag-{tag}-remove")"
                             @onclick="() => RemoveTag(tag)">
                     </button>
@@ -47,37 +54,40 @@
 
 </div>
 
-<StringValueModal
+<CommaSeparatedStringModal
     IsOpen="_tagModalOpen"
     IsOpenChanged="v => _tagModalOpen = v"
-    Title="Add Tag"
-    Description="Enter tag value"
-    Label="Tag"
-    Value=""
-    OnSubmit="HandleTagSubmit"/>
+    Title="Add Tags"
+    Description="Enter one or more tags separated by commas"
+    Label="Tags"
+    TestIdPrefix="@BaseTestId"
+    OnSubmit="HandleTagsSubmit" />
 
 @code {
     [Parameter][EditorRequired] public TResource Resource { get; set; } = default!;
-
     [Parameter] public EventCallback OnTagsChanged { get; set; }
+    [Parameter] public string? TestIdPrefix { get; set; }
 
-    bool _tagModalOpen;
+    private bool _tagModalOpen;
+
+    private string BaseTestId =>
+        string.IsNullOrWhiteSpace(TestIdPrefix)
+            ? "resource-tag-editor"
+            : $"{TestIdPrefix}-resource-tag-editor";
 
     void OpenAddTag()
     {
         _tagModalOpen = true;
     }
 
-    async Task HandleTagSubmit(string value)
+    public async Task HandleTagsSubmit(IReadOnlyList<string> values)
     {
-        if (string.IsNullOrWhiteSpace(value))
-            return;
-
-        await AddTagUseCase.ExecuteAsync(
-            Resource.Name,
-            value);
-
-        _tagModalOpen = false;
+        foreach (var value in values)
+        {
+            await AddTagUseCase.ExecuteAsync(
+                Resource.Name,
+                value);
+        }
 
         if (OnTagsChanged.HasDelegate)
             await OnTagsChanged.InvokeAsync();
@@ -97,5 +107,4 @@
     {
         Nav.NavigateTo($"tags/{Uri.EscapeDataString(tag)}");
     }
-
 }

+ 45 - 3
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -204,7 +204,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Desktop"/>
+        <ResourceTagEditor Resource="Desktop"
+                           TestIdPrefix="desktop" />
+
 
     </div>
 
@@ -257,6 +259,46 @@
                   OnSubmit="HandleCloneSubmit"
                   TestIdPrefix="desktop-clone"/>
 
+
+<CpuModal IsOpen="@_cpuModalOpen"
+          IsOpenChanged="v => _cpuModalOpen = v"
+          Value="@_editingCpu"
+          OnSubmit="HandleCpuSubmit"
+          OnDelete="HandleCpuDelete" 
+          TestIdPrefix="desktop"/>
+
+
+<RamModal IsOpen="@_isRamModalOpen"
+          IsOpenChanged="v => _isRamModalOpen = v"
+          Value="@Desktop.Ram"
+          OnSubmit="HandleRamSubmit" 
+          TestIdPrefix="desktop"/>
+
+
+<DriveModal IsOpen="@_driveModalOpen"
+            IsOpenChanged="v => _driveModalOpen = v"
+            Value="@_editingDrive"
+            OnSubmit="HandleDriveSubmit"
+            OnDelete="HandleDriveDelete" 
+            TestIdPrefix="desktop"/>
+
+
+<GpuModal IsOpen="@_gpuModalOpen"
+          IsOpenChanged="v => _gpuModalOpen = v"
+          Value="@_editingGpu"
+          OnSubmit="HandleGpuSubmit"
+          OnDelete="HandleGpuDelete" 
+          TestIdPrefix="desktop"/>
+
+
+<NicModal
+    IsOpen="@_nicModalOpen"
+    IsOpenChanged="v => _nicModalOpen = v"
+    Value="@_editingNic"
+    OnSubmit="HandleNicSubmit"
+    OnDelete="HandleNicDelete"
+    TestIdPrefix="desktop-nic"/>
+
 @code {
     [Parameter] [EditorRequired] public Desktop Desktop { get; set; } = default!;
 
@@ -269,10 +311,10 @@
         _isRamModalOpen = true;
     }
 
-    private async Task HandleRamSubmit(Ram value)
+    private async Task HandleRamSubmit(Ram? value)
     {
         _isRamModalOpen = false;
-        await UpdateUseCase.ExecuteAsync(Desktop.Name, Desktop.Model, value.Size, value.Mts);
+        await UpdateUseCase.ExecuteAsync(Desktop.Name, Desktop.Model, value?.Size ?? 0, value?.Mts ?? 0);
         Desktop = await GetByNameUseCase.ExecuteAsync(Desktop.Name);
     }
 

+ 3 - 1
Shared.Rcl/Firewalls/FirewallCardComponent.razor

@@ -162,7 +162,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Firewall" />
+        <ResourceTagEditor Resource="Firewall" 
+                           TestIdPrefix="firewall" />
+
 
         <div class="md:col-span-2"
              data-testid="firewall-notes-section">

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

@@ -175,7 +175,9 @@
 
     </div>
 
-    <ResourceTagEditor Resource="Laptop" />
+    <ResourceTagEditor Resource="Laptop" 
+                       TestIdPrefix="laptop" />
+
 
     <div class="md:col-span-2"
          data-testid="laptop-notes-section">
@@ -205,24 +207,32 @@
           IsOpenChanged="v => _cpuModalOpen = v"
           Value="@_editingCpu"
           OnSubmit="HandleCpuSubmit"
-          OnDelete="HandleCpuDelete" />
+          OnDelete="HandleCpuDelete" 
+          TestIdPrefix="laptop"/>
+
 
 <RamModal IsOpen="@_isRamModalOpen"
           IsOpenChanged="v => _isRamModalOpen = v"
           Value="@Laptop.Ram"
-          OnSubmit="HandleRamSubmit" />
+          OnSubmit="HandleRamSubmit" 
+          TestIdPrefix="laptop"/>
+
 
 <DriveModal IsOpen="@_driveModalOpen"
             IsOpenChanged="v => _driveModalOpen = v"
             Value="@_editingDrive"
             OnSubmit="HandleDriveSubmit"
-            OnDelete="HandleDriveDelete" />
+            OnDelete="HandleDriveDelete" 
+            TestIdPrefix="laptop"/>
+
 
 <GpuModal IsOpen="@_gpuModalOpen"
           IsOpenChanged="v => _gpuModalOpen = v"
           Value="@_editingGpu"
           OnSubmit="HandleGpuSubmit"
-          OnDelete="HandleGpuDelete" />
+          OnDelete="HandleGpuDelete" 
+          TestIdPrefix="laptop"/>
+
 
 <ConfirmModal IsOpen="_confirmDeleteOpen"
               IsOpenChanged="v => _confirmDeleteOpen = v"
@@ -264,10 +274,10 @@
         _isRamModalOpen = true;
     }
 
-    private async Task HandleRamSubmit(Ram value)
+    private async Task HandleRamSubmit(Ram? value)
     {
         _isRamModalOpen = false;
-        await UpdateUseCase.ExecuteAsync(Laptop.Name, Laptop.Model, value.Size, value.Mts, Laptop.Notes);
+        await UpdateUseCase.ExecuteAsync(Laptop.Name, Laptop.Model, value?.Size ?? 0, value?.Mts ?? 0, Laptop.Notes);
         Laptop = await GetByNameUseCase.ExecuteAsync(Laptop.Name);
     }
 

+ 171 - 0
Shared.Rcl/Modals/CommaSeparatedStringModal.razor

@@ -0,0 +1,171 @@
+@using System.ComponentModel.DataAnnotations
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center"
+         data-testid="@BaseTestId">
+
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70"
+             data-testid="@($"{BaseTestId}-backdrop")"
+             @onclick="Cancel">
+        </div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-md p-4"
+             data-testid="@($"{BaseTestId}-container")">
+
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-3"
+                 data-testid="@($"{BaseTestId}-header")">
+
+                <div class="text-zinc-100 text-sm font-medium"
+                     data-testid="@($"{BaseTestId}-title")">
+                    @Title
+                </div>
+
+                <button class="text-zinc-400 hover:text-zinc-200"
+                        data-testid="@($"{BaseTestId}-close")"
+                        @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            @if (!string.IsNullOrWhiteSpace(Description))
+            {
+                <div class="text-xs text-zinc-400 mb-4"
+                     data-testid="@($"{BaseTestId}-description")">
+                    @Description
+                </div>
+            }
+
+            <!-- Form -->
+            <EditForm Model="_model"
+                      OnValidSubmit="HandleValidSubmit"
+                      data-testid="@($"{BaseTestId}-form")">
+
+                <DataAnnotationsValidator />
+
+                @if (!string.IsNullOrEmpty(_error))
+                {
+                    <div class="text-xs text-red-400 mb-3"
+                         data-testid="@($"{BaseTestId}-error")">
+                        @_error
+                    </div>
+                }
+
+                <div class="text-sm"
+                     data-testid="@($"{BaseTestId}-field")">
+
+                    <label class="block text-zinc-400 mb-1">
+                        @Label
+                    </label>
+
+                    <InputText class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                               data-testid="@($"{BaseTestId}-input")"
+                               @bind-Value="_model.Value" />
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-end gap-2 mt-5"
+                     data-testid="@($"{BaseTestId}-actions")">
+
+                    <button type="button"
+                            class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                            data-testid="@($"{BaseTestId}-cancel")"
+                            @onclick="Cancel">
+                        Cancel
+                    </button>
+
+                    <button type="submit"
+                            class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+                            data-testid="@($"{BaseTestId}-submit")">
+                        Accept
+                    </button>
+                </div>
+
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter] public string Title { get; set; } = "Add values";
+    [Parameter] public string? Description { get; set; }
+    [Parameter] public string Label { get; set; } = "Values";
+
+    [Parameter] public string? Value { get; set; }
+
+    [Parameter] public EventCallback<IReadOnlyList<string>> OnSubmit { get; set; }
+
+    [Parameter] public string? TestIdPrefix { get; set; }
+
+    private string BaseTestId =>
+        string.IsNullOrWhiteSpace(TestIdPrefix)
+            ? "comma-separated-string-modal"
+            : $"{TestIdPrefix}-comma-separated-string-modal";
+
+    private FormModel _model = new();
+    private string? _error;
+
+    protected override void OnParametersSet()
+    {
+        if (IsOpen)
+        {
+            _error = null;
+            _model = new FormModel
+            {
+                Value = Value
+            };
+        }
+    }
+
+    private async Task HandleValidSubmit()
+    {
+        _error = null;
+
+        try
+        {
+            var values = (_model.Value ?? string.Empty)
+                .Split(',', StringSplitOptions.RemoveEmptyEntries)
+                .Select(x => x.Trim())
+                .Where(x => !string.IsNullOrWhiteSpace(x))
+                .Distinct(StringComparer.OrdinalIgnoreCase)
+                .ToList();
+
+            if (!values.Any())
+            {
+                _error = "Please enter at least one value.";
+                return;
+            }
+
+            await OnSubmit.InvokeAsync(values);
+            await Close();
+        }
+        catch (Exception ex)
+        {
+            _error = ex.Message;
+        }
+    }
+
+    private async Task Cancel()
+    {
+        await Close();
+    }
+
+    private async Task Close()
+    {
+        _model = new FormModel();
+        _error = null;
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    private class FormModel
+    {
+        [Required]
+        public string? Value { get; set; }
+    }
+}

+ 24 - 3
Shared.Rcl/Modals/RamModal.razor

@@ -70,7 +70,19 @@
                 <div class="flex justify-between items-center mt-5"
                      data-testid="@($"{BaseTestId}-actions")">
 
-                    <span></span>
+                    @if (IsEdit)
+                    {
+                        <button type="button"
+                                class="text-red-400 hover:text-red-300 text-sm"
+                                data-testid="@($"{BaseTestId}-clear")"
+                                @onclick="HandleClear">
+                            Clear RAM
+                        </button>
+                    }
+                    else
+                    {
+                        <span></span>
+                    }
 
                     <div class="flex gap-2">
 
@@ -90,6 +102,7 @@
                     </div>
                 </div>
 
+
             </EditForm>
         </div>
     </div>
@@ -101,7 +114,7 @@
 
     [Parameter] public Ram? Value { get; set; }
 
-    [Parameter] public EventCallback<Ram> OnSubmit { get; set; }
+    [Parameter] public EventCallback<Ram?> OnSubmit { get; set; }
 
     [Parameter] public string? TestIdPrefix { get; set; }
 
@@ -127,6 +140,14 @@
                 };
         }
     }
+    
+    private async Task HandleClear()
+    {
+        await OnSubmit.InvokeAsync(null);
+        await Close();
+    }
+
+    
 
     private async Task HandleValidSubmit()
     {
@@ -153,7 +174,7 @@
 
     private class RamFormModel
     {
-        [Range(1, 1024)] public int? Size { get; set; }
+        [Range(0, 1024)] public double? Size { get; set; }
 
         [Range(1, 10000)] public int? Mts { get; set; }
     }

+ 3 - 1
Shared.Rcl/Routers/RouterCardComponent.razor

@@ -164,7 +164,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Router" />
+        <ResourceTagEditor Resource="Router" 
+                           TestIdPrefix="router" />
+
 
         <div class="md:col-span-2"
              data-testid="router-notes-section">

+ 5 - 3
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -231,7 +231,9 @@
 
 
     </div>
-    <ResourceTagEditor Resource="Server"/>
+    <ResourceTagEditor Resource="Server"
+                       TestIdPrefix="server" />
+
 
     <div class="md:col-span-2">
         <div class="text-zinc-400 mb-1">Notes</div>
@@ -346,10 +348,10 @@
         _isRamModalOpen = true;
     }
 
-    private async Task HandleRamSubmit(Ram value)
+    private async Task HandleRamSubmit(Ram? value)
     {
         _isRamModalOpen = false;
-        await UpdateUseCase.ExecuteAsync(Server.Name, value.Size, value.Mts, Server.Ipmi);
+        await UpdateUseCase.ExecuteAsync(Server.Name, value?.Size ?? 0, value?.Mts ?? 0, Server.Ipmi);
         Server = await GetByNameUseCase.ExecuteAsync(Server.Name);
     }
 

+ 3 - 1
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -188,7 +188,9 @@
         </div>
 
 
-        <ResourceTagEditor Resource="Service"/>
+        <ResourceTagEditor Resource="Service"
+                           TestIdPrefix="service" />
+
 
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>

+ 3 - 1
Shared.Rcl/Switches/SwitchCardComponent.razor

@@ -162,7 +162,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Switch" />
+        <ResourceTagEditor Resource="Switch" 
+                           TestIdPrefix="switch" />
+
 
         <div class="md:col-span-2"
              data-testid="switch-notes-section">

+ 3 - 1
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -227,7 +227,9 @@
                 }
             }
         </div>
-        <ResourceTagEditor Resource="System"/>
+        <ResourceTagEditor Resource="System"
+                           TestIdPrefix="system" />
+
 
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>

+ 1 - 1
Shared.Rcl/Systems/SystemEditModel.cs

@@ -8,7 +8,7 @@ public sealed class SystemEditModel
     public string? Type { get; set; }
     public string? Os { get; set; }
     public int? Cores { get; set; }
-    public int? Ram { get; set; }
+    public double? Ram { get; set; }
     public string? RunsOn { get; set; }
     public string? Notes { get; set; }
 

+ 3 - 1
Shared.Rcl/Ups/UpsCardComponent.razor

@@ -104,7 +104,9 @@
             }
         </div>
 
-        <ResourceTagEditor Resource="Ups" />
+        <ResourceTagEditor Resource="Ups" 
+                           TestIdPrefix="ups" />
+
 
         <div class="md:col-span-2"
              data-testid="ups-notes-section">

+ 67 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -246,4 +246,71 @@ public class AccessPointCardTests(
             await context.CloseAsync();
         }
     }
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_AccessPoint_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoAccessPointsListAsync();
+
+            var list = new AccessPointsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddAccessPointAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new AccessPointCardPom(page);
+            await card.AssertCardVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("accesspoint", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("accesspoint", "Foo");
+            await tags.AssertTagVisibleAsync("accesspoint", "Bar");
+            await tags.AssertTagVisibleAsync("accesspoint", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("accesspoint", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("accesspoint", "Bar");
+            await tags.AssertTagVisibleAsync("accesspoint", "Foo");
+            await tags.AssertTagVisibleAsync("accesspoint", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("accesspoint", "Foo");
+            await tags.AssertTagVisibleAsync("accesspoint", "Baz");
+            await tags.AssertTagNotVisibleAsync("accesspoint", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 70 - 0
Tests.E2e/DesktopCardTests.cs

@@ -281,4 +281,74 @@ public class DesktopCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Desktop_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoDesktopsListAsync();
+
+            var list = new DesktopsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddDesktopAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(name)).ToBeVisibleAsync();
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("desktop", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("desktop", "Foo");
+            await tags.AssertTagVisibleAsync("desktop", "Bar");
+            await tags.AssertTagVisibleAsync("desktop", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("desktop", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("desktop", "Bar");
+            await tags.AssertTagVisibleAsync("desktop", "Foo");
+            await tags.AssertTagVisibleAsync("desktop", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("desktop", "Foo");
+            await tags.AssertTagVisibleAsync("desktop", "Baz");
+            await tags.AssertTagNotVisibleAsync("desktop", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+    
+    
 }

+ 67 - 0
Tests.E2e/FirewallCardTests.cs

@@ -228,4 +228,71 @@ public class FirewallCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Firewall_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoFirewallsListAsync();
+
+            var list = new FirewallsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddFirewallAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new FirewallCardPom(page);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("firewall", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("firewall", "Foo");
+            await tags.AssertTagVisibleAsync("firewall", "Bar");
+            await tags.AssertTagVisibleAsync("firewall", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("firewall", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("firewall", "Bar");
+            await tags.AssertTagVisibleAsync("firewall", "Foo");
+            await tags.AssertTagVisibleAsync("firewall", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("firewall", "Foo");
+            await tags.AssertTagVisibleAsync("firewall", "Baz");
+            await tags.AssertTagNotVisibleAsync("firewall", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 68 - 0
Tests.E2e/LaptopCardTests.cs

@@ -281,4 +281,72 @@ public class LaptopCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Laptop_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoLaptopsListAsync();
+
+            var list = new LaptopListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddLaptopAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(name)).ToBeVisibleAsync();
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("laptop", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("laptop", "Foo");
+            await tags.AssertTagVisibleAsync("laptop", "Bar");
+            await tags.AssertTagVisibleAsync("laptop", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("laptop", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("laptop", "Bar");
+            await tags.AssertTagVisibleAsync("laptop", "Foo");
+            await tags.AssertTagVisibleAsync("laptop", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("laptop", "Foo");
+            await tags.AssertTagVisibleAsync("laptop", "Baz");
+            await tags.AssertTagNotVisibleAsync("laptop", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 2 - 0
Tests.E2e/PageObjectModels/AccessPointCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class AccessPointCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // Root
     public ILocator Card(string accessPointName)
         => page.GetByTestId($"accesspoint-item-{Sanitize(accessPointName)}");

+ 3 - 1
Tests.E2e/PageObjectModels/DesktopCardPom.cs

@@ -3,7 +3,9 @@ namespace Tests.E2e.PageObjectModels;
 using Microsoft.Playwright;
 
 public class DesktopCardPom(IPage page)
-{
+{    
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root + Navigation
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/FirewallCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class FirewallCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Dynamic Firewall Item (root)
     // -------------------------------------------------

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

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class LaptopCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root + Navigation
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/RouterCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class RouterCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Dynamic Router Item (root)
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/ServerCardPom.cs

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class ServerCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root / Identity
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/ServiceCardPom.cs

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class ServiceCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/SwitchCardPom.cs

@@ -4,6 +4,8 @@ using Microsoft.Playwright;
 
 public class SwitchCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Dynamic Switch Item (root)
     // -------------------------------------------------

+ 2 - 0
Tests.E2e/PageObjectModels/SystemCardPom.cs

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class SystemCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Helpers
     // -------------------------------------------------

+ 101 - 0
Tests.E2e/PageObjectModels/TagsPom.cs

@@ -0,0 +1,101 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class TagsPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Root(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor");
+
+    public ILocator Header(string testIdPrefix)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-tag-editor-header");
+
+    public ILocator AddButton(string testIdPrefix)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-tag-editor-add");
+
+    public ILocator TagList(string testIdPrefix)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-tag-editor-list");
+
+    // -------------------------------------------------
+    // Individual Tags
+    // -------------------------------------------------
+
+    public ILocator Tag(string testIdPrefix, string tag)
+        => Root(testIdPrefix)
+            .GetByTestId($"{testIdPrefix}-resource-tag-editor-tag-{tag}");
+
+    public ILocator ViewTagButton(string testIdPrefix, string tag)
+        => Root(testIdPrefix)
+            .GetByTestId($"{testIdPrefix}-resource-tag-editor-tag-{tag}-view");
+
+    public ILocator RemoveTagButton(string testIdPrefix, string tag)
+        => Root(testIdPrefix)
+            .GetByTestId($"{testIdPrefix}-resource-tag-editor-tag-{tag}-remove");
+
+    // -------------------------------------------------
+    // Modal (CommaSeparatedStringModal)
+    // -------------------------------------------------
+
+    public ILocator Modal(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal");
+
+    public ILocator ModalInput(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal-input");
+
+    public ILocator ModalSubmit(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal-submit");
+
+    public ILocator ModalCancel(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-tag-editor-comma-separated-string-modal-cancel");
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertTagVisibleAsync(string testIdPrefix, string tag)
+    {
+        await Assertions.Expect(Tag(testIdPrefix, tag)).ToBeVisibleAsync();
+    }
+
+    public async Task AssertTagNotVisibleAsync(string testIdPrefix, string tag)
+    {
+        await Assertions.Expect(Tag(testIdPrefix, tag)).Not.ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddTagsAsync(string testIdPrefix, params string[] tags)
+    {
+        await AddButton(testIdPrefix).ClickAsync();
+
+        await Assertions.Expect(ModalInput(testIdPrefix)).ToBeVisibleAsync();
+
+        var value = string.Join(", ", tags);
+
+        await ModalInput(testIdPrefix).FillAsync(value);
+        await ModalSubmit(testIdPrefix).ClickAsync();
+
+        foreach (var tag in tags)
+        {
+            await AssertTagVisibleAsync(testIdPrefix, tag);
+        }
+    }
+
+    public async Task RemoveTagAsync(string testIdPrefix, string tag)
+    {
+        await RemoveTagButton(testIdPrefix, tag).ClickAsync();
+        await AssertTagNotVisibleAsync(testIdPrefix, tag);
+    }
+
+    public async Task NavigateToTagAsync(string testIdPrefix, string tag)
+    {
+        await ViewTagButton(testIdPrefix, tag).ClickAsync();
+        await page.WaitForURLAsync($"**/tags/{tag}");
+    }
+}

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

@@ -4,6 +4,8 @@ namespace Tests.E2e.PageObjectModels;
 
 public class UpsCardPom(IPage page)
 {
+    public TagsPom Tags => new(page);
+
     // -------------------------------------------------
     // Root
     // -------------------------------------------------

+ 66 - 0
Tests.E2e/RouterCardTests.cs

@@ -228,4 +228,70 @@ public class RouterCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Router_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoRoutersListAsync();
+
+            var list = new RouterListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddRouterAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new RouterCardPom(page);
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("router", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("router", "Foo");
+            await tags.AssertTagVisibleAsync("router", "Bar");
+            await tags.AssertTagVisibleAsync("router", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("router", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("router", "Bar");
+            await tags.AssertTagVisibleAsync("router", "Foo");
+            await tags.AssertTagVisibleAsync("router", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("router", "Foo");
+            await tags.AssertTagVisibleAsync("router", "Baz");
+            await tags.AssertTagNotVisibleAsync("router", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 68 - 0
Tests.E2e/ServerCardTests.cs

@@ -101,4 +101,72 @@ public class ServerCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Server_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoServersListAsync();
+
+            var list = new ServersListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServerAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new ServerCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("server", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("server", "Foo");
+            await tags.AssertTagVisibleAsync("server", "Bar");
+            await tags.AssertTagVisibleAsync("server", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("server", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("server", "Bar");
+            await tags.AssertTagVisibleAsync("server", "Foo");
+            await tags.AssertTagVisibleAsync("server", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("server", "Foo");
+            await tags.AssertTagVisibleAsync("server", "Baz");
+            await tags.AssertTagNotVisibleAsync("server", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 60 - 0
Tests.E2e/ServiceCardTests.cs

@@ -178,4 +178,64 @@ public class ServiceCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Service_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServiceAsync(name);
+            await page.WaitForURLAsync($"**/resources/services/{name}");
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("service", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("service", "Foo");
+            await tags.AssertTagVisibleAsync("service", "Bar");
+            await tags.AssertTagVisibleAsync("service", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("service", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("service", "Bar");
+            await tags.AssertTagVisibleAsync("service", "Foo");
+            await tags.AssertTagVisibleAsync("service", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("service", "Foo");
+            await tags.AssertTagVisibleAsync("service", "Baz");
+            await tags.AssertTagNotVisibleAsync("service", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 67 - 0
Tests.E2e/SwitchCardTests.cs

@@ -228,4 +228,71 @@ public class SwitchCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Switch_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoSwitchesListAsync();
+
+            var list = new SwitchListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSwitchAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new SwitchCardPom(page);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("switch", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("switch", "Foo");
+            await tags.AssertTagVisibleAsync("switch", "Bar");
+            await tags.AssertTagVisibleAsync("switch", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("switch", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("switch", "Bar");
+            await tags.AssertTagVisibleAsync("switch", "Foo");
+            await tags.AssertTagVisibleAsync("switch", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("switch", "Foo");
+            await tags.AssertTagVisibleAsync("switch", "Baz");
+            await tags.AssertTagNotVisibleAsync("switch", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 60 - 0
Tests.E2e/SystemCardTests.cs

@@ -203,5 +203,65 @@ public class SystemCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_System_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSystemAsync(name);
+            await page.WaitForURLAsync($"**/resources/systems/{name}");
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("system", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("system", "Foo");
+            await tags.AssertTagVisibleAsync("system", "Bar");
+            await tags.AssertTagVisibleAsync("system", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("system", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("system", "Bar");
+            await tags.AssertTagVisibleAsync("system", "Foo");
+            await tags.AssertTagVisibleAsync("system", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("system", "Foo");
+            await tags.AssertTagVisibleAsync("system", "Baz");
+            await tags.AssertTagNotVisibleAsync("system", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 
 }

+ 68 - 0
Tests.E2e/UpsCardTests.cs

@@ -173,4 +173,72 @@ public class UpsCardTests(
             await context.CloseAsync();
         }
     }
+    
+    
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Ups_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoUpsListAsync();
+
+            var list = new UpsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddUpsAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("ups", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("ups", "Foo");
+            await tags.AssertTagVisibleAsync("ups", "Bar");
+            await tags.AssertTagVisibleAsync("ups", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("ups", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("ups", "Bar");
+            await tags.AssertTagVisibleAsync("ups", "Foo");
+            await tags.AssertTagVisibleAsync("ups", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("ups", "Foo");
+            await tags.AssertTagVisibleAsync("ups", "Baz");
+            await tags.AssertTagNotVisibleAsync("ups", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }

+ 2 - 2
Tests/Yaml/SystemDeserializationTests.cs

@@ -39,7 +39,7 @@ resources:
     name: home-virtualization-host
     os: proxmox     
     cores: 2
-    ram: 12gb
+    ram: 12.5gb
     drives:
         - size: 2Tb
         - size: 1tb   
@@ -59,7 +59,7 @@ resources:
         Assert.Equal("home-virtualization-host", system.Name);
         Assert.Equal("proxmox", system.Os);
         Assert.Equal(2, system.Cores);
-        Assert.Equal(12, system.Ram);
+        Assert.Equal(12.5, system.Ram);
 
         // Drives
         Assert.NotNull(system.Drives);