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

Merge branch 'main' into ISSUE-149

David Walshe пре 1 месец
родитељ
комит
deaba4e214
54 измењених фајлова са 4278 додато и 139 уклоњено
  1. 0 1
      RackPeek.Domain/Persistence/InMemoryResourceCollection.cs
  2. 140 44
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  3. 1 1
      RackPeek.Domain/Resources/Resource.cs
  4. 1 3
      RackPeek.Domain/UseCases/Tags/AddResourceTagUseCase.cs
  5. 2 2
      RackPeek.Domain/UseCases/Tags/RemoveResourceTagUseCase.cs
  6. 1 1
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  7. 1 1
      Shared.Rcl/Components/ResourceTagEditor.razor
  8. 1 1
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  9. 1 1
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  10. 8 5
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  11. 1 1
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  12. 1 1
      Shared.Rcl/Routers/RouterCardComponent.razor
  13. 27 12
      Shared.Rcl/Servers/ServerCardComponent.razor
  14. 46 37
      Shared.Rcl/Services/ServiceCardComponent.razor
  15. 4 2
      Shared.Rcl/Services/ServiceDetailsPage.razor
  16. 1 1
      Shared.Rcl/Switches/SwitchCardComponent.razor
  17. 11 5
      Shared.Rcl/Systems/SystemCardComponent.razor
  18. 2 1
      Shared.Rcl/Systems/SystemsDetailsPage.razor
  19. 1 1
      Shared.Rcl/Ups/UpsCardComponent.razor
  20. 249 0
      Tests.E2e/AccessPointCardTests.cs
  21. 284 0
      Tests.E2e/DesktopCardTests.cs
  22. 231 0
      Tests.E2e/FirewallCardTests.cs
  23. 284 0
      Tests.E2e/LaptopCardTests.cs
  24. 167 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  25. 6 2
      Tests.E2e/PageObjectModels/AccessPointListPom.cs
  26. 189 0
      Tests.E2e/PageObjectModels/DesktopCardPom.cs
  27. 217 0
      Tests.E2e/PageObjectModels/FirewallCardPom.cs
  28. 2 5
      Tests.E2e/PageObjectModels/FirewallListPom.cs
  29. 189 0
      Tests.E2e/PageObjectModels/LaptopCardPom.cs
  30. 217 0
      Tests.E2e/PageObjectModels/RouterCardPom.cs
  31. 1 4
      Tests.E2e/PageObjectModels/RouterListPom.cs
  32. 165 0
      Tests.E2e/PageObjectModels/ServerCardPom.cs
  33. 1 1
      Tests.E2e/PageObjectModels/ServerListPom.cs
  34. 133 0
      Tests.E2e/PageObjectModels/ServiceCardPom.cs
  35. 1 1
      Tests.E2e/PageObjectModels/ServicesListPom.cs
  36. 217 0
      Tests.E2e/PageObjectModels/SwitchCardPom.cs
  37. 1 4
      Tests.E2e/PageObjectModels/SwitchListPom.cs
  38. 180 0
      Tests.E2e/PageObjectModels/SystemCardPom.cs
  39. 1 1
      Tests.E2e/PageObjectModels/SystemsListPom.cs
  40. 146 0
      Tests.E2e/PageObjectModels/UpsCardPom.cs
  41. 231 0
      Tests.E2e/RouterCardTests.cs
  42. 104 0
      Tests.E2e/ServerCardTests.cs
  43. 181 0
      Tests.E2e/ServiceCardTests.cs
  44. 231 0
      Tests.E2e/SwitchCardTests.cs
  45. 207 0
      Tests.E2e/SystemCardTests.cs
  46. 176 0
      Tests.E2e/UpsCardTests.cs
  47. 2 0
      Tests/EndToEnd/AccessPointE2ETests.cs
  48. 2 0
      Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs
  49. 3 0
      Tests/EndToEnd/ServiceYamlE2ETests.cs
  50. 2 0
      Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs
  51. 2 0
      Tests/EndToEnd/SwitchYamlE2ETests.cs
  52. 2 0
      Tests/EndToEnd/SystemYamlE2ETests.cs
  53. 2 0
      Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs
  54. 2 0
      Tests/EndToEnd/UpsYamlE2ETests.cs

+ 0 - 1
RackPeek.Domain/Persistence/InMemoryResourceCollection.cs

@@ -78,7 +78,6 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
         lock (_lock)
         {
             var result = _resources
-                .Where(r => r.Tags != null)
                 .SelectMany(r => r.Tags!) // flatten all tag arrays
                 .Where(t => !string.IsNullOrWhiteSpace(t))
                 .GroupBy(t => t)

+ 140 - 44
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -29,6 +29,9 @@ public sealed class YamlResourceCollection(
     ResourceCollection resourceCollection)
     : IResourceCollection
 {
+    // Bump this when your YAML schema changes, and add a migration step below.
+    private const int CurrentSchemaVersion = 1;
+
     public Task<bool> Exists(string name)
     {
         return Task.FromResult(resourceCollection.Resources.Exists(r =>
@@ -38,11 +41,11 @@ public sealed class YamlResourceCollection(
     public Task<Dictionary<string, int>> GetTagsAsync()
     {
         var result = resourceCollection.Resources
-            .Where(r => r.Tags != null)
-            .SelectMany(r => r.Tags!) // flatten all tag arrays
+            .SelectMany(r => r.Tags) // flatten all tag arrays
             .Where(t => !string.IsNullOrWhiteSpace(t))
             .GroupBy(t => t)
             .ToDictionary(g => g.Key, g => g.Count());
+
         return Task.FromResult(result);
     }
 
@@ -54,28 +57,17 @@ public sealed class YamlResourceCollection(
     public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
     {
         return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources
-            .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false).ToList());
-    }
-
-    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
-    {
-        return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources.Where(r => r.Tags.Contains(name))
+            .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false)
             .ToList());
     }
 
-    public async Task LoadAsync()
+    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
     {
-        var loaded = await LoadFromFileAsync();
-        try
-        {
-            resourceCollection.Resources.Clear();
-        }
-        catch
-        {
-            // ignore
-        }
-
-        resourceCollection.Resources.AddRange(loaded);
+        return Task.FromResult<IReadOnlyList<Resource>>(
+            resourceCollection.Resources
+                .Where(r => r.Tags.Contains(name))
+                .ToList()
+        );
     }
 
     public IReadOnlyList<Hardware> HardwareResources =>
@@ -97,7 +89,7 @@ public sealed class YamlResourceCollection(
     {
         var resource =
             resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
-        return Task.FromResult<T?>(resource as T);
+        return Task.FromResult(resource as T);
     }
 
     public Resource? GetByName(string name)
@@ -106,6 +98,46 @@ public sealed class YamlResourceCollection(
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
     }
 
+    public async Task LoadAsync()
+    {
+        // Read raw YAML so we can back it up exactly before any migration writes.
+        var yaml = await fileStore.ReadAllTextAsync(filePath);
+        if (string.IsNullOrWhiteSpace(yaml))
+        {
+            resourceCollection.Resources.Clear();
+            return;
+        }
+
+        var root = DeserializeRoot(yaml);
+        if (root == null)
+        {
+            // Keep behavior aligned with your previous code: if YAML is invalid, treat as empty.
+            resourceCollection.Resources.Clear();
+            return;
+        }
+
+        // Guard: config is newer than this app understands.
+        if (root.Version > CurrentSchemaVersion)
+        {
+            throw new InvalidOperationException(
+                $"Config schema version {root.Version} is newer than this application supports ({CurrentSchemaVersion}).");
+        }
+
+        // If older, backup first, then migrate step-by-step, then save.
+        if (root.Version < CurrentSchemaVersion)
+        {
+            await BackupOriginalAsync(yaml);
+
+            root = await MigrateAsync(root);
+
+            // Ensure we persist the migrated root (with updated version)
+            await SaveRootAsync(root);
+        }
+
+        resourceCollection.Resources.Clear();
+        resourceCollection.Resources.AddRange(root.Resources ?? []);
+    }
+
     public Task AddAsync(Resource resource)
     {
         return UpdateWithLockAsync(list =>
@@ -143,24 +175,14 @@ public sealed class YamlResourceCollection(
         {
             action(resourceCollection.Resources);
 
-            var serializer = new SerializerBuilder()
-                .WithNamingConvention(CamelCaseNamingConvention.Instance)
-                .WithTypeConverter(new StorageSizeYamlConverter())
-                .WithTypeConverter(new NotesStringYamlConverter())
-                .ConfigureDefaultValuesHandling(
-                    DefaultValuesHandling.OmitNull |
-                    DefaultValuesHandling.OmitEmptyCollections
-                )
-                .Build();
-
-            var payload = new OrderedDictionary
+            // Always write current schema version when app writes the file.
+            var root = new YamlRoot
             {
-                ["resources"] = resourceCollection.Resources.Select(SerializeResource).ToList()
+                Version = CurrentSchemaVersion,
+                Resources = resourceCollection.Resources
             };
 
-            await fileStore.WriteAllTextAsync(
-                filePath,
-                serializer.Serialize(payload));
+            await SaveRootAsync(root);
         }
         finally
         {
@@ -168,12 +190,57 @@ public sealed class YamlResourceCollection(
         }
     }
 
-    private async Task<List<Resource>> LoadFromFileAsync()
+    // ----------------------------
+    // Versioning + migration
+    // ----------------------------
+
+    private async Task BackupOriginalAsync(string originalYaml)
     {
-        var yaml = await fileStore.ReadAllTextAsync(filePath);
-        if (string.IsNullOrWhiteSpace(yaml))
-            return new List<Resource>();
+        // Timestamped backup for safe rollback
+        var backupPath = $"{filePath}.bak.{DateTime.UtcNow:yyyyMMddHHmmss}";
+        await fileStore.WriteAllTextAsync(backupPath, originalYaml);
+    }
+
+    private Task<YamlRoot> MigrateAsync(YamlRoot root)
+    {
+        // Step-by-step migrations until we reach CurrentSchemaVersion
+        while (root.Version < CurrentSchemaVersion)
+        {
+            root = root.Version switch
+            {
+                0 => MigrateV0ToV1(root),
+                _ => throw new InvalidOperationException(
+                    $"No migration is defined from version {root.Version} to {root.Version + 1}.")
+            };
+        }
+
+        return Task.FromResult(root);
+    }
 
+    private YamlRoot MigrateV0ToV1(YamlRoot root)
+    {
+        // V0 -> V1 example migration:
+        // - Ensure 'kind' is normalized on all resources
+        // - Ensure tags collections aren’t null
+        if (root.Resources != null)
+        {
+            foreach (var r in root.Resources)
+            {
+                r.Kind = GetKind(r);
+                r.Tags ??= [];
+            }
+        }
+
+        root.Version = 1;
+        return root;
+    }
+
+    // ----------------------------
+    // YAML read/write
+    // ----------------------------
+
+    private YamlRoot? DeserializeRoot(string yaml)
+    {
         var deserializer = new DeserializerBuilder()
             .WithNamingConvention(CamelCaseNamingConvention.Instance)
             .WithCaseInsensitivePropertyMatching()
@@ -199,15 +266,43 @@ public sealed class YamlResourceCollection(
 
         try
         {
+            // If 'version' is missing, int defaults to 0 => treated as V0.
             var root = deserializer.Deserialize<YamlRoot>(yaml);
-            return root?.Resources ?? new List<Resource>();
+
+            // If YAML had only "resources:" previously, this will still work.
+            root ??= new YamlRoot { Version = 0, Resources = new List<Resource>() };
+            root.Resources ??= new List<Resource>();
+
+            return root;
         }
         catch (YamlException)
         {
-            return new List<Resource>();
+            return null;
         }
     }
 
+    private async Task SaveRootAsync(YamlRoot root)
+    {
+        var serializer = new SerializerBuilder()
+            .WithNamingConvention(CamelCaseNamingConvention.Instance)
+            .WithTypeConverter(new StorageSizeYamlConverter())
+            .WithTypeConverter(new NotesStringYamlConverter())
+            .ConfigureDefaultValuesHandling(
+                DefaultValuesHandling.OmitNull |
+                DefaultValuesHandling.OmitEmptyCollections
+            )
+            .Build();
+
+        // Preserve ordering: version first, then resources
+        var payload = new OrderedDictionary
+        {
+            ["version"] = root.Version,
+            ["resources"] = (root.Resources ?? new List<Resource>()).Select(SerializeResource).ToList()
+        };
+
+        await fileStore.WriteAllTextAsync(filePath, serializer.Serialize(payload));
+    }
+
     private string GetKind(Resource resource)
     {
         return resource switch
@@ -249,7 +344,7 @@ public sealed class YamlResourceCollection(
             .Deserialize<Dictionary<string, object?>>(yaml);
 
         foreach (var (key, value) in props)
-            if (key != "kind")
+            if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase))
                 map[key] = value;
 
         return map;
@@ -258,5 +353,6 @@ public sealed class YamlResourceCollection(
 
 public class YamlRoot
 {
+    public int Version { get; set; } // <- NEW: YAML schema version
     public List<Resource>? Resources { get; set; }
-}
+}

+ 1 - 1
RackPeek.Domain/Resources/Resource.cs

@@ -47,7 +47,7 @@ public abstract class Resource
 
     public required string Name { get; set; }
 
-    public string[]? Tags { get; set; } = [];
+    public string[] Tags { get; set; } = [];
     public string? Notes { get; set; }
 
     public string? RunsOn { get; set; }

+ 1 - 3
RackPeek.Domain/UseCases/Tags/AddResourceTagUseCase.cs

@@ -23,9 +23,7 @@ public class AddTagUseCase<T>(IResourceCollection repo) : IAddTagUseCase<T> wher
         if (resource == null)
             throw new NotFoundException($"Resource '{name}' not found.");
 
-        if (resource.Tags == null)
-            resource.Tags = [tag];
-        else if (!resource.Tags.Contains(tag))
+        if (!resource.Tags.Contains(tag))
             resource.Tags = [..resource.Tags, tag];
         else
             // Tag already exists

+ 2 - 2
RackPeek.Domain/UseCases/Tags/RemoveResourceTagUseCase.cs

@@ -24,7 +24,7 @@ public class RemoveTagUseCase<T>(IResourceCollection repo)
         var resource = await repo.GetByNameAsync(name)
                        ?? throw new NotFoundException($"Resource '{name}' not found.");
 
-        if (resource.Tags is null || resource.Tags.Length == 0)
+        if (resource.Tags.Length == 0)
             return;
 
         var updated = resource.Tags
@@ -34,7 +34,7 @@ public class RemoveTagUseCase<T>(IResourceCollection repo)
         if (updated.Length == resource.Tags.Length)
             return; // tag didn't exist
 
-        resource.Tags = updated.Length == 0 ? null : updated;
+        resource.Tags = updated;
 
         await repo.UpdateAsync(resource);
     }

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

@@ -13,7 +13,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{AccessPoint.Name}")"
                      class="block"
-                     data-testid="open-accesspoint-link">
+                     data-testid="@($"open-accesspoint-{AccessPoint.Name.Replace(" ", "-")}-link")">
                 @AccessPoint.Name
             </NavLink>
         </div>

+ 1 - 1
Shared.Rcl/Components/ResourceTagEditor.razor

@@ -17,7 +17,7 @@
         </div>
     </div>
 
-    @if (Resource.Tags?.Any() == true)
+    @if (Resource.Tags.Any())
     {
         <div class="flex flex-wrap gap-2">
             @foreach (var tag in Resource.Tags.OrderBy(t => t))

+ 1 - 1
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -31,7 +31,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Desktop.Name}")"
                      class="block"
-                     data-testid="open-desktop-link">
+                     data-testid=@($"desktop-item-{Desktop.Name.Replace(" ", "-")}-link")>
                 @Desktop.Name
             </NavLink>
         </div>

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

@@ -19,7 +19,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Firewall.Name}")"
                      class="block"
-                     data-testid="open-firewall-link">
+                     data-testid=@($"firewall-item-{Firewall.Name.Replace(" ", "-")}-link")>
                 @Firewall.Name
             </NavLink>
         </div>

+ 8 - 5
Shared.Rcl/Hardware/HardwareDetailsPage.razor

@@ -108,21 +108,24 @@
 
     private Hardware? _hardware;
     private bool _loading = true;
-    HardwareDependencyTree? _tree;
+    private HardwareDependencyTree? _tree;
 
-    protected override async Task OnInitializedAsync()
+    protected override async Task OnParametersSetAsync()
     {
-        _hardware = await Repo.GetByNameAsync(HardwareName) as Hardware;
+        _loading = true;
+        _hardware = null;
         _tree = null;
+
+        _hardware = await Repo.GetByNameAsync(HardwareName) as Hardware;
+
         if (!string.IsNullOrEmpty(_hardware?.Name))
         {
-            _tree = await GetHardwareSystemTreeUseCase.ExecuteAsync(_hardware?.Name!);
+            _tree = await GetHardwareSystemTreeUseCase.ExecuteAsync(_hardware.Name);
         }
 
         _loading = false;
     }
 
-
     private async Task DeleteCallback(string obj)
     {
         Nav.NavigateTo("/hardware/tree");

+ 1 - 1
Shared.Rcl/Laptops/LaptopCardComponent.razor

@@ -28,7 +28,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Laptop.Name}")"
                      class="block"
-                     data-testid="open-laptop-link">
+                     data-testid=@($"laptop-item-{Laptop.Name.Replace(" ", "-")}-link")>
                 @Laptop.Name
             </NavLink>
         </div>

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

@@ -21,7 +21,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Router.Name}")"
                      class="block"
-                     data-testid="open-router-link">
+                     data-testid=@($"router-item-{Router.Name.Replace(" ", "-")}-link")>
                 @Router.Name
             </NavLink>
         </div>

+ 27 - 12
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -32,8 +32,8 @@
      data-testid=@($"server-item-{Server.Name.Replace(" ", "-")}")>
     <div class="flex justify-between items-center mb-3">
         <div class="text-zinc-100 hover:text-emerald-300">
-            <NavLink href="@($"resources/hardware/{Server.Name}")" class="block">
-
+            <NavLink href="@($"resources/hardware/{Server.Name}")" class="block"
+                     data-testid=@($"server-item-{Server.Name.Replace(" ", "-")}-link")>
                 @Server.Name
             </NavLink>
 
@@ -241,7 +241,8 @@
             <MarkdownViewer
                 Value="@Server.Notes"
                 ShowEditButton="true"
-                OnEdit="BeginNotesEdit"/>
+                OnEdit="BeginNotesEdit"
+                TestIdPrefix="server-markdown"/>
         }
         else
         {
@@ -249,7 +250,9 @@
                 @bind-Value="_notesDraft"
                 ShowActionButtons="true"
                 OnSave="SaveNotes"
-                OnCancel="CancelNotesEdit"/>
+                OnCancel="CancelNotesEdit"
+                TestIdPrefix="server-markdown"/>
+
         }
     </div>
 
@@ -260,34 +263,42 @@
     IsOpenChanged="v => _cpuModalOpen = v"
     Value="@_editingCpu"
     OnSubmit="HandleCpuSubmit"
-    OnDelete="HandleCpuDelete"/>
+    OnDelete="HandleCpuDelete"
+    TestIdPrefix="server-cpu"/>
+
 
 <RamModal
     IsOpen="@_isRamModalOpen"
     IsOpenChanged="v => _isRamModalOpen = v"
     Value="@Server.Ram"
-    OnSubmit="HandleRamSubmit"/>
+    OnSubmit="HandleRamSubmit"
+    TestIdPrefix="server-ram"/>
+
 
 <DriveModal
     IsOpen="@_driveModalOpen"
     IsOpenChanged="v => _driveModalOpen = v"
     Value="@_editingDrive"
     OnSubmit="HandleDriveSubmit"
-    OnDelete="HandleDriveDelete"/>
+    OnDelete="HandleDriveDelete"
+    TestIdPrefix="server-drive"/>
+
 
 <NicModal
     IsOpen="@_nicModalOpen"
     IsOpenChanged="v => _nicModalOpen = v"
     Value="@_editingNic"
     OnSubmit="HandleNicSubmit"
-    OnDelete="HandleNicDelete"/>
+    OnDelete="HandleNicDelete"
+    TestIdPrefix="server-nic"/>
 
 <GpuModal
     IsOpen="@_gpuModalOpen"
     IsOpenChanged="v => _gpuModalOpen = v"
     Value="@_editingGpu"
     OnSubmit="HandleGpuSubmit"
-    OnDelete="HandleGpuDelete"/>
+    OnDelete="HandleGpuDelete"
+    TestIdPrefix="server-gpu"/>
 
 <ConfirmModal
     IsOpen="_confirmDeleteOpen"
@@ -296,7 +307,7 @@
     ConfirmText="Delete"
     ConfirmClass="bg-red-600 hover:bg-red-500"
     OnConfirm="DeleteServer"
-    TestIdPrefix="Server">
+    TestIdPrefix="server-delete">
     Are you sure you want to delete <strong>@Server.Name</strong>?
     <br/>
     This will detach all dependent systems.
@@ -309,7 +320,9 @@
     Description="Enter a new name for this server"
     Label="New server name"
     Value="@Server.Name"
-    OnSubmit="HandleRenameSubmit"/>
+    OnSubmit="HandleRenameSubmit"
+    TestIdPrefix="server-rename" />
+
 <StringValueModal
     IsOpen="_cloneOpen"
     IsOpenChanged="v => _cloneOpen = v"
@@ -317,7 +330,9 @@
     Description="Enter a name for the cloned resource"
     Label="New resource name"
     Value="@($"{Server.Name}-copy")"
-    OnSubmit="HandleCloneSubmit"/>
+    OnSubmit="HandleCloneSubmit"
+    TestIdPrefix="server-clone" />
+
 
 @code {
     [Parameter] [EditorRequired] public Server Server { get; set; } = default!;

+ 46 - 37
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -8,7 +8,8 @@
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900"
      data-testid=@($"service-item-{Service.Name.Replace(" ", "-")}")>
     <div class="flex justify-between items-center mb-3">
-        <NavLink href="@($"resources/services/{Service.Name}")" class="block">
+        <NavLink href="@($"resources/services/{Service.Name}")" class="block"
+                 data-testid=@($"service-item-{Service.Name.Replace(" ", "-")}-link")>
 
             <div class="text-zinc-100 hover:text-emerald-300">
                 @Service.Name
@@ -69,82 +70,84 @@
     <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
 
         <!-- IP -->
-        <div>
+        <div data-testid="service-ip-section">
             <div class="text-zinc-400 mb-1">IP</div>
+
             @if (_isEditing)
             {
                 <input
+                    data-testid="service-ip-input"
                     class="w-full px-3 py-2 rounded-md
-                           bg-zinc-800 text-zinc-100
-                           border border-zinc-600
-                           placeholder-zinc-500
-                           focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
-                           hover:border-zinc-400
-                           transition-colors duration-150
-                           cursor-text"
+                   bg-zinc-800 text-zinc-100
+                   border border-zinc-600"
                     @bind="_edit.Ip"/>
             }
             else if (!string.IsNullOrWhiteSpace(Service.Network?.Ip))
             {
-                <div class="text-zinc-300">@Service.Network!.Ip</div>
+                <div class="text-zinc-300"
+                     data-testid="service-ip-value">
+                    @Service.Network!.Ip
+                </div>
             }
         </div>
 
+
         <!-- Port -->
-        <div>
+        <div data-testid="service-port-section">
             <div class="text-zinc-400 mb-1">Port</div>
+
             @if (_isEditing)
             {
                 <input type="number"
+                       data-testid="service-port-input"
                        @bind="_edit.Port"/>
             }
             else if (Service.Network?.Port.HasValue == true)
             {
-                <div class="text-zinc-300">@Service.Network.Port</div>
+                <div class="text-zinc-300"
+                     data-testid="service-port-value">
+                    @Service.Network.Port
+                </div>
             }
         </div>
 
+
         <!-- Protocol -->
-        <div>
+        <div data-testid="service-protocol-section">
             <div class="text-zinc-400 mb-1">Protocol</div>
+
             @if (_isEditing)
             {
                 <input
-                    class="w-full px-3 py-2 rounded-md
-                           bg-zinc-800 text-zinc-100
-                           border border-zinc-600
-                           placeholder-zinc-500
-                           focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
-                           hover:border-zinc-400
-                           transition-colors duration-150
-                           cursor-text"
+                    data-testid="service-protocol-input"
+                    class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
                     @bind="_edit.Protocol"/>
             }
             else if (!string.IsNullOrWhiteSpace(Service.Network?.Protocol))
             {
-                <div class="text-zinc-300">@Service.Network!.Protocol</div>
+                <div class="text-zinc-300"
+                     data-testid="service-protocol-value">
+                    @Service.Network!.Protocol
+                </div>
             }
         </div>
 
+
         <!-- URL -->
-        <div>
+        <div data-testid="service-url-section">
             <div class="text-zinc-400 mb-1">URL</div>
+
             @if (_isEditing)
             {
                 <input
-                    class="w-full px-3 py-2 rounded-md
-                           bg-zinc-800 text-zinc-100
-                           border border-zinc-600
-                           placeholder-zinc-500
-                           focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500
-                           hover:border-zinc-400
-                           transition-colors duration-150
-                           cursor-text"
+                    data-testid="service-url-input"
+                    class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
                     @bind="_edit.Url"/>
             }
             else if (!string.IsNullOrWhiteSpace(Service.Network?.Url))
             {
                 <a href="@Service.Network!.Url"
+                   data-testid="service-url-value"
                    target="_blank"
                    rel="noopener noreferrer"
                    class="text-emerald-400 hover:underline break-all">
@@ -153,14 +156,16 @@
             }
         </div>
 
+
         <!-- Runs On -->
-        <div>
+        <div data-testid="service-runson-section">
             <div class="text-zinc-400 mb-1">Runs On</div>
+
             @if (_isEditing)
             {
                 <button
+                    data-testid="service-runson-button"
                     class="hover:text-emerald-400"
-                    title="Edit Runs On"
                     @onclick="() => _selectParentOpen = true">
                     @if (!string.IsNullOrWhiteSpace(Service.RunsOn))
                     {
@@ -170,18 +175,19 @@
                     {
                         @("Edit parent")
                     }
-
                 </button>
             }
             else if (!string.IsNullOrWhiteSpace(Service.RunsOn))
             {
                 <NavLink href="@($"resources/systems/{Service.RunsOn}")"
+                         data-testid="service-runson-link"
                          class="text-emerald-400">
                     @Service.RunsOn
                 </NavLink>
             }
         </div>
 
+
         <ResourceTagEditor Resource="Service"/>
 
         <div class="md:col-span-2">
@@ -220,7 +226,7 @@
     Title="Delete service"
     ConfirmText="Delete"
     ConfirmClass="bg-red-600 hover:bg-red-500"
-    OnConfirm="DeleteServer" TestIdPrefix="Service">
+    OnConfirm="DeleteServer" TestIdPrefix="service-delete">
     Are you sure you want to delete <strong>@Service.Name</strong>?
 </ConfirmModal>
 
@@ -231,7 +237,8 @@
     Description="Enter a name for the cloned service"
     Label="New service name"
     Value="@($"{Service.Name}-copy")"
-    OnSubmit="HandleCloneSubmit"/>
+    OnSubmit="HandleCloneSubmit"
+    TestIdPrefix="service-clone"/>
 
 <StringValueModal
     IsOpen="_renameOpen"
@@ -240,7 +247,9 @@
     Description="Enter a new name for this service"
     Label="New service name"
     Value="@Service.Name"
-    OnSubmit="HandleRenameSubmit"/>
+    OnSubmit="HandleRenameSubmit"
+    TestIdPrefix="service-rename"/>
+
 
 
 @code

+ 4 - 2
Shared.Rcl/Services/ServiceDetailsPage.razor

@@ -33,12 +33,14 @@
 
     private Service? _service;
     private bool _loading = true;
-
-    protected override async Task OnInitializedAsync()
+    
+    protected override async Task OnParametersSetAsync()
     {
+        _loading = true;
         _service = await Repo.GetByNameAsync<Service>(ServiceName);
         _loading = false;
     }
+    
 
     private void OnDeleted(string obj)
     {

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

@@ -19,7 +19,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Switch.Name}")"
                      class="block"
-                     data-testid="open-switch-link">
+                     data-testid=@($"switch-item-{Switch.Name.Replace(" ", "-")}-link")>
                 @Switch.Name
             </NavLink>
         </div>

+ 11 - 5
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -15,7 +15,8 @@
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900"
      data-testid=@($"system-item-{System.Name.Replace(" ", "-")}")>
     <div class="flex justify-between items-center mb-3">
-        <NavLink href="@($"resources/systems/{System.Name}")" class="block">
+        <NavLink href="@($"resources/systems/{System.Name}")" class="block"
+                 data-testid=@($"system-item-{System.Name.Replace(" ", "-")}-link")>
 
             <div class="text-zinc-100 hover:text-emerald-300">
                 @System.Name
@@ -263,7 +264,9 @@
     IsOpenChanged="v => _driveModalOpen = v"
     Value="@_editingDrive"
     OnSubmit="HandleDriveSubmit"
-    OnDelete="HandleDriveDelete"/>
+    OnDelete="HandleDriveDelete"
+    TestIdPrefix="system"/>
+
 
 <ConfirmModal
     IsOpen="_confirmDeleteOpen"
@@ -272,7 +275,7 @@
     ConfirmText="Delete"
     ConfirmClass="bg-red-600 hover:bg-red-500"
     OnConfirm="DeleteServer"
-    TestIdPrefix="System">>
+    TestIdPrefix="system-delete">
     Are you sure you want to delete <strong>@System.Name</strong>?
     <br/>
     This will detach all dependent systems.
@@ -285,7 +288,9 @@
     Description="Enter a name for the cloned system"
     Label="New system name"
     Value="@($"{System.Name}-copy")"
-    OnSubmit="HandleCloneSubmit"/>
+    OnSubmit="HandleCloneSubmit"
+    TestIdPrefix="system-clone"/>
+
 
 @code {
     [Parameter] [EditorRequired] public SystemResource System { get; set; } = default!;
@@ -389,7 +394,8 @@
     Description="Enter a new name for this system"
     Label="New system name"
     Value="@System.Name"
-    OnSubmit="HandleRenameSubmit"/>
+    OnSubmit="HandleRenameSubmit"
+    TestIdPrefix="system-rename"/>
 
 @code {
     private bool _confirmDeleteOpen;

+ 2 - 1
Shared.Rcl/Systems/SystemsDetailsPage.razor

@@ -58,8 +58,9 @@
     private bool _loading = true;
     SystemDependencyTree? _tree;
 
-    protected override async Task OnInitializedAsync()
+    protected override async Task OnParametersSetAsync()
     {
+        _loading = true;
         _system = await Repo.GetByNameAsync<SystemResource>(SystemName);
         _tree = null;
         if (!string.IsNullOrEmpty(_system?.Name))

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

@@ -14,7 +14,7 @@
         <div class="text-zinc-100 hover:text-emerald-300">
             <NavLink href="@($"resources/hardware/{Ups.Name}")"
                      class="block"
-                     data-testid="open-ups-link">
+                     data-testid=@($"ups-item-{Ups.Name.Replace(" ", "-")}-link")>
                 @Ups.Name
             </NavLink>
         </div>

+ 249 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -0,0 +1,249 @@
+using System.Globalization;
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class AccessPointCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Model_And_Speed_And_Save()
+    {
+        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 newModel = "AP-Model-9000";
+            var newSpeed = 2.5;
+
+            await card.BeginEditAsync(name);
+            await card.SetModelAsync(name, newModel);
+            await card.SetSpeedAsync(name, newSpeed);
+            await card.SaveAsync(name);
+
+            await page.ReloadAsync();
+            
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.SpeedValue(name))
+                .ToHaveTextAsync($"{newSpeed.ToString(CultureInfo.InvariantCulture)} Gbps");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Cancel_Edit_And_Changes_Are_Not_Applied()
+    {
+        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);
+
+            // Capture current values (may be empty depending on seed data)
+            var beforeModel = await card.ModelSection(name).TextContentAsync();
+            var beforeSpeed = await card.SpeedSection(name).TextContentAsync();
+
+            await card.BeginEditAsync(name);
+            await card.SetModelAsync(name, "SHOULD-NOT-SAVE");
+            await card.SetSpeedAsync(name, 9.9);
+            await card.CancelEditAsync(name);
+
+            var afterModel = await card.ModelSection(name).TextContentAsync();
+            var afterSpeed = await card.SpeedSection(name).TextContentAsync();
+
+            Assert.Equal(beforeModel, afterModel);
+            Assert.Equal(beforeSpeed, afterSpeed);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_AccessPoint_From_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var newName = $"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);
+
+            await card.RenameAsync(name, newName);
+
+            // After rename, the card test id uses the new name
+            await card.AssertCardVisibleAsync(newName);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_AccessPoint_From_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var cloneName = $"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);
+
+            await card.CloneAsync(name, cloneName);
+
+            // Clone navigates to the clone details page
+            await card.AssertCardVisibleAsync(cloneName);
+
+            // Cleanup: delete clone then original (both from details pages)
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+            await list.OpenAccessPointAsync(name);
+
+            await card.AssertCardVisibleAsync(name);
+            await card.DeleteAsync(name);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_AccessPoint_From_Card_And_Is_Redirected()
+    {
+        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);
+
+            await card.DeleteAsync(name);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // Verify it’s gone from the list
+            await hardwareTree.GotoAccessPointsListAsync();
+            await list.AssertLoadedAsync();
+            await list.AssertAccessPointDoesNotExist(name);
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 284 - 0
Tests.E2e/DesktopCardTests.cs

@@ -0,0 +1,284 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class DesktopCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Desktop()
+    {
+        var (context, page) = await CreatePageAsync();
+        var desktopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoDesktopsListAsync();
+
+            var listPage = new DesktopsListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddDesktopAsync(desktopName);
+
+            // creation should navigate to details page
+            await page.WaitForURLAsync($"**/resources/hardware/{desktopName}");
+
+            // delete from details page (card)
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(desktopName)).ToBeVisibleAsync();
+            await card.DeleteDesktopAsync(desktopName);
+
+            // after deletion you redirect (your page does Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Desktop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var renamedName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoDesktopsListAsync();
+
+            var listPage = new DesktopsListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddDesktopAsync(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(originalName)).ToBeVisibleAsync();
+
+            await card.RenameDesktopAsync(originalName, renamedName);
+            await Assertions.Expect(card.DesktopItem(renamedName)).ToBeVisibleAsync();
+
+            // cleanup
+            await card.DeleteDesktopAsync(renamedName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Desktop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var cloneName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoDesktopsListAsync();
+
+            var listPage = new DesktopsListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddDesktopAsync(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(originalName)).ToBeVisibleAsync();
+
+            await card.CloneDesktopAsync(originalName, cloneName);
+            await Assertions.Expect(card.DesktopItem(cloneName)).ToBeVisibleAsync();
+
+            // cleanup: delete clone then original
+            await card.DeleteDesktopAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // go back to original and delete it too
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{originalName}");
+            await Assertions.Expect(card.DesktopItem(originalName)).ToBeVisibleAsync();
+            await card.DeleteDesktopAsync(originalName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Desktop_Notes_And_Save()
+    {
+        var (context, page) = await CreatePageAsync();
+        var desktopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoDesktopsListAsync();
+
+            var listPage = new DesktopsListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddDesktopAsync(desktopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{desktopName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(desktopName)).ToBeVisibleAsync();
+
+            // start editing notes via MarkdownViewer edit button
+            await card.NotesViewerEditButton(desktopName).ClickAsync();
+
+            // ensure editor visible then fill + save
+            await Assertions.Expect(card.NotesEditorRoot(desktopName)).ToBeVisibleAsync();
+            await card.NotesEditorTextarea(desktopName).FillAsync(notes);
+            await card.NotesEditorSave(desktopName).ClickAsync();
+
+            // viewer back, and content should contain the notes
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteDesktopAsync(desktopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Desktop_Notes_And_Cancel()
+    {
+        var (context, page) = await CreatePageAsync();
+        var desktopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoDesktopsListAsync();
+
+            var listPage = new DesktopsListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddDesktopAsync(desktopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{desktopName}");
+
+            var card = new DesktopCardPom(page);
+            await Assertions.Expect(card.DesktopItem(desktopName)).ToBeVisibleAsync();
+
+            await card.NotesViewerEditButton(desktopName).ClickAsync();
+            await Assertions.Expect(card.NotesEditorRoot(desktopName)).ToBeVisibleAsync();
+
+            await card.NotesEditorTextarea(desktopName).FillAsync(notes);
+            await card.NotesEditorCancel(desktopName).ClickAsync();
+
+            // viewer should be back, and should NOT show the cancelled notes
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(desktopName)).Not.ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteDesktopAsync(desktopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 231 - 0
Tests.E2e/FirewallCardTests.cs

@@ -0,0 +1,231 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class FirewallCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Firewall_Model_And_Features()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{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 list.AssertFirewallExists(name);
+
+            // Go to details page so we can exercise the card component
+            await list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await Assertions.Expect(card.FirewallItem(name)).ToBeVisibleAsync();
+
+            // Enter edit mode
+            await card.EnterEditModeAsync(name);
+
+            // Update Model + toggle features
+            var newModel = "e2e-model-123";
+            await card.ModelInput(name).FillAsync(newModel);
+
+            // Ensure both toggles are ON (idempotent)
+            if (!await card.ManagedCheckbox(name).IsCheckedAsync())
+                await card.ManagedCheckbox(name).CheckAsync();
+            if (!await card.PoeCheckbox(name).IsCheckedAsync())
+                await card.PoeCheckbox(name).CheckAsync();
+
+            await card.SaveEditsAsync(name);
+
+            // Validate view mode reflects changes
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.ManagedBadge(name)).ToBeVisibleAsync();
+            await Assertions.Expect(card.PoeBadge(name)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Firewall_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var renamed = $"{name}-ren";
+
+        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 list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await card.RenameFirewallAsync(name, renamed);
+
+            // after rename we should be on new URL and see the renamed card root
+            await Assertions.Expect(card.FirewallItem(renamed)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Firewall_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var clone = $"{name}-cpy";
+
+        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 list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await card.CloneFirewallAsync(name, clone);
+
+            // should be on the clone's details URL and see the clone card
+            await Assertions.Expect(card.FirewallItem(clone)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_Firewall_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{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 list.OpenFirewallAsync(name);
+
+            var card = new FirewallCardPom(page);
+
+            await card.DeleteFirewallAsync(name);
+
+            // After delete, your page navigates away (Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 284 - 0
Tests.E2e/LaptopCardTests.cs

@@ -0,0 +1,284 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class LaptopCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Laptop()
+    {
+        var (context, page) = await CreatePageAsync();
+        var laptopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoLaptopsListAsync();
+
+            var listPage = new LaptopListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddLaptopAsync(laptopName);
+
+            // creation should navigate to details page
+            await page.WaitForURLAsync($"**/resources/hardware/{laptopName}");
+
+            // delete from details page (card)
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(laptopName)).ToBeVisibleAsync();
+            await card.DeleteLaptopAsync(laptopName);
+
+            // after deletion you redirect (your page does Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Laptop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var renamedName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoLaptopsListAsync();
+
+            var listPage = new LaptopListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddLaptopAsync(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(originalName)).ToBeVisibleAsync();
+
+            await card.RenameLaptopAsync(originalName, renamedName);
+            await Assertions.Expect(card.LaptopItem(renamedName)).ToBeVisibleAsync();
+
+            // cleanup
+            await card.DeleteLaptopAsync(renamedName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Laptop_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var originalName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var cloneName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoLaptopsListAsync();
+
+            var listPage = new LaptopListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddLaptopAsync(originalName);
+            await page.WaitForURLAsync($"**/resources/hardware/{originalName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(originalName)).ToBeVisibleAsync();
+
+            await card.CloneLaptopAsync(originalName, cloneName);
+            await Assertions.Expect(card.LaptopItem(cloneName)).ToBeVisibleAsync();
+
+            // cleanup: delete clone then original
+            await card.DeleteLaptopAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // go back to original and delete it too
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{originalName}");
+            await Assertions.Expect(card.LaptopItem(originalName)).ToBeVisibleAsync();
+            await card.DeleteLaptopAsync(originalName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Laptop_Notes_And_Save()
+    {
+        var (context, page) = await CreatePageAsync();
+        var laptopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoLaptopsListAsync();
+
+            var listPage = new LaptopListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddLaptopAsync(laptopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{laptopName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(laptopName)).ToBeVisibleAsync();
+
+            // start editing notes via MarkdownViewer edit button
+            await card.NotesViewerEditButton(laptopName).ClickAsync();
+
+            // ensure editor visible then fill + save
+            await Assertions.Expect(card.NotesEditorRoot(laptopName)).ToBeVisibleAsync();
+            await card.NotesEditorTextarea(laptopName).FillAsync(notes);
+            await card.NotesEditorSave(laptopName).ClickAsync();
+
+            // viewer back, and content should contain the notes
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteLaptopAsync(laptopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Laptop_Notes_And_Cancel()
+    {
+        var (context, page) = await CreatePageAsync();
+        var laptopName = $"e2e-dt-{Guid.NewGuid():N}"[..16];
+        var notes = $"notes-{Guid.NewGuid():N}";
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoLaptopsListAsync();
+
+            var listPage = new LaptopListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            await listPage.AddLaptopAsync(laptopName);
+            await page.WaitForURLAsync($"**/resources/hardware/{laptopName}");
+
+            var card = new LaptopCardPom(page);
+            await Assertions.Expect(card.LaptopItem(laptopName)).ToBeVisibleAsync();
+
+            await card.NotesViewerEditButton(laptopName).ClickAsync();
+            await Assertions.Expect(card.NotesEditorRoot(laptopName)).ToBeVisibleAsync();
+
+            await card.NotesEditorTextarea(laptopName).FillAsync(notes);
+            await card.NotesEditorCancel(laptopName).ClickAsync();
+
+            // viewer should be back, and should NOT show the cancelled notes
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).ToBeVisibleAsync();
+            await Assertions.Expect(card.NotesViewerRoot(laptopName)).Not.ToContainTextAsync(notes);
+
+            // cleanup
+            await card.DeleteLaptopAsync(laptopName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(await page.ContentAsync());
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

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

@@ -0,0 +1,167 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class AccessPointCardPom(IPage page)
+{
+    // Root
+    public ILocator Card(string accessPointName)
+        => page.GetByTestId($"accesspoint-item-{Sanitize(accessPointName)}");
+
+    // Link / navigation
+    public ILocator OpenLink(string accessPointName)
+        => Card(accessPointName).GetByTestId("open-accesspoint-link");
+
+    // Top-right actions (view mode)
+    public ILocator EditButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("edit-accesspoint-button");
+
+    public ILocator RenameButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("rename-accesspoint-button");
+
+    public ILocator CloneButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("clone-accesspoint-button");
+
+    public ILocator DeleteButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("delete-accesspoint-button");
+
+    // Top-right actions (edit mode)
+    public ILocator SaveButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("save-accesspoint-button");
+
+    public ILocator CancelButton(string accessPointName)
+        => Card(accessPointName).GetByTestId("cancel-accesspoint-button");
+
+    // Fields
+    public ILocator ModelSection(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-model-section");
+
+    public ILocator ModelInput(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-model-input");
+
+    public ILocator ModelValue(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-model-value");
+
+    public ILocator SpeedSection(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-speed-section");
+
+    public ILocator SpeedInput(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-speed-input");
+
+    public ILocator SpeedValue(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-speed-value");
+
+    // Notes (prefixed components)
+    public ILocator NotesViewerRoot(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-viewer-markdown-viewer");
+
+    public ILocator NotesViewerContent(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-viewer-markdown-viewer-content");
+
+    public ILocator NotesViewerEmpty(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-viewer-markdown-viewer-empty");
+
+    public ILocator NotesEditorRoot(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-editor-markdown-editor");
+
+    public ILocator NotesEditorTextarea(string accessPointName)
+        => Card(accessPointName).GetByTestId("accesspoint-notes-editor-markdown-editor-textarea");
+
+    // Modals
+    public ILocator DeleteConfirmModal => page.GetByTestId("AccessPoint-confirm-modal");
+    public ILocator DeleteConfirmButton => page.GetByTestId("AccessPoint-confirm-modal-confirm");
+    public ILocator DeleteCancelButton => page.GetByTestId("AccessPoint-confirm-modal-cancel");
+
+    public ILocator RenameModal => page.GetByTestId("accesspoint-rename-string-value-modal");
+    public ILocator RenameModalInput => page.GetByTestId("accesspoint-rename-string-value-modal-input");
+    public ILocator RenameModalSubmit => page.GetByTestId("accesspoint-rename-string-value-modal-submit");
+    public ILocator RenameModalCancel => page.GetByTestId("accesspoint-rename-string-value-modal-cancel");
+
+    public ILocator CloneModal => page.GetByTestId("accesspoint-clone-string-value-modal");
+    public ILocator CloneModalInput => page.GetByTestId("accesspoint-clone-string-value-modal-input");
+    public ILocator CloneModalSubmit => page.GetByTestId("accesspoint-clone-string-value-modal-submit");
+    public ILocator CloneModalCancel => page.GetByTestId("accesspoint-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation (hardware details page)
+    // -------------------------------------------------
+
+    public async Task GotoHardwareAsync(string baseUrl, string hardwareName)
+    {
+        await page.GotoAsync($"{baseUrl}/resources/hardware/{hardwareName}");
+        await AssertCardVisibleAsync(hardwareName);
+    }
+
+    public async Task AssertCardVisibleAsync(string accessPointName)
+    {
+        await Assertions.Expect(Card(accessPointName)).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task OpenAsync(string accessPointName)
+    {
+        await OpenLink(accessPointName).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{accessPointName}");
+    }
+
+    public async Task BeginEditAsync(string accessPointName)
+    {
+        await EditButton(accessPointName).ClickAsync();
+        await Assertions.Expect(ModelInput(accessPointName)).ToBeVisibleAsync();
+    }
+
+    public async Task SetModelAsync(string accessPointName, string model)
+    {
+        await ModelInput(accessPointName).FillAsync(model);
+    }
+
+    public async Task SetSpeedAsync(string accessPointName, double speed)
+    {
+        await SpeedInput(accessPointName).FillAsync(speed.ToString(System.Globalization.CultureInfo.InvariantCulture));
+    }
+
+    public async Task SaveAsync(string accessPointName)
+    {
+        await SaveButton(accessPointName).ClickAsync();
+        await Assertions.Expect(ModelSection(accessPointName)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditAsync(string accessPointName)
+    {
+        await CancelButton(accessPointName).ClickAsync();
+        await Assertions.Expect(EditButton(accessPointName)).ToBeVisibleAsync();
+    }
+
+    public async Task DeleteAsync(string accessPointName)
+    {
+        await DeleteButton(accessPointName).ClickAsync();
+        await DeleteConfirmButton.ClickAsync();
+
+        await Assertions.Expect(Card(accessPointName))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameAsync(string accessPointName, string newName)
+    {
+        await RenameButton(accessPointName).ClickAsync();
+        await RenameModalInput.FillAsync(newName);
+        await RenameModalSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneAsync(string accessPointName, string cloneName)
+    {
+        await CloneButton(accessPointName).ClickAsync();
+        await CloneModalInput.FillAsync(cloneName);
+        await CloneModalSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 6 - 2
Tests.E2e/PageObjectModels/AccessPointListPom.cs

@@ -15,7 +15,6 @@ public class AccessPointsListPom(IPage page)
 
     public ILocator AddSection => page.GetByTestId("accesspoints-add-section");
 
-    // These must match your AddResourceComponent test IDs
     public ILocator AddInput => page.GetByTestId("add-accesspoint-input");
     public ILocator AddButton => page.GetByTestId("add-accesspoint-button");
 
@@ -27,6 +26,11 @@ public class AccessPointsListPom(IPage page)
     {
         return page.GetByTestId($"accesspoint-item-{Sanitize(name)}");
     }
+    
+    public ILocator AccessPointItemLink(string name)
+    {
+        return page.GetByTestId($"open-accesspoint-{Sanitize(name)}-link");
+    }
 
     public ILocator DeleteButton(string name)
     {
@@ -95,7 +99,7 @@ public class AccessPointsListPom(IPage page)
 
     public async Task OpenAccessPointAsync(string name)
     {
-        await AccessPointItem(name).ClickAsync();
+        await AccessPointItemLink(name).ClickAsync();
         await page.WaitForURLAsync($"**/resources/hardware/{name}");
     }
 

+ 189 - 0
Tests.E2e/PageObjectModels/DesktopCardPom.cs

@@ -0,0 +1,189 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class DesktopCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root + Navigation
+    // -------------------------------------------------
+
+    public ILocator DesktopItem(string name)
+        => page.GetByTestId($"desktop-item-{Sanitize(name)}");
+
+    public ILocator OpenDesktopLink(string name)
+        => page.GetByTestId($"desktop-item-{Sanitize(name)}-link");
+
+    public async Task OpenDesktopAsync(string name)
+    {
+        await OpenDesktopLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Header actions
+    // -------------------------------------------------
+
+    public ILocator RenameButton(string name)
+        => DesktopItem(name).GetByTestId("rename-desktop-button");
+
+    public ILocator CloneButton(string name)
+        => DesktopItem(name).GetByTestId("clone-desktop-button");
+
+    public ILocator DeleteButton(string name)
+        => DesktopItem(name).GetByTestId("delete-desktop-button");
+
+    public ILocator ModelBadge(string name)
+        => DesktopItem(name).GetByTestId("desktop-model-badge");
+
+    // -------------------------------------------------
+    // CPU section
+    // -------------------------------------------------
+
+    public ILocator CpuSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-cpu-section");
+
+    public ILocator AddCpuButton(string name)
+        => DesktopItem(name).GetByTestId("add-cpu-button");
+
+    public ILocator EditCpuButton(string name, string cpuToString)
+        => DesktopItem(name).GetByTestId($"edit-cpu-{Sanitize(cpuToString)}");
+
+    // -------------------------------------------------
+    // RAM section
+    // -------------------------------------------------
+
+    public ILocator RamSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-ram-section");
+
+    public ILocator EditRamButton(string name)
+        => DesktopItem(name).GetByTestId("edit-ram-button");
+
+    public ILocator RamValueButton(string name)
+        => DesktopItem(name).GetByTestId("ram-value-button");
+
+    // -------------------------------------------------
+    // Drive section
+    // -------------------------------------------------
+
+    public ILocator DriveSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-drive-section");
+
+    public ILocator AddDriveButton(string name)
+        => DesktopItem(name).GetByTestId("add-drive-button");
+
+    public ILocator EditDriveButton(string name, string type, int size)
+        => DesktopItem(name).GetByTestId($"edit-drive-{type}-{size}");
+
+    // -------------------------------------------------
+    // NIC section
+    // -------------------------------------------------
+
+    public ILocator NicSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-nic-section");
+
+    public ILocator AddNicButton(string name)
+        => DesktopItem(name).GetByTestId("add-nic-button");
+
+    public ILocator EditNicButton(string name, string type, double speed)
+        => DesktopItem(name).GetByTestId($"edit-nic-{type}-{speed}");
+
+    // -------------------------------------------------
+    // GPU section
+    // -------------------------------------------------
+
+    public ILocator GpuSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-gpu-section");
+
+    public ILocator AddGpuButton(string name)
+        => DesktopItem(name).GetByTestId("add-gpu-button");
+
+    public ILocator EditGpuButton(string name, string model, int vram)
+        => DesktopItem(name).GetByTestId($"edit-gpu-{model}-{vram}");
+
+    // -------------------------------------------------
+    // Notes (MarkdownViewer/MarkdownEditor use prefixes)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-section");
+
+    // MarkdownViewer (TestIdPrefix="desktop-notes-viewer")
+    public ILocator NotesViewerRoot(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-viewer-markdown-viewer");
+
+    public ILocator NotesViewerEditButton(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-viewer-markdown-viewer-edit-button");
+
+    // MarkdownEditor (TestIdPrefix="desktop-notes-editor")
+    public ILocator NotesEditorRoot(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => DesktopItem(name).GetByTestId("desktop-notes-editor-markdown-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    // ConfirmModal TestIdPrefix="Desktop" => "Desktop-confirm-modal-*"
+    public ILocator DeleteConfirmModal => page.GetByTestId("Desktop-confirm-modal");
+    public ILocator DeleteConfirmButton => page.GetByTestId("Desktop-confirm-modal-confirm");
+    public ILocator DeleteCancelButton => page.GetByTestId("Desktop-confirm-modal-cancel");
+
+    // StringValueModal prefixes you set:
+    // desktop-rename => "desktop-rename-string-value-modal-*"
+    public ILocator RenameModal => page.GetByTestId("desktop-rename-string-value-modal");
+    public ILocator RenameModalInput => page.GetByTestId("desktop-rename-string-value-modal-input");
+    public ILocator RenameModalAccept => page.GetByTestId("desktop-rename-string-value-modal-submit");
+    public ILocator RenameModalCancel => page.GetByTestId("desktop-rename-string-value-modal-cancel");
+
+    // desktop-clone => "desktop-clone-string-value-modal-*"
+    public ILocator CloneModal => page.GetByTestId("desktop-clone-string-value-modal");
+    public ILocator CloneModalInput => page.GetByTestId("desktop-clone-string-value-modal-input");
+    public ILocator CloneModalAccept => page.GetByTestId("desktop-clone-string-value-modal-submit");
+    public ILocator CloneModalCancel => page.GetByTestId("desktop-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Actions helpers
+    // -------------------------------------------------
+
+    public async Task DeleteDesktopAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await Assertions.Expect(DeleteConfirmModal).ToBeVisibleAsync();
+        await DeleteConfirmButton.ClickAsync();
+        await Assertions.Expect(DesktopItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameDesktopAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await Assertions.Expect(RenameModal).ToBeVisibleAsync();
+
+        await RenameModalInput.FillAsync(newName);
+        await RenameModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneDesktopAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await Assertions.Expect(CloneModal).ToBeVisibleAsync();
+
+        await CloneModalInput.FillAsync(cloneName);
+        await CloneModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

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

@@ -0,0 +1,217 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class FirewallCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Dynamic Firewall Item (root)
+    // -------------------------------------------------
+
+    public ILocator FirewallItem(string name)
+        => page.GetByTestId($"firewall-item-{Sanitize(name)}");
+
+    public ILocator OpenLink(string name)
+        => FirewallItem(name).GetByTestId($"firewall-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Header Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => FirewallItem(name).GetByTestId("edit-firewall-button");
+
+    public ILocator SaveButton(string name)
+        => FirewallItem(name).GetByTestId("save-firewall-button");
+
+    public ILocator CancelButton(string name)
+        => FirewallItem(name).GetByTestId("cancel-firewall-button");
+
+    public ILocator RenameButton(string name)
+        => FirewallItem(name).GetByTestId("rename-firewall-button");
+
+    public ILocator CloneButton(string name)
+        => FirewallItem(name).GetByTestId("clone-firewall-button");
+
+    public ILocator DeleteButton(string name)
+        => FirewallItem(name).GetByTestId("delete-firewall-button");
+
+    // -------------------------------------------------
+    // Model
+    // -------------------------------------------------
+
+    public ILocator ModelSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-model-section");
+
+    public ILocator ModelInput(string name)
+        => FirewallItem(name).GetByTestId("firewall-model-input");
+
+    public ILocator ModelValue(string name)
+        => FirewallItem(name).GetByTestId("firewall-model-value");
+
+    // -------------------------------------------------
+    // Features
+    // -------------------------------------------------
+
+    public ILocator FeaturesSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-features-section");
+
+    public ILocator ManagedCheckbox(string name)
+        => FirewallItem(name).GetByTestId("firewall-managed-checkbox");
+
+    public ILocator PoeCheckbox(string name)
+        => FirewallItem(name).GetByTestId("firewall-poe-checkbox");
+
+    public ILocator ManagedBadge(string name)
+        => FirewallItem(name).GetByTestId("firewall-managed-badge");
+
+    public ILocator PoeBadge(string name)
+        => FirewallItem(name).GetByTestId("firewall-poe-badge");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortsSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-ports-section");
+
+    public ILocator AddPortButton(string name)
+        => FirewallItem(name).GetByTestId("add-port-button");
+
+    public ILocator EditPortButton(string firewallName, string portType, double portSpeed)
+        => FirewallItem(firewallName).GetByTestId($"edit-port-{portType}-{portSpeed}");
+
+    // -------------------------------------------------
+    // Notes (Markdown)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-section");
+
+    // MarkdownViewer / MarkdownEditor now use TestIdPrefix internally
+    public ILocator NotesViewerRoot(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-viewer");
+
+    public ILocator NotesViewerContent(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-viewer-content");
+
+    public ILocator NotesEditorRoot(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => FirewallItem(name).GetByTestId("firewall-notes-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmConfirmButton()
+        => page.GetByTestId("Firewall-confirm-modal-confirm");
+
+    public ILocator DeleteConfirmCancelButton()
+        => page.GetByTestId("Firewall-confirm-modal-cancel");
+
+    // StringValueModal (rename)
+    public ILocator RenameModal()
+        => page.GetByTestId("firewall-rename-string-value-modal");
+
+    public ILocator RenameInput()
+        => page.GetByTestId("firewall-rename-string-value-modal-input");
+
+    public ILocator RenameAccept()
+        => page.GetByTestId("firewall-rename-string-value-modal-submit");
+
+    public ILocator RenameCancel()
+        => page.GetByTestId("firewall-rename-string-value-modal-cancel");
+
+    // StringValueModal (clone)
+    public ILocator CloneModal()
+        => page.GetByTestId("firewall-clone-string-value-modal");
+
+    public ILocator CloneInput()
+        => page.GetByTestId("firewall-clone-string-value-modal-input");
+
+    public ILocator CloneAccept()
+        => page.GetByTestId("firewall-clone-string-value-modal-submit");
+
+    public ILocator CloneCancel()
+        => page.GetByTestId("firewall-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation helpers
+    // -------------------------------------------------
+
+    public async Task OpenFirewallAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task DeleteFirewallAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await DeleteConfirmConfirmButton().ClickAsync();
+
+        await Assertions.Expect(FirewallItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameFirewallAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await Assertions.Expect(RenameModal()).ToBeVisibleAsync();
+        await RenameInput().FillAsync(newName);
+        await RenameAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+        await Assertions.Expect(FirewallItem(newName)).ToBeVisibleAsync();
+    }
+
+    public async Task CloneFirewallAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await Assertions.Expect(CloneModal()).ToBeVisibleAsync();
+        await CloneInput().FillAsync(cloneName);
+        await CloneAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+        await Assertions.Expect(FirewallItem(cloneName)).ToBeVisibleAsync();
+    }
+
+    public async Task EnterEditModeAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(ModelInput(name)).ToBeVisibleAsync();
+        await Assertions.Expect(ManagedCheckbox(name)).ToBeVisibleAsync();
+        await Assertions.Expect(PoeCheckbox(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveEditsAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditsAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value) => value.Replace(" ", "-");
+}

+ 2 - 5
Tests.E2e/PageObjectModels/FirewallListPom.cs

@@ -27,12 +27,9 @@ public class FirewallsListPom(IPage page)
     {
         return page.GetByTestId($"firewall-item-{Sanitize(name)}");
     }
-
+    
     public ILocator OpenLink(string name)
-    {
-        return FirewallItem(name)
-            .GetByTestId("open-firewall-link");
-    }
+        => FirewallItem(name).GetByTestId($"firewall-item-{Sanitize(name)}-link");
 
     public ILocator EditButton(string name)
     {

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

@@ -0,0 +1,189 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class LaptopCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root + Navigation
+    // -------------------------------------------------
+
+    public ILocator LaptopItem(string name)
+        => page.GetByTestId($"laptop-item-{Sanitize(name)}");
+
+    public ILocator OpenLaptopLink(string name)
+        => page.GetByTestId($"laptop-item-{Sanitize(name)}-link");
+
+    public async Task OpenLaptopAsync(string name)
+    {
+        await OpenLaptopLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Header actions
+    // -------------------------------------------------
+
+    public ILocator RenameButton(string name)
+        => LaptopItem(name).GetByTestId("rename-laptop-button");
+
+    public ILocator CloneButton(string name)
+        => LaptopItem(name).GetByTestId("clone-laptop-button");
+
+    public ILocator DeleteButton(string name)
+        => LaptopItem(name).GetByTestId("delete-laptop-button");
+
+    public ILocator ModelBadge(string name)
+        => LaptopItem(name).GetByTestId("laptop-model-badge");
+
+    // -------------------------------------------------
+    // CPU section
+    // -------------------------------------------------
+
+    public ILocator CpuSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-cpu-section");
+
+    public ILocator AddCpuButton(string name)
+        => LaptopItem(name).GetByTestId("add-cpu-button");
+
+    public ILocator EditCpuButton(string name, string cpuToString)
+        => LaptopItem(name).GetByTestId($"edit-cpu-{Sanitize(cpuToString)}");
+
+    // -------------------------------------------------
+    // RAM section
+    // -------------------------------------------------
+
+    public ILocator RamSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-ram-section");
+
+    public ILocator EditRamButton(string name)
+        => LaptopItem(name).GetByTestId("edit-ram-button");
+
+    public ILocator RamValueButton(string name)
+        => LaptopItem(name).GetByTestId("ram-value-button");
+
+    // -------------------------------------------------
+    // Drive section
+    // -------------------------------------------------
+
+    public ILocator DriveSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-drive-section");
+
+    public ILocator AddDriveButton(string name)
+        => LaptopItem(name).GetByTestId("add-drive-button");
+
+    public ILocator EditDriveButton(string name, string type, int size)
+        => LaptopItem(name).GetByTestId($"edit-drive-{type}-{size}");
+
+    // -------------------------------------------------
+    // NIC section
+    // -------------------------------------------------
+
+    public ILocator NicSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-nic-section");
+
+    public ILocator AddNicButton(string name)
+        => LaptopItem(name).GetByTestId("add-nic-button");
+
+    public ILocator EditNicButton(string name, string type, double speed)
+        => LaptopItem(name).GetByTestId($"edit-nic-{type}-{speed}");
+
+    // -------------------------------------------------
+    // GPU section
+    // -------------------------------------------------
+
+    public ILocator GpuSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-gpu-section");
+
+    public ILocator AddGpuButton(string name)
+        => LaptopItem(name).GetByTestId("add-gpu-button");
+
+    public ILocator EditGpuButton(string name, string model, int vram)
+        => LaptopItem(name).GetByTestId($"edit-gpu-{model}-{vram}");
+
+    // -------------------------------------------------
+    // Notes (MarkdownViewer/MarkdownEditor use prefixes)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-section");
+
+    // MarkdownViewer (TestIdPrefix="laptop-notes-viewer")
+    public ILocator NotesViewerRoot(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-viewer-markdown-viewer");
+
+    public ILocator NotesViewerEditButton(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-viewer-markdown-viewer-edit-button");
+
+    // MarkdownEditor (TestIdPrefix="laptop-notes-editor")
+    public ILocator NotesEditorRoot(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => LaptopItem(name).GetByTestId("laptop-notes-editor-markdown-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    // ConfirmModal TestIdPrefix="Laptop" => "Laptop-confirm-modal-*"
+    public ILocator DeleteConfirmModal => page.GetByTestId("Laptop-confirm-modal");
+    public ILocator DeleteConfirmButton => page.GetByTestId("Laptop-confirm-modal-confirm");
+    public ILocator DeleteCancelButton => page.GetByTestId("Laptop-confirm-modal-cancel");
+
+    // StringValueModal prefixes you set:
+    // laptop-rename => "laptop-rename-string-value-modal-*"
+    public ILocator RenameModal => page.GetByTestId("laptop-rename-string-value-modal");
+    public ILocator RenameModalInput => page.GetByTestId("laptop-rename-string-value-modal-input");
+    public ILocator RenameModalAccept => page.GetByTestId("laptop-rename-string-value-modal-submit");
+    public ILocator RenameModalCancel => page.GetByTestId("laptop-rename-string-value-modal-cancel");
+
+    // laptop-clone => "laptop-clone-string-value-modal-*"
+    public ILocator CloneModal => page.GetByTestId("laptop-clone-string-value-modal");
+    public ILocator CloneModalInput => page.GetByTestId("laptop-clone-string-value-modal-input");
+    public ILocator CloneModalAccept => page.GetByTestId("laptop-clone-string-value-modal-submit");
+    public ILocator CloneModalCancel => page.GetByTestId("laptop-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Actions helpers
+    // -------------------------------------------------
+
+    public async Task DeleteLaptopAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await Assertions.Expect(DeleteConfirmModal).ToBeVisibleAsync();
+        await DeleteConfirmButton.ClickAsync();
+        await Assertions.Expect(LaptopItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameLaptopAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await Assertions.Expect(RenameModal).ToBeVisibleAsync();
+
+        await RenameModalInput.FillAsync(newName);
+        await RenameModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneLaptopAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await Assertions.Expect(CloneModal).ToBeVisibleAsync();
+
+        await CloneModalInput.FillAsync(cloneName);
+        await CloneModalAccept.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

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

@@ -0,0 +1,217 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class RouterCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Dynamic Router Item (root)
+    // -------------------------------------------------
+
+    public ILocator RouterItem(string name)
+        => page.GetByTestId($"router-item-{Sanitize(name)}");
+
+    public ILocator OpenLink(string name)
+        => page.GetByTestId($"router-item-{Sanitize(name)}-link");
+    
+    // -------------------------------------------------
+    // Header Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => RouterItem(name).GetByTestId("edit-router-button");
+
+    public ILocator SaveButton(string name)
+        => RouterItem(name).GetByTestId("save-router-button");
+
+    public ILocator CancelButton(string name)
+        => RouterItem(name).GetByTestId("cancel-router-button");
+
+    public ILocator RenameButton(string name)
+        => RouterItem(name).GetByTestId("rename-router-button");
+
+    public ILocator CloneButton(string name)
+        => RouterItem(name).GetByTestId("clone-router-button");
+
+    public ILocator DeleteButton(string name)
+        => RouterItem(name).GetByTestId("delete-router-button");
+
+    // -------------------------------------------------
+    // Model
+    // -------------------------------------------------
+
+    public ILocator ModelSection(string name)
+        => RouterItem(name).GetByTestId("router-model-section");
+
+    public ILocator ModelInput(string name)
+        => RouterItem(name).GetByTestId("router-model-input");
+
+    public ILocator ModelValue(string name)
+        => RouterItem(name).GetByTestId("router-model-value");
+
+    // -------------------------------------------------
+    // Features
+    // -------------------------------------------------
+
+    public ILocator FeaturesSection(string name)
+        => RouterItem(name).GetByTestId("router-features-section");
+
+    public ILocator ManagedCheckbox(string name)
+        => RouterItem(name).GetByTestId("router-managed-checkbox");
+
+    public ILocator PoeCheckbox(string name)
+        => RouterItem(name).GetByTestId("router-poe-checkbox");
+
+    public ILocator ManagedBadge(string name)
+        => RouterItem(name).GetByTestId("router-managed-badge");
+
+    public ILocator PoeBadge(string name)
+        => RouterItem(name).GetByTestId("router-poe-badge");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortsSection(string name)
+        => RouterItem(name).GetByTestId("router-ports-section");
+
+    public ILocator AddPortButton(string name)
+        => RouterItem(name).GetByTestId("add-port-button");
+
+    public ILocator EditPortButton(string routerName, string portType, double portSpeed)
+        => RouterItem(routerName).GetByTestId($"edit-port-{portType}-{portSpeed}");
+
+    // -------------------------------------------------
+    // Notes (Markdown)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => RouterItem(name).GetByTestId("router-notes-section");
+
+    // MarkdownViewer / MarkdownEditor now use TestIdPrefix internally
+    public ILocator NotesViewerRoot(string name)
+        => RouterItem(name).GetByTestId("router-notes-viewer");
+
+    public ILocator NotesViewerContent(string name)
+        => RouterItem(name).GetByTestId("router-notes-viewer-content");
+
+    public ILocator NotesEditorRoot(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => RouterItem(name).GetByTestId("router-notes-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmConfirmButton()
+        => page.GetByTestId("Router-confirm-modal-confirm");
+
+    public ILocator DeleteConfirmCancelButton()
+        => page.GetByTestId("Router-confirm-modal-cancel");
+
+    // StringValueModal (rename)
+    public ILocator RenameModal()
+        => page.GetByTestId("router-rename-string-value-modal");
+
+    public ILocator RenameInput()
+        => page.GetByTestId("router-rename-string-value-modal-input");
+
+    public ILocator RenameAccept()
+        => page.GetByTestId("router-rename-string-value-modal-submit");
+
+    public ILocator RenameCancel()
+        => page.GetByTestId("router-rename-string-value-modal-cancel");
+
+    // StringValueModal (clone)
+    public ILocator CloneModal()
+        => page.GetByTestId("router-clone-string-value-modal");
+
+    public ILocator CloneInput()
+        => page.GetByTestId("router-clone-string-value-modal-input");
+
+    public ILocator CloneAccept()
+        => page.GetByTestId("router-clone-string-value-modal-submit");
+
+    public ILocator CloneCancel()
+        => page.GetByTestId("router-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation helpers
+    // -------------------------------------------------
+
+    public async Task OpenRouterAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task DeleteRouterAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await DeleteConfirmConfirmButton().ClickAsync();
+
+        await Assertions.Expect(RouterItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameRouterAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await Assertions.Expect(RenameModal()).ToBeVisibleAsync();
+        await RenameInput().FillAsync(newName);
+        await RenameAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+        await Assertions.Expect(RouterItem(newName)).ToBeVisibleAsync();
+    }
+
+    public async Task CloneRouterAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await Assertions.Expect(CloneModal()).ToBeVisibleAsync();
+        await CloneInput().FillAsync(cloneName);
+        await CloneAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+        await Assertions.Expect(RouterItem(cloneName)).ToBeVisibleAsync();
+    }
+
+    public async Task EnterEditModeAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(ModelInput(name)).ToBeVisibleAsync();
+        await Assertions.Expect(ManagedCheckbox(name)).ToBeVisibleAsync();
+        await Assertions.Expect(PoeCheckbox(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveEditsAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditsAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value) => value.Replace(" ", "-");
+}

+ 1 - 4
Tests.E2e/PageObjectModels/RouterListPom.cs

@@ -29,10 +29,7 @@ public class RouterListPom(IPage page)
     }
 
     public ILocator OpenLink(string name)
-    {
-        return RouterItem(name)
-            .GetByTestId("open-router-link");
-    }
+        => page.GetByTestId($"router-item-{Sanitize(name)}-link");
 
     public ILocator EditButton(string name)
     {

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

@@ -0,0 +1,165 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class ServerCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root / Identity
+    // -------------------------------------------------
+
+    public ILocator ServerItem(string name)
+        => page.GetByTestId($"server-item-{Sanitize(name)}");
+
+    public ILocator ServerLink(string name)
+        => page.GetByTestId($"server-item-{Sanitize(name)}-link");
+
+    public async Task AssertVisibleAsync(string name)
+        => await Assertions.Expect(ServerItem(name)).ToBeVisibleAsync();
+
+    // -------------------------------------------------
+    // Top actions
+    // -------------------------------------------------
+
+    public ILocator RenameButton(string name)
+        => ServerItem(name).GetByTestId("rename-server-button");
+
+    public ILocator CloneButton(string name)
+        => ServerItem(name).GetByTestId("clone-server-button");
+
+    public ILocator DeleteButton(string name)
+        => ServerItem(name).GetByTestId("delete-server-button");
+
+    // -------------------------------------------------
+    // CPU section + modal (TestIdPrefix="server-cpu")
+    // -------------------------------------------------
+
+    public ILocator CpuSection(string name)
+        => ServerItem(name).GetByTestId("server-cpu-section");
+
+    public ILocator AddCpuButton(string name)
+        => ServerItem(name).GetByTestId("add-cpu-button");
+
+    public ILocator EditCpuButton(string name, string cpuDisplayKey)
+        => ServerItem(name).GetByTestId($"edit-cpu-{Sanitize(cpuDisplayKey)}");
+
+    // CpuModal base id becomes: "server-cpu-cpu-modal"
+    public ILocator CpuModalRoot => page.GetByTestId("server-cpu-cpu-modal");
+    public ILocator CpuModalModelInput => page.GetByTestId("server-cpu-cpu-modal-model-input");
+    public ILocator CpuModalCoresInput => page.GetByTestId("server-cpu-cpu-modal-cores-input");
+    public ILocator CpuModalThreadsInput => page.GetByTestId("server-cpu-cpu-modal-threads-input");
+    public ILocator CpuModalSubmit => page.GetByTestId("server-cpu-cpu-modal-submit");
+    public ILocator CpuModalCancel => page.GetByTestId("server-cpu-cpu-modal-cancel");
+    public ILocator CpuModalDelete => page.GetByTestId("server-cpu-cpu-modal-delete");
+    
+
+    public ILocator RamModalRoot => page.GetByTestId("server-ram-ram-modal");
+    public ILocator RamModalSizeInput => page.GetByTestId("server-ram-ram-modal-size-input");
+    public ILocator RamModalMtsInput => page.GetByTestId("server-ram-ram-modal-mts-input");
+    public ILocator RamModalSubmit => page.GetByTestId("server-ram-ram-modal-submit");
+    public ILocator RamModalCancel => page.GetByTestId("server-ram-ram-modal-cancel");
+
+    // -------------------------------------------------
+    // Drive modal (TestIdPrefix="server-drive")
+    // -------------------------------------------------
+
+    public ILocator DriveModalRoot => page.GetByTestId("server-drive-drive-modal");
+    public ILocator DriveModalTypeInput => page.GetByTestId("server-drive-drive-modal-type-input");
+    public ILocator DriveModalSizeInput => page.GetByTestId("server-drive-drive-modal-size-input");
+    public ILocator DriveModalSubmit => page.GetByTestId("server-drive-drive-modal-submit");
+    public ILocator DriveModalCancel => page.GetByTestId("server-drive-drive-modal-cancel");
+    public ILocator DriveModalDelete => page.GetByTestId("server-drive-drive-modal-delete");
+
+    // -------------------------------------------------
+    // NIC modal (TestIdPrefix="server-nic")
+    // -------------------------------------------------
+
+    public ILocator NicModalRoot => page.GetByTestId("server-nic-nic-modal");
+    public ILocator NicModalTypeInput => page.GetByTestId("server-nic-nic-modal-type-input");
+    public ILocator NicModalSpeedInput => page.GetByTestId("server-nic-nic-modal-speed-input");
+    public ILocator NicModalPortsInput => page.GetByTestId("server-nic-nic-modal-ports-input");
+    public ILocator NicModalSubmit => page.GetByTestId("server-nic-nic-modal-submit");
+    public ILocator NicModalCancel => page.GetByTestId("server-nic-nic-modal-cancel");
+    public ILocator NicModalDelete => page.GetByTestId("server-nic-nic-modal-delete");
+
+    // -------------------------------------------------
+    // GPU modal (TestIdPrefix="server-gpu")
+    // -------------------------------------------------
+
+    public ILocator GpuModalRoot => page.GetByTestId("server-gpu-gpu-modal");
+    public ILocator GpuModalModelInput => page.GetByTestId("server-gpu-gpu-modal-model-input");
+    public ILocator GpuModalVramInput => page.GetByTestId("server-gpu-gpu-modal-vram-input");
+    public ILocator GpuModalSubmit => page.GetByTestId("server-gpu-gpu-modal-submit");
+    public ILocator GpuModalCancel => page.GetByTestId("server-gpu-gpu-modal-cancel");
+    public ILocator GpuModalDelete => page.GetByTestId("server-gpu-gpu-modal-delete");
+
+    // -------------------------------------------------
+    // Notes (TestIdPrefix="server-markdown")
+    // MarkdownViewer base id: "server-markdown-markdown-viewer"
+    // MarkdownEditor base id: "server-markdown-markdown-editor"
+    // -------------------------------------------------
+
+    public ILocator NotesViewerRoot => page.GetByTestId("server-markdown-markdown-viewer");
+    public ILocator NotesViewerEditButton => page.GetByTestId("server-markdown-markdown-viewer-edit");
+
+    public ILocator NotesEditorRoot => page.GetByTestId("server-markdown-markdown-editor");
+    public ILocator NotesEditorTextarea => page.GetByTestId("server-markdown-markdown-editor-textarea");
+    public ILocator NotesEditorSave => page.GetByTestId("server-markdown-markdown-editor-save");
+    public ILocator NotesEditorCancel => page.GetByTestId("server-markdown-markdown-editor-cancel");
+
+    // -------------------------------------------------
+    // Delete confirm modal (TestIdPrefix="server-delete")
+    // ConfirmModal base id becomes: "server-delete-confirm-modal"
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmModal => page.GetByTestId("server-delete-confirm-modal");
+    public ILocator DeleteConfirm => page.GetByTestId("server-delete-confirm-modal-confirm");
+    public ILocator DeleteCancel => page.GetByTestId("server-delete-confirm-modal-cancel");
+    
+
+    public ILocator RenameModal => page.GetByTestId("server-rename-string-value-modal");
+    public ILocator RenameInput => page.GetByTestId("server-rename-string-value-modal-input");
+    public ILocator RenameAccept => page.GetByTestId("server-rename-string-value-modal-submit");
+    public ILocator RenameCancel => page.GetByTestId("server-rename-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Clone modal (TestIdPrefix="server-clone")
+    // -------------------------------------------------
+
+    public ILocator CloneModal => page.GetByTestId("server-clone-string-value-modal");
+    public ILocator CloneInput => page.GetByTestId("server-clone-string-value-modal-input");
+    public ILocator CloneAccept => page.GetByTestId("server-clone-string-value-modal-submit");
+    public ILocator CloneCancel => page.GetByTestId("server-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Helpers / Common actions
+    // -------------------------------------------------
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await Assertions.Expect(RenameModal).ToBeVisibleAsync();
+        await RenameInput.FillAsync(newName);
+        await RenameAccept.ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await Assertions.Expect(CloneModal).ToBeVisibleAsync();
+        await CloneInput.FillAsync(cloneName);
+        await CloneAccept.ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await Assertions.Expect(DeleteConfirmModal).ToBeVisibleAsync();
+        await DeleteConfirm.ClickAsync();
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 1 - 1
Tests.E2e/PageObjectModels/ServerListPom.cs

@@ -87,7 +87,7 @@ public class ServersListPom(IPage page)
     public async Task DeleteServerAsync(string serverName)
     {
         await DeleteButton(serverName).ClickAsync();
-        await page.GetByTestId("Server-confirm-modal-confirm").ClickAsync();
+        await page.GetByTestId("server-delete-confirm-modal-confirm").ClickAsync();
 
         await Assertions.Expect(ServerItem(serverName))
             .Not.ToBeVisibleAsync();

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

@@ -0,0 +1,133 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class ServiceCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Card(string name)
+        => page.GetByTestId($"service-item-{Sanitize(name)}");
+
+    public ILocator Link(string name)
+        => page.GetByTestId($"service-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => Card(name).GetByTestId("edit-service-button");
+
+    public ILocator SaveButton(string name)
+        => Card(name).GetByTestId("save-service-button");
+
+    public ILocator CancelButton(string name)
+        => Card(name).GetByTestId("cancel-service-button");
+
+    public ILocator RenameButton(string name)
+        => Card(name).GetByTestId("rename-service-button");
+
+    public ILocator CloneButton(string name)
+        => Card(name).GetByTestId("clone-service-button");
+
+    public ILocator DeleteButton(string name)
+        => Card(name).GetByTestId("delete-service-button");
+
+    // -------------------------------------------------
+    // Inputs (Edit Mode)
+    // -------------------------------------------------
+
+    public ILocator IpInput(string name)
+        => Card(name).GetByTestId("service-ip-input");
+
+    public ILocator PortInput(string name)
+        => Card(name).GetByTestId("service-port-input");
+
+    public ILocator ProtocolInput(string name)
+        => Card(name).GetByTestId("service-protocol-input");
+
+    public ILocator UrlInput(string name)
+        => Card(name).GetByTestId("service-url-input");
+
+    public ILocator RunsOnButton(string name)
+        => Card(name).GetByTestId("service-runson-button");
+
+    // -------------------------------------------------
+    // View Mode Values
+    // -------------------------------------------------
+
+    public ILocator IpValue(string name)
+        => Card(name).GetByTestId("service-ip-value");
+
+    public ILocator PortValue(string name)
+        => Card(name).GetByTestId("service-port-value");
+
+    public ILocator ProtocolValue(string name)
+        => Card(name).GetByTestId("service-protocol-value");
+
+    public ILocator UrlValue(string name)
+        => Card(name).GetByTestId("service-url-value");
+
+    // -------------------------------------------------
+    // Notes
+    // -------------------------------------------------
+
+    public ILocator NotesViewer
+        => page.GetByTestId("service-notes-viewer-container");
+
+    public ILocator NotesEditor
+        => page.GetByTestId("service-notes-editor-container");
+
+    // -------------------------------------------------
+    // Confirm Modal
+    // -------------------------------------------------
+
+    public ILocator ConfirmDeleteButton
+        => page.GetByTestId("service-delete-confirm-modal-confirm");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync(string name)
+    {
+        await Assertions.Expect(Card(name)).ToBeVisibleAsync();
+    }
+
+    public async Task BeginEditAsync(string name)
+        => await EditButton(name).ClickAsync();
+
+    public async Task SaveAsync(string name)
+        => await SaveButton(name).ClickAsync();
+
+    public async Task CancelAsync(string name)
+        => await CancelButton(name).ClickAsync();
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+        await page.GetByTestId("service-rename-string-value-modal-input").FillAsync(newName);
+        await page.GetByTestId("service-rename-string-value-modal-submit").ClickAsync();
+        await page.WaitForURLAsync($"**/resources/services/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+        await page.GetByTestId("service-clone-string-value-modal-input").FillAsync(cloneName);
+        await page.GetByTestId("service-clone-string-value-modal-submit").ClickAsync();
+        await page.WaitForURLAsync($"**/resources/services/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await ConfirmDeleteButton.ClickAsync();
+    }
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+}

+ 1 - 1
Tests.E2e/PageObjectModels/ServicesListPom.cs

@@ -123,7 +123,7 @@ public class ServicesListPom
     {
         await DeleteButton(name).ClickAsync();
 
-        await _page.GetByTestId("Service-confirm-modal-confirm")
+        await _page.GetByTestId("service-delete-confirm-modal-confirm")
             .ClickAsync();
 
         await Assertions.Expect(ServiceCard(name))

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

@@ -0,0 +1,217 @@
+namespace Tests.E2e.PageObjectModels;
+
+using Microsoft.Playwright;
+
+public class SwitchCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Dynamic Switch Item (root)
+    // -------------------------------------------------
+
+    public ILocator SwitchItem(string name)
+        => page.GetByTestId($"switch-item-{Sanitize(name)}");
+
+    public ILocator OpenLink(string name)
+        => page.GetByTestId($"switch-item-{Sanitize(name)}-link");
+    
+    // -------------------------------------------------
+    // Header Actions
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => SwitchItem(name).GetByTestId("edit-switch-button");
+
+    public ILocator SaveButton(string name)
+        => SwitchItem(name).GetByTestId("save-switch-button");
+
+    public ILocator CancelButton(string name)
+        => SwitchItem(name).GetByTestId("cancel-switch-button");
+
+    public ILocator RenameButton(string name)
+        => SwitchItem(name).GetByTestId("rename-switch-button");
+
+    public ILocator CloneButton(string name)
+        => SwitchItem(name).GetByTestId("clone-switch-button");
+
+    public ILocator DeleteButton(string name)
+        => SwitchItem(name).GetByTestId("delete-switch-button");
+
+    // -------------------------------------------------
+    // Model
+    // -------------------------------------------------
+
+    public ILocator ModelSection(string name)
+        => SwitchItem(name).GetByTestId("switch-model-section");
+
+    public ILocator ModelInput(string name)
+        => SwitchItem(name).GetByTestId("switch-model-input");
+
+    public ILocator ModelValue(string name)
+        => SwitchItem(name).GetByTestId("switch-model-value");
+
+    // -------------------------------------------------
+    // Features
+    // -------------------------------------------------
+
+    public ILocator FeaturesSection(string name)
+        => SwitchItem(name).GetByTestId("switch-features-section");
+
+    public ILocator ManagedCheckbox(string name)
+        => SwitchItem(name).GetByTestId("switch-managed-checkbox");
+
+    public ILocator PoeCheckbox(string name)
+        => SwitchItem(name).GetByTestId("switch-poe-checkbox");
+
+    public ILocator ManagedBadge(string name)
+        => SwitchItem(name).GetByTestId("switch-managed-badge");
+
+    public ILocator PoeBadge(string name)
+        => SwitchItem(name).GetByTestId("switch-poe-badge");
+
+    // -------------------------------------------------
+    // Ports
+    // -------------------------------------------------
+
+    public ILocator PortsSection(string name)
+        => SwitchItem(name).GetByTestId("switch-ports-section");
+
+    public ILocator AddPortButton(string name)
+        => SwitchItem(name).GetByTestId("add-port-button");
+
+    public ILocator EditPortButton(string switchName, string portType, double portSpeed)
+        => SwitchItem(switchName).GetByTestId($"edit-port-{portType}-{portSpeed}");
+
+    // -------------------------------------------------
+    // Notes (Markdown)
+    // -------------------------------------------------
+
+    public ILocator NotesSection(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-section");
+
+    // MarkdownViewer / MarkdownEditor now use TestIdPrefix internally
+    public ILocator NotesViewerRoot(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-viewer");
+
+    public ILocator NotesViewerContent(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-viewer-content");
+
+    public ILocator NotesEditorRoot(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor");
+
+    public ILocator NotesEditorTextarea(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor-textarea");
+
+    public ILocator NotesEditorSave(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor-save");
+
+    public ILocator NotesEditorCancel(string name)
+        => SwitchItem(name).GetByTestId("switch-notes-editor-cancel");
+
+    // -------------------------------------------------
+    // Modals
+    // -------------------------------------------------
+
+    public ILocator DeleteConfirmConfirmButton()
+        => page.GetByTestId("Switch-confirm-modal-confirm");
+
+    public ILocator DeleteConfirmCancelButton()
+        => page.GetByTestId("Switch-confirm-modal-cancel");
+
+    // StringValueModal (rename)
+    public ILocator RenameModal()
+        => page.GetByTestId("switch-rename-string-value-modal");
+
+    public ILocator RenameInput()
+        => page.GetByTestId("switch-rename-string-value-modal-input");
+
+    public ILocator RenameAccept()
+        => page.GetByTestId("switch-rename-string-value-modal-submit");
+
+    public ILocator RenameCancel()
+        => page.GetByTestId("switch-rename-string-value-modal-cancel");
+
+    // StringValueModal (clone)
+    public ILocator CloneModal()
+        => page.GetByTestId("switch-clone-string-value-modal");
+
+    public ILocator CloneInput()
+        => page.GetByTestId("switch-clone-string-value-modal-input");
+
+    public ILocator CloneAccept()
+        => page.GetByTestId("switch-clone-string-value-modal-submit");
+
+    public ILocator CloneCancel()
+        => page.GetByTestId("switch-clone-string-value-modal-cancel");
+
+    // -------------------------------------------------
+    // Navigation helpers
+    // -------------------------------------------------
+
+    public async Task OpenSwitchAsync(string name)
+    {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task DeleteSwitchAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await DeleteConfirmConfirmButton().ClickAsync();
+
+        await Assertions.Expect(SwitchItem(name)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task RenameSwitchAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await Assertions.Expect(RenameModal()).ToBeVisibleAsync();
+        await RenameInput().FillAsync(newName);
+        await RenameAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+        await Assertions.Expect(SwitchItem(newName)).ToBeVisibleAsync();
+    }
+
+    public async Task CloneSwitchAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await Assertions.Expect(CloneModal()).ToBeVisibleAsync();
+        await CloneInput().FillAsync(cloneName);
+        await CloneAccept().ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+        await Assertions.Expect(SwitchItem(cloneName)).ToBeVisibleAsync();
+    }
+
+    public async Task EnterEditModeAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(ModelInput(name)).ToBeVisibleAsync();
+        await Assertions.Expect(ManagedCheckbox(name)).ToBeVisibleAsync();
+        await Assertions.Expect(PoeCheckbox(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveEditsAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelEditsAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+
+        // back to view mode
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value) => value.Replace(" ", "-");
+}

+ 1 - 4
Tests.E2e/PageObjectModels/SwitchListPom.cs

@@ -29,10 +29,7 @@ public class SwitchListPom(IPage page)
     }
 
     public ILocator OpenLink(string name)
-    {
-        return SwitchItem(name)
-            .GetByTestId("open-switch-link");
-    }
+        => page.GetByTestId($"switch-item-{Sanitize(name)}-link");
 
     public ILocator EditButton(string name)
     {

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

@@ -0,0 +1,180 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SystemCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Helpers
+    // -------------------------------------------------
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+
+    public ILocator Card(string name)
+        => page.GetByTestId($"system-item-{Sanitize(name)}");
+
+    public ILocator Link(string name)
+        => page.GetByTestId($"system-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Action Buttons
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => Card(name).GetByTestId("edit-system-button");
+
+    public ILocator SaveButton(string name)
+        => Card(name).GetByTestId("save-system-button");
+
+    public ILocator CancelButton(string name)
+        => Card(name).GetByTestId("cancel-system-button");
+
+    public ILocator RenameButton(string name)
+        => Card(name).GetByTestId("rename-system-button");
+
+    public ILocator CloneButton(string name)
+        => Card(name).GetByTestId("clone-system-button");
+
+    public ILocator DeleteButton(string name)
+        => Card(name).GetByTestId("delete-system-button");
+
+    // -------------------------------------------------
+    // Edit Inputs
+    // -------------------------------------------------
+
+    public ILocator TypeSelect(string name)
+        => Card(name).GetByTestId("system-type-select");
+
+    public ILocator OsInput(string name)
+        => Card(name).GetByTestId("system-os-input");
+
+    public ILocator CoresInput(string name)
+        => Card(name).GetByTestId("system-cores-input");
+
+    public ILocator RamInput(string name)
+        => Card(name).GetByTestId("system-ram-input");
+
+    public ILocator RunsOnButton(string name)
+        => Card(name).GetByTestId("system-runs-on-button");
+
+    // -------------------------------------------------
+    // Drives
+    // -------------------------------------------------
+
+    public ILocator AddDriveButton(string name)
+        => Card(name).GetByTestId("add-drive-button");
+
+    public ILocator DriveItem(string name, string type, int size)
+        => Card(name).GetByTestId($"drive-item-{type}-{size}");
+
+    // ---- Drive Modal (TestIdPrefix = "system") ----
+
+// ---- Drive Modal (TestIdPrefix = "system") ----
+
+    public ILocator DriveTypeSelect
+        => page.GetByTestId("system-drive-modal-type-input");
+    public ILocator DriveSizeInput
+        => page.GetByTestId("system-drive-modal-size-input");
+
+    public ILocator DriveSubmitButton
+        => page.GetByTestId("system-drive-modal-submit");
+
+    public ILocator DriveDeleteButton
+        => page.GetByTestId("system-delete-button");
+
+    // High-level drive action
+    public async Task AddDriveAsync(string name, string type, int size)
+    {
+        await AddDriveButton(name).ClickAsync();
+
+        await DriveTypeSelect.SelectOptionAsync(new SelectOptionValue
+        {
+            Value = type
+        });
+
+        await DriveSizeInput.FillAsync(size.ToString());
+
+        await DriveSubmitButton.ClickAsync();
+
+        await Assertions.Expect(
+            DriveItem(name, type, size)
+        ).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // Notes
+    // -------------------------------------------------
+
+    public ILocator NotesViewer
+        => page.GetByTestId("system-notes-viewer-container");
+
+    public ILocator NotesEditor
+        => page.GetByTestId("system-notes-editor-container");
+
+    // -------------------------------------------------
+    // Confirm Delete Modal
+    // -------------------------------------------------
+
+    public ILocator ConfirmDeleteButton
+        => page.GetByTestId("system-delete-confirm-modal-confirm");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync(string name)
+    {
+        await Assertions.Expect(Card(name)).ToBeVisibleAsync();
+    }
+
+    public async Task BeginEditAsync(string name)
+    {
+        await EditButton(name).ClickAsync();
+        await Assertions.Expect(SaveButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task SaveAsync(string name)
+    {
+        await SaveButton(name).ClickAsync();
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task CancelAsync(string name)
+    {
+        await CancelButton(name).ClickAsync();
+        await Assertions.Expect(EditButton(name)).ToBeVisibleAsync();
+    }
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await page.GetByTestId("system-rename-string-value-modal-input")
+            .FillAsync(newName);
+
+        await page.GetByTestId("system-rename-string-value-modal-submit")
+            .ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/systems/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await page.GetByTestId("system-clone-string-value-modal-input")
+            .FillAsync(cloneName);
+
+        await page.GetByTestId("system-clone-string-value-modal-submit")
+            .ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/systems/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await ConfirmDeleteButton.ClickAsync();
+    }
+}

+ 1 - 1
Tests.E2e/PageObjectModels/SystemsListPom.cs

@@ -129,7 +129,7 @@ public class SystemsListPom
     {
         await DeleteButton(name).ClickAsync();
 
-        await _page.GetByTestId("System-confirm-modal-confirm")
+        await _page.GetByTestId("system-delete-confirm-modal-confirm")
             .ClickAsync();
 
         await Assertions.Expect(SystemCard(name))

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

@@ -0,0 +1,146 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class UpsCardPom(IPage page)
+{
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+
+    public ILocator Card(string name)
+        => page.GetByTestId($"ups-item-{Sanitize(name)}");
+
+    public ILocator Link(string name)
+        => page.GetByTestId($"ups-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Action Buttons
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => Card(name).GetByTestId("edit-ups-button");
+
+    public ILocator SaveButton(string name)
+        => Card(name).GetByTestId("save-ups-button");
+
+    public ILocator CancelButton(string name)
+        => Card(name).GetByTestId("cancel-ups-button");
+
+    public ILocator RenameButton(string name)
+        => Card(name).GetByTestId("rename-ups-button");
+
+    public ILocator CloneButton(string name)
+        => Card(name).GetByTestId("clone-ups-button");
+
+    public ILocator DeleteButton(string name)
+        => Card(name).GetByTestId("delete-ups-button");
+
+    // -------------------------------------------------
+    // Edit Inputs
+    // -------------------------------------------------
+
+    public ILocator ModelInput(string name)
+        => Card(name).GetByTestId("ups-model-input");
+
+    public ILocator CapacityInput(string name)
+        => Card(name).GetByTestId("ups-capacity-input");
+
+    // -------------------------------------------------
+    // View Values
+    // -------------------------------------------------
+
+    public ILocator ModelValue(string name)
+        => Card(name).GetByTestId("ups-model-value");
+
+    public ILocator CapacityValue(string name)
+        => Card(name).GetByTestId("ups-capacity-value");
+
+    // -------------------------------------------------
+    // Notes
+    // -------------------------------------------------
+
+    public ILocator NotesViewer
+        => page.GetByTestId("ups-notes-viewer-container");
+
+    public ILocator NotesEditor
+        => page.GetByTestId("ups-notes-editor-container");
+
+    // -------------------------------------------------
+    // Confirm Modal (TestIdPrefix="Ups")
+    // -------------------------------------------------
+
+    public ILocator ConfirmDeleteButton
+        => page.GetByTestId("Ups-confirm-modal-confirm");
+
+    // -------------------------------------------------
+    // Rename Modal (TestIdPrefix="ups-rename")
+    // -------------------------------------------------
+
+    public ILocator RenameInput
+        => page.GetByTestId("ups-rename-string-value-modal-input");
+
+    public ILocator RenameSubmit
+        => page.GetByTestId("ups-rename-string-value-modal-submit");
+
+    // -------------------------------------------------
+    // Clone Modal (TestIdPrefix="ups-clone")
+    // -------------------------------------------------
+
+    public ILocator CloneInput
+        => page.GetByTestId("ups-clone-string-value-modal-input");
+
+    public ILocator CloneSubmit
+        => page.GetByTestId("ups-clone-string-value-modal-submit");
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync(string name)
+    {
+        await Assertions.Expect(Card(name)).ToBeVisibleAsync();
+    }
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task BeginEditAsync(string name)
+        => await EditButton(name).ClickAsync();
+
+    public async Task SaveAsync(string name)
+        => await SaveButton(name).ClickAsync();
+
+    public async Task CancelAsync(string name)
+        => await CancelButton(name).ClickAsync();
+
+    public async Task RenameAsync(string currentName, string newName)
+    {
+        await RenameButton(currentName).ClickAsync();
+
+        await RenameInput.FillAsync(newName);
+        await RenameSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{newName}");
+    }
+
+    public async Task CloneAsync(string currentName, string cloneName)
+    {
+        await CloneButton(currentName).ClickAsync();
+
+        await CloneInput.FillAsync(cloneName);
+        await CloneSubmit.ClickAsync();
+
+        await page.WaitForURLAsync($"**/resources/hardware/{cloneName}");
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        await DeleteButton(name).ClickAsync();
+        await ConfirmDeleteButton.ClickAsync();
+    }
+}

+ 231 - 0
Tests.E2e/RouterCardTests.cs

@@ -0,0 +1,231 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class RouterCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Router_Model_And_Features()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{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 list.AssertRouterExists(name);
+
+            // Go to details page so we can exercise the card component
+            await list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await Assertions.Expect(card.RouterItem(name)).ToBeVisibleAsync();
+
+            // Enter edit mode
+            await card.EnterEditModeAsync(name);
+
+            // Update Model + toggle features
+            var newModel = "e2e-model-123";
+            await card.ModelInput(name).FillAsync(newModel);
+
+            // Ensure both toggles are ON (idempotent)
+            if (!await card.ManagedCheckbox(name).IsCheckedAsync())
+                await card.ManagedCheckbox(name).CheckAsync();
+            if (!await card.PoeCheckbox(name).IsCheckedAsync())
+                await card.PoeCheckbox(name).CheckAsync();
+
+            await card.SaveEditsAsync(name);
+
+            // Validate view mode reflects changes
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.ManagedBadge(name)).ToBeVisibleAsync();
+            await Assertions.Expect(card.PoeBadge(name)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Router_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var renamed = $"{name}-ren";
+
+        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 list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await card.RenameRouterAsync(name, renamed);
+
+            // after rename we should be on new URL and see the renamed card root
+            await Assertions.Expect(card.RouterItem(renamed)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Router_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var clone = $"{name}-cpy";
+
+        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 list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await card.CloneRouterAsync(name, clone);
+
+            // should be on the clone's details URL and see the clone card
+            await Assertions.Expect(card.RouterItem(clone)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_Router_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{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 list.OpenRouterAsync(name);
+
+            var card = new RouterCardPom(page);
+
+            await card.DeleteRouterAsync(name);
+
+            // After delete, your page navigates away (Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 104 - 0
Tests.E2e/ServerCardTests.cs

@@ -0,0 +1,104 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class ServerCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_Server_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-srv-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-srv-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-srv-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            // ------------------------------------
+            // Navigate to Servers list
+            // ------------------------------------
+            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 listPage = new ServersListPom(page);
+            await listPage.AssertLoadedAsync();
+
+            // ------------------------------------
+            // Create server
+            // ------------------------------------
+            await listPage.AddServerAsync(originalName);
+
+            // If list does not auto-navigate, open it
+            if (!page.Url.Contains($"/resources/hardware/{originalName}", StringComparison.OrdinalIgnoreCase))
+            {
+                await listPage.OpenServerAsync(originalName);
+            }
+
+            var card = new ServerCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // ====================================
+            // RENAME
+            // ====================================
+            await card.RenameAsync(originalName, renamedName);
+
+            await card.AssertVisibleAsync(renamedName);
+
+            // ====================================
+            // CLONE
+            // ====================================
+            await card.CloneAsync(renamedName, cloneName);
+
+            await card.AssertVisibleAsync(cloneName);
+
+            // ====================================
+            // DELETE CLONE
+            // ====================================
+            await card.DeleteAsync(cloneName);
+
+            // Details page delete navigates to tree
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // ====================================
+            // DELETE RENAMED ORIGINAL
+            // ====================================
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{renamedName}");
+
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 181 - 0
Tests.E2e/ServiceCardTests.cs

@@ -0,0 +1,181 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+using Microsoft.Playwright;
+
+namespace Tests.E2e;
+
+public class ServiceCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Rename / Clone / Delete Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_Service_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-svc-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-svc-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-svc-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServiceAsync(originalName);
+
+            if (!page.Url.Contains($"/resources/services/{originalName}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenServiceAsync(originalName);
+            }
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // -------------------------
+            // Rename
+            // -------------------------
+            await card.RenameAsync(originalName, renamedName);
+            await card.AssertVisibleAsync(renamedName);
+
+            // -------------------------
+            // Clone
+            // -------------------------
+            await card.CloneAsync(renamedName, cloneName);
+            await card.AssertVisibleAsync(cloneName);
+
+            // -------------------------
+            // Delete clone
+            // -------------------------
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/services/list");
+
+            // Delete renamed original
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/services/{renamedName}");
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+            await page.WaitForURLAsync("**/services/list");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Edit Flow Test
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Edit_And_Save_Service()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-svc-edit-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AddServiceAsync(name);
+
+            if (!page.Url.Contains($"/resources/services/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenServiceAsync(name);
+            }
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            // Fill via proper test ids
+            await card.IpInput(name).FillAsync("127.0.0.1");
+            await card.PortInput(name).FillAsync("8080");
+            await card.ProtocolInput(name).FillAsync("http");
+            await card.UrlInput(name).FillAsync("http://localhost:8080");
+
+            await card.SaveAsync(name);
+
+            // Verify edit mode exited
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+
+            // Verify persisted values
+            await Assertions.Expect(card.IpValue(name)).ToHaveTextAsync("127.0.0.1");
+            await Assertions.Expect(card.PortValue(name)).ToHaveTextAsync("8080");
+            await Assertions.Expect(card.ProtocolValue(name)).ToHaveTextAsync("http");
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Cancel Edit Test
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Cancel_Edit_Without_Saving()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-svc-cancel-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/services/list");
+
+            var list = new ServicesListPom(page);
+            await list.AddServiceAsync(name);
+
+            if (!page.Url.Contains($"/resources/services/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenServiceAsync(name);
+            }
+
+            var card = new ServiceCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.IpInput(name).FillAsync("should-not-save");
+
+            await card.CancelAsync(name);
+
+            // Confirm edit mode exited
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+
+            // Confirm value did NOT persist
+            await Assertions.Expect(card.IpValue(name)).Not.ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 231 - 0
Tests.E2e/SwitchCardTests.cs

@@ -0,0 +1,231 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class SwitchCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Edit_Switch_Model_And_Features()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{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 list.AssertSwitchExists(name);
+
+            // Go to details page so we can exercise the card component
+            await list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await Assertions.Expect(card.SwitchItem(name)).ToBeVisibleAsync();
+
+            // Enter edit mode
+            await card.EnterEditModeAsync(name);
+
+            // Update Model + toggle features
+            var newModel = "e2e-model-123";
+            await card.ModelInput(name).FillAsync(newModel);
+
+            // Ensure both toggles are ON (idempotent)
+            if (!await card.ManagedCheckbox(name).IsCheckedAsync())
+                await card.ManagedCheckbox(name).CheckAsync();
+            if (!await card.PoeCheckbox(name).IsCheckedAsync())
+                await card.PoeCheckbox(name).CheckAsync();
+
+            await card.SaveEditsAsync(name);
+
+            // Validate view mode reflects changes
+            await Assertions.Expect(card.ModelValue(name)).ToHaveTextAsync(newModel);
+            await Assertions.Expect(card.ManagedBadge(name)).ToBeVisibleAsync();
+            await Assertions.Expect(card.PoeBadge(name)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Rename_Switch_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var renamed = $"{name}-ren";
+
+        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 list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await card.RenameSwitchAsync(name, renamed);
+
+            // after rename we should be on new URL and see the renamed card root
+            await Assertions.Expect(card.SwitchItem(renamed)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Clone_Switch_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{Guid.NewGuid():N}"[..16];
+        var clone = $"{name}-cpy";
+
+        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 list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await card.CloneSwitchAsync(name, clone);
+
+            // should be on the clone's details URL and see the clone card
+            await Assertions.Expect(card.SwitchItem(clone)).ToBeVisibleAsync();
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Delete_Switch_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-fw-{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 list.OpenSwitchAsync(name);
+
+            var card = new SwitchCardPom(page);
+
+            await card.DeleteSwitchAsync(name);
+
+            // After delete, your page navigates away (Nav.NavigateTo("/hardware/tree"))
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 207 - 0
Tests.E2e/SystemCardTests.cs

@@ -0,0 +1,207 @@
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+using Microsoft.Playwright;
+
+namespace Tests.E2e;
+
+public class SystemCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    // ============================================================
+    // Rename / Clone / Delete Flow
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_System()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-sys-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-sys-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-sys-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddSystemAsync(originalName);
+
+            if (!page.Url.Contains($"/resources/systems/{originalName}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(originalName);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // -------------------------
+            // Rename
+            // -------------------------
+            await card.RenameAsync(originalName, renamedName);
+            await card.AssertVisibleAsync(renamedName);
+
+            // -------------------------
+            // Clone
+            // -------------------------
+            await card.CloneAsync(renamedName, cloneName);
+            await card.AssertVisibleAsync(cloneName);
+
+            // -------------------------
+            // Delete clone
+            // -------------------------
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/systems/list");
+
+            // Delete renamed original
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/systems/{renamedName}");
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+            await page.WaitForURLAsync("**/systems/list");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // ============================================================
+    // Edit Flow
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Edit_And_Save_System()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-sys-edit-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AddSystemAsync(name);
+
+            if (!page.Url.Contains($"/resources/systems/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(name);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            // Edit fields
+            await card.TypeSelect(name).SelectOptionAsync(new SelectOptionValue { Index = 0 });
+            await card.OsInput(name).FillAsync("Ubuntu 22.04");
+            await card.CoresInput(name).FillAsync("8");
+            await card.RamInput(name).FillAsync("16");
+
+            await card.SaveAsync(name);
+
+            // Verify read mode restored
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // ============================================================
+    // Cancel Edit
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Cancel_System_Edit()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-sys-cancel-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AddSystemAsync(name);
+
+            if (!page.Url.Contains($"/resources/systems/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(name);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.OsInput(name).FillAsync("Should Not Save");
+
+            await card.CancelAsync(name);
+
+            await Assertions.Expect(card.EditButton(name)).ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // ============================================================
+    // Drive Flow
+    // ============================================================
+
+    [Fact]
+    public async Task User_Can_Add_And_Edit_System_Drive()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-sys-drive-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/systems/list");
+
+            var list = new SystemsListPom(page);
+            await list.AddSystemAsync(name);
+
+            if (!page.Url.Contains($"/resources/systems/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenSystemAsync(name);
+            }
+
+            var card = new SystemCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.AddDriveAsync(name, "ssd", 512);
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+}

+ 176 - 0
Tests.E2e/UpsCardTests.cs

@@ -0,0 +1,176 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class UpsCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output)
+{
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Rename + Clone + Delete Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_Ups_From_Details_Page()
+    {
+        var (context, page) = await CreatePageAsync();
+
+        var originalName = $"e2e-ups-{Guid.NewGuid():N}"[..16];
+        var renamedName  = $"e2e-ups-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName    = $"e2e-ups-cl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(originalName);
+
+            if (!page.Url.Contains($"/resources/hardware/{originalName}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenUpsAsync(originalName);
+            }
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(originalName);
+
+            // -------------------------
+            // Rename
+            // -------------------------
+            await card.RenameAsync(originalName, renamedName);
+            await card.AssertVisibleAsync(renamedName);
+
+            // -------------------------
+            // Clone
+            // -------------------------
+            await card.CloneAsync(renamedName, cloneName);
+            await card.AssertVisibleAsync(cloneName);
+
+            // -------------------------
+            // Delete clone
+            // -------------------------
+            await card.DeleteAsync(cloneName);
+            await page.WaitForURLAsync("**/hardware/tree");
+
+            // Navigate back and delete renamed
+            await page.GotoAsync($"{fixture.BaseUrl}/resources/hardware/{renamedName}");
+            await card.AssertVisibleAsync(renamedName);
+
+            await card.DeleteAsync(renamedName);
+            await page.WaitForURLAsync("**/hardware/tree");
+        }
+        catch (Exception)
+        {
+            _output.WriteLine("TEST FAILED — Capturing diagnostics");
+            _output.WriteLine($"Current URL: {page.Url}");
+
+            var html = await page.ContentAsync();
+            _output.WriteLine("==== DOM SNAPSHOT START ====");
+            _output.WriteLine(html);
+            _output.WriteLine("==== DOM SNAPSHOT END ====");
+
+            throw;
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Edit + Save Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Edit_And_Save_Ups()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ups-edit-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenUpsAsync(name);
+            }
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.ModelInput(name).FillAsync("APC Smart-UPS");
+            await card.CapacityInput(name).FillAsync("1500");
+
+            await card.SaveAsync(name);
+
+            await Assertions.Expect(
+                card.ModelValue(name)
+            ).ToContainTextAsync("APC Smart-UPS");
+
+            await Assertions.Expect(
+                card.CapacityValue(name)
+            ).ToContainTextAsync("1500");
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Cancel Edit Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Cancel_Ups_Edit_Without_Saving()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-ups-cancel-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync($"{fixture.BaseUrl}/ups/list");
+
+            var list = new UpsListPom(page);
+            await list.AddUpsAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+            {
+                await list.OpenUpsAsync(name);
+            }
+
+            var card = new UpsCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.ModelInput(name).FillAsync("ShouldNotPersist");
+            await card.CapacityInput(name).FillAsync("9999");
+
+            await card.CancelAsync(name);
+
+            // Verify edit mode exited
+            await Assertions.Expect(
+                card.EditButton(name)
+            ).ToBeVisibleAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+}

+ 2 - 0
Tests/EndToEnd/AccessPointE2ETests.cs

@@ -41,6 +41,7 @@ public class AccessPointYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper ou
         Assert.Equal("Access Point 'ap01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
@@ -61,6 +62,7 @@ public class AccessPointYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper ou
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite

+ 2 - 0
Tests/EndToEnd/AccessPointTests/AccessPointWorkflowTests.cs

@@ -38,6 +38,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite
@@ -57,6 +58,7 @@ public class AccessPointWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper o
         Assert.Equal("Access Point 'ap02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: AccessPoint
                        model: Unifi-U6-Lite

+ 3 - 0
Tests/EndToEnd/ServiceYamlE2ETests.cs

@@ -29,6 +29,7 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
         var (output, yaml) = await ExecuteAsync("services", "add", "immich");
         Assert.Equal("Service 'immich' added.\n", output);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Service
                        name: immich
@@ -44,6 +45,7 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
 
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Service
                        network:
@@ -66,6 +68,7 @@ public class ServiceYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
                      """, output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: System
                        name: vm01

+ 2 - 0
Tests/EndToEnd/SwitchTests/SwitchWorkflowTests.cs

@@ -45,6 +45,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108
@@ -68,6 +69,7 @@ public class SwitchWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108

+ 2 - 0
Tests/EndToEnd/SwitchYamlE2ETests.cs

@@ -38,6 +38,7 @@ public class SwitchYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
             "--poe", "true");
         Assert.Equal("Switch 'sw01' updated.\n", output);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108
@@ -56,6 +57,7 @@ public class SwitchYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
         Assert.Equal("Switch 'sw02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Switch
                        model: Netgear GS108

+ 2 - 0
Tests/EndToEnd/SystemYamlE2ETests.cs

@@ -33,6 +33,7 @@ public class SystemYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
         (output, yaml) = await ExecuteAsync("systems", "add", "host01");
         Assert.Equal("System 'host01' added.\n", output);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Server
                        name: hypervisor01
@@ -55,6 +56,7 @@ public class SystemYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputH
 
         outputHelper.WriteLine(yaml);
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Server
                        name: hypervisor01

+ 2 - 0
Tests/EndToEnd/UpsTests/UpsWorkflowtests.cs

@@ -40,6 +40,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC-SmartUPS-1500
@@ -60,6 +61,7 @@ public class UpsWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHel
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC-SmartUPS-1500

+ 2 - 0
Tests/EndToEnd/UpsYamlE2ETests.cs

@@ -43,6 +43,7 @@ public class UpsYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
         Assert.Equal("UPS 'ups01' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC Smart-UPS 1500
@@ -64,6 +65,7 @@ public class UpsYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper outputHelp
         Assert.Equal("UPS 'ups02' updated.\n", output);
 
         Assert.Equal("""
+                     version: 1
                      resources:
                      - kind: Ups
                        model: APC Smart-UPS 1500