Browse Source

Merge pull request #320 from Timmoth/staging

V2.1.0
Tim Jones 15 giờ trước cách đây
mục cha
commit
6493437773
100 tập tin đã thay đổi với 5610 bổ sung2358 xóa
  1. 5 5
      AGENTS.md
  2. 12 2
      README.md
  3. 3 0
      RackPeek.Domain/Api/InventoryResponse.cs
  4. 18 0
      RackPeek.Domain/Api/UpsertInventoryUseCase.cs
  5. 1 0
      RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs
  6. 50 0
      RackPeek.Domain/Persistence/ConnectionMerger.cs
  7. 4 0
      RackPeek.Domain/Persistence/ResourceCollectionMerger.cs
  8. 2 0
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  9. 12 0
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  10. 5 5
      RackPeek.Domain/RackPeek.Domain.csproj
  11. 29 0
      RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs
  12. 7 0
      RackPeek.Domain/Resources/OtherHardware/Other.cs
  13. 27 0
      RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs
  14. 29 0
      RackPeek.Domain/Resources/OtherHardware/UpdateOtherUseCase.cs
  15. 4 1
      RackPeek.Domain/Resources/Resource.cs
  16. 1 1
      RackPeek.Domain/RpkConstants.cs
  17. 3 2
      RackPeek.Domain/UseCases/DeleteResourceUseCase.cs
  18. 4 4
      RackPeek.Domain/UseCases/RenameResourceUseCase.cs
  19. 2 0
      RackPeek.Web.Viewer/Program.cs
  20. 3 3
      RackPeek.Web.Viewer/RackPeek.Web.Viewer.csproj
  21. 7 4
      RackPeek.Web/Dockerfile
  22. 30 15
      RackPeek.Web/Program.cs
  23. 25 0
      RackPeek.Web/StaticWebAssetDocsContentProvider.cs
  24. 11 11
      RackPeek/RackPeek.csproj
  25. 44 0
      Shared.Rcl/CliBootstrap.cs
  26. 23 0
      Shared.Rcl/Commands/OtherHardware/Labels/OtherLabelAddCommand.cs
  27. 22 0
      Shared.Rcl/Commands/OtherHardware/Labels/OtherLabelRemoveCommand.cs
  28. 28 0
      Shared.Rcl/Commands/OtherHardware/OtherAddCommand.cs
  29. 28 0
      Shared.Rcl/Commands/OtherHardware/OtherDeleteCommand.cs
  30. 34 0
      Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs
  31. 24 0
      Shared.Rcl/Commands/OtherHardware/OtherGetByNameCommand.cs
  32. 39 0
      Shared.Rcl/Commands/OtherHardware/OtherGetCommand.cs
  33. 38 0
      Shared.Rcl/Commands/OtherHardware/OtherReportCommand.cs
  34. 27 0
      Shared.Rcl/Commands/OtherHardware/OtherSetCommand.cs
  35. 29 0
      Shared.Rcl/Commands/OtherHardware/Rename/OtherRenameCommand.cs
  36. 21 7
      Shared.Rcl/Components/LabelPage.razor
  37. 12 5
      Shared.Rcl/Components/MarkdownEditor.razor
  38. 12 5
      Shared.Rcl/Components/TagPage.razor
  39. 3 1
      Shared.Rcl/Connections/ConnectionsPage.razor
  40. 5 4
      Shared.Rcl/Docs/DocsHomePage.razor
  41. 6 0
      Shared.Rcl/Docs/HttpDocsContentProvider.cs
  42. 17 0
      Shared.Rcl/Docs/IDocsContentProvider.cs
  43. 6 0
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  44. 1 0
      Shared.Rcl/Hardware/HardwareTreePage.razor
  45. 254 0
      Shared.Rcl/OtherHardware/OtherCardComponent.razor
  46. 32 0
      Shared.Rcl/OtherHardware/OtherListPage.razor
  47. 3 3
      Shared.Rcl/Shared.Rcl.csproj
  48. 3 2
      Shared.Rcl/Systems/SystemCardComponent.razor
  49. 4 1
      Shared.Rcl/Systems/SystemEditModel.cs
  50. 2 1
      Shared.Rcl/YamlFileComponent.razor
  51. 31 1
      Shared.Rcl/YamlImportPage.razor
  52. 15 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  53. 256 17
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  54. 8 0
      Shared.Rcl/wwwroot/raw_docs/install-guide.md
  55. 1 1
      Shared.Rcl/wwwroot/raw_docs/inventory-api.md
  56. 1 0
      Shared.Rcl/wwwroot/raw_docs/resource-levels.md
  57. 66 0
      Tests.E2e/AccessPointCardTests.cs
  58. 182 0
      Tests.E2e/ConnectionsPageTests.cs
  59. 157 0
      Tests.E2e/DocsTests.cs
  60. 160 0
      Tests.E2e/ExportTests.cs
  61. 41 0
      Tests.E2e/Infra/BlazorLatency.cs
  62. 225 0
      Tests.E2e/OtherCardTests.cs
  63. 58 0
      Tests.E2e/OtherTests.cs
  64. 27 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  65. 81 0
      Tests.E2e/PageObjectModels/ConnectionModalPom.cs
  66. 37 0
      Tests.E2e/PageObjectModels/ConnectionsPagePom.cs
  67. 86 0
      Tests.E2e/PageObjectModels/DocsPom.cs
  68. 6 0
      Tests.E2e/PageObjectModels/HardwareTreePom.cs
  69. 73 0
      Tests.E2e/PageObjectModels/HostsExportPom.cs
  70. 103 0
      Tests.E2e/PageObjectModels/LabelPagePom.cs
  71. 142 0
      Tests.E2e/PageObjectModels/OtherCardPom.cs
  72. 112 0
      Tests.E2e/PageObjectModels/OtherListPom.cs
  73. 19 33
      Tests.E2e/PageObjectModels/PortsPom.cs
  74. 76 0
      Tests.E2e/PageObjectModels/SshExportPom.cs
  75. 62 0
      Tests.E2e/PageObjectModels/SubnetBrowserPom.cs
  76. 13 0
      Tests.E2e/PageObjectModels/SystemCardPom.cs
  77. 57 0
      Tests.E2e/PageObjectModels/TagPagePom.cs
  78. 54 0
      Tests.E2e/PageObjectModels/VisualisePom.cs
  79. 72 0
      Tests.E2e/PageObjectModels/YamlImportPom.cs
  80. 165 0
      Tests.E2e/SubnetAndVisualiseTests.cs
  81. 34 0
      Tests.E2e/SystemCardTests.cs
  82. 282 0
      Tests.E2e/TagAndLabelPageTests.cs
  83. 6 6
      Tests.E2e/Tests.E2e.csproj
  84. 164 0
      Tests.E2e/YamlImportTests.cs
  85. 2 0
      Tests/Api/ApiTestBase.cs
  86. 171 0
      Tests/Api/InventoryEndpointTests.cs
  87. 41 0
      Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs
  88. 47 0
      Tests/EndToEnd/DeleteResourceTests.cs
  89. 69 0
      Tests/EndToEnd/OtherTests/OtherCommandTests.cs
  90. 60 0
      Tests/EndToEnd/OtherTests/OtherErrorTests.cs
  91. 104 0
      Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs
  92. 8 0
      Tests/TestConfigs/v3/12-other.yaml
  93. 14 9
      Tests/Tests.csproj
  94. 0 606
      Tests/schemas/schema.v1.json
  95. 0 624
      Tests/schemas/schema.v2.json
  96. 0 667
      Tests/schemas/schema.v3.json
  97. 15 0
      docs/CommandIndex.md
  98. 256 17
      docs/Commands.md
  99. 431 143
      schemas/v2/schema.v2.json
  100. 509 152
      schemas/v3/schema.v3.json

+ 5 - 5
AGENTS.md

@@ -6,7 +6,7 @@ This document is the entry point for AI agents (Claude Code, OpenCode, etc.) wor
 
 ## 1. What RackPeek is
 
-RackPeek is a **CLI + Web UI for documenting and managing home-lab / small-scale IT infrastructure** (servers, switches, routers, firewalls, access points, UPS units, desktops, laptops, systems, and services).
+RackPeek is a **CLI + Web UI for documenting and managing home-lab / small-scale IT infrastructure** (servers, switches, routers, firewalls, access points, UPS units, desktops, laptops, other hardware, systems, and services).
 
 - All state is persisted to a **single YAML file** (`config/config.yaml`) — no database.
 - Same domain code powers the CLI binary (`rpk`) and the Blazor Server Web UI.
@@ -170,7 +170,7 @@ Top-level shape:
 
 ```yaml
 resources:
-  - kind: Server | Switch | Firewall | Router | Accesspoint | Desktop | Laptop | Ups | System | Service
+  - kind: Server | Switch | Firewall | Router | Accesspoint | Desktop | Laptop | Ups | Other | System | Service
     name: <unique name within kind>
     tags: [...]
     labels: { key: value }
@@ -185,8 +185,8 @@ Key invariants (see `RackPeek.Domain/Resources/Resource.cs`):
 - `name` is the identity within a `kind`. Don't introduce numeric IDs.
 - `runsOn` relationships are validated by `Resource.CanRunOn<T>`:
   - `Service` may run on a `System`.
-  - `System` may run on hardware (`Server`, `Switch`, `Firewall`, `Router`, `Accesspoint`, `Desktop`, `Laptop`, `Ups`) or on another `System`.
-- "Hardware" is the umbrella term for the eight physical kinds above (`Resource.IsHardware`).
+  - `System` may run on hardware (`Server`, `Switch`, `Firewall`, `Router`, `Accesspoint`, `Desktop`, `Laptop`, `Ups`, `Other`) or on another `System`.
+- "Hardware" is the umbrella term for the nine physical kinds above (`Resource.IsHardware`).
 - Anything that mutates the YAML must go through an `IResourceUseCase<T>` → `IResourceCollection` → repository, never direct file writes.
 
 ### YAML migrations
@@ -213,7 +213,7 @@ The full command tree is documented in `docs/Commands.md` and `docs/CommandIndex
 rpk <kind> <verb> [name] [flags]
 
 kinds:   summary, servers, switches, routers, firewalls, systems,
-         accesspoints, ups, desktops, laptops, services
+         accesspoints, ups, desktops, laptops, other, services
 verbs:   summary, add, list, get, describe, set, del, tree
 sub:     cpu, drive, gpu, nic, port, subnets, labels, tags, rename, …
 ```

+ 12 - 2
README.md

@@ -1,6 +1,6 @@
 [![RackPeek demo](./assets/rackpeek_banner_thin.png)](./assets/rackpeek_banner_thin.png)
 
-![Version](https://img.shields.io/badge/Version-2.0.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
+![Version](https://img.shields.io/badge/Version-2.1.0-2ea44f) ![Status](https://img.shields.io/badge/Status-Stable-success)
 [![Join our Discord](https://img.shields.io/badge/Discord-Join%20Us-7289DA?logo=discord&logoColor=white)](https://discord.gg/egXRPdesee) [![Live Demo](https://img.shields.io/badge/Live%20Demo-Try%20RackPeek%20Online-2ea44f?logo=githubpages&logoColor=white)](https://timmoth.github.io/RackPeek/) [![Docker Hub](https://img.shields.io/badge/Docker%20Hub-rackpeek-2496ED?logo=docker&logoColor=white)](https://hub.docker.com/r/aptacode/rackpeek/)
 
 RackPeek is a webui & CLI tool for documenting and managing home lab and small-scale IT infrastructure.
@@ -29,12 +29,22 @@ docker run -d \
   aptacode/rackpeek:latest
 
 # Bind mount
+mkdir -p config
+sudo chown 1654:1654 config
 docker run -d \
   --name rackpeek \
   -p 8080:8080 \
-  -v $(pwd)/config:/app/config \
+  -v $(pwd)/config:/app/config:Z \
   aptacode/rackpeek:latest
 
+# RackPeek runs as UID/GID 1654:1654 inside the container, so a bind-mounted
+# host directory must be writable by that user. The :Z suffix is needed on
+# SELinux-enabled systems such as Fedora, RHEL, and CentOS; it can be omitted
+# on systems without SELinux.
+
+# To verify the container user:
+docker exec rackpeek id
+
 # Note - RackPeek stores its state in YAML
 config/
 └── config.yaml

+ 3 - 0
RackPeek.Domain/Api/InventoryResponse.cs

@@ -10,4 +10,7 @@ public class ImportYamlResponse {
 
     public Dictionary<string, string> NewYaml { get; set; }
         = new(StringComparer.OrdinalIgnoreCase);
+
+    public List<string> ConnectionsAdded { get; set; } = new();
+    public List<string> ConnectionsRemoved { get; set; } = new();
 }

+ 18 - 0
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -118,6 +118,24 @@ public class UpsertInventoryUseCase(
             else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
         }
 
+        IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
+        List<Connection>? mergedConnections = ConnectionMerger.Merge(
+            currentConnections,
+            incomingRoot.Connections,
+            request.Mode);
+
+        if (mergedConnections != null) {
+            response.ConnectionsAdded = mergedConnections
+                .Select(ConnectionMerger.Describe)
+                .Except(currentConnections.Select(ConnectionMerger.Describe))
+                .ToList();
+
+            response.ConnectionsRemoved = currentConnections
+                .Select(ConnectionMerger.Describe)
+                .Except(mergedConnections.Select(ConnectionMerger.Describe))
+                .ToList();
+        }
+
         if (!request.DryRun) await repo.Merge(yamlInput, request.Mode);
 
         return response;

+ 1 - 0
RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs

@@ -43,6 +43,7 @@ public sealed class MermaidSerialiser {
             ["Ups"] = new("{\"", "\"}"),           // rhombus — utility
             ["Desktop"] = new("(\"", "\")"),       // rounded rect — endpoint
             ["Laptop"] = new("(\"", "\")"),        // rounded rect — endpoint
+            ["Other"] = new("[\"", "\"]"),         // plain rect — uncategorised
 
             // Logical / service view shapes (don't appear with the physical
             // kinds in the same diagram, so shape reuse across views is OK)

+ 50 - 0
RackPeek.Domain/Persistence/ConnectionMerger.cs

@@ -0,0 +1,50 @@
+using RackPeek.Domain.Resources.Connections;
+
+namespace RackPeek.Domain.Persistence;
+
+public static class ConnectionMerger {
+    /// <summary>
+    ///     Merges an imported connections section into the existing set.
+    ///     Returns null when the import carries no connections section — the
+    ///     existing connections are kept untouched in that case (#308).
+    ///     Replace mode swaps the whole set; Merge mode applies the same
+    ///     overwrite rule as the UI (a port holds at most one connection, so
+    ///     each incoming connection evicts anything touching its endpoints).
+    /// </summary>
+    public static List<Connection>? Merge(
+        IReadOnlyList<Connection> existing,
+        List<Connection>? incoming,
+        MergeMode mode) {
+        if (incoming == null)
+            return null;
+
+        if (mode == MergeMode.Replace)
+            return incoming.ToList();
+
+        var merged = existing.ToList();
+
+        foreach (Connection connection in incoming) {
+            merged.RemoveAll(c =>
+                Touches(c, connection.A) || Touches(c, connection.B));
+
+            merged.Add(connection);
+        }
+
+        return merged;
+    }
+
+    public static string Describe(Connection c) =>
+        $"{Describe(c.A)} <-> {Describe(c.B)}";
+
+    private static string Describe(PortReference p) =>
+        $"{p.Resource}[{p.PortGroup}.{p.PortIndex}]";
+
+    private static bool Touches(Connection c, PortReference port) =>
+        PortsMatch(c.A, port) || PortsMatch(c.B, port);
+
+    private static bool PortsMatch(PortReference a, PortReference b) {
+        return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase)
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+}

+ 4 - 0
RackPeek.Domain/Persistence/ResourceCollectionMerger.cs

@@ -12,6 +12,7 @@ using RackPeek.Domain.Resources.Routers;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.UpsUnits;
 
@@ -185,6 +186,9 @@ internal static class ResourcePolymorphismResolver {
                 typeInfo.PolymorphismOptions.DerivedTypes.Add(
                     new JsonDerivedType(typeof(Ups), Ups.KindLabel));
 
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Other), Other.KindLabel));
+
                 typeInfo.PolymorphismOptions.DerivedTypes.Add(
                     new JsonDerivedType(typeof(SystemResource), SystemResource.KindLabel));
 

+ 2 - 0
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -10,6 +10,7 @@ using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.UpsUnits;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
@@ -46,6 +47,7 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
                         { Laptop.KindLabel, typeof(Laptop) },
                         { AccessPoint.KindLabel, typeof(AccessPoint) },
                         { Ups.KindLabel, typeof(Ups) },
+                        { Other.KindLabel, typeof(Other) },
                         { SystemResource.KindLabel, typeof(SystemResource) },
                         { Service.KindLabel, typeof(Service) }
                     });

+ 12 - 0
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -12,6 +12,7 @@ using RackPeek.Domain.Resources.Routers;
 using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.UpsUnits;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
@@ -135,6 +136,16 @@ public sealed class YamlResourceCollection(
             resourceCollection.Resources.Clear();
             resourceCollection.Resources.AddRange(merged);
 
+            List<Connection>? mergedConnections = ConnectionMerger.Merge(
+                resourceCollection.Connections,
+                incomingRoot.Connections,
+                mode);
+
+            if (mergedConnections != null) {
+                resourceCollection.Connections.Clear();
+                resourceCollection.Connections.AddRange(mergedConnections);
+            }
+
             var rootToSave = new YamlRoot {
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
                 Resources = resourceCollection.Resources,
@@ -407,6 +418,7 @@ public sealed class YamlResourceCollection(
             Laptop => "Laptop",
             AccessPoint => "AccessPoint",
             Ups => "Ups",
+            Other => "Other",
             SystemResource => "System",
             Service => "Service",
             _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")

+ 5 - 5
RackPeek.Domain/RackPeek.Domain.csproj

@@ -8,11 +8,11 @@
 
     <ItemGroup>
         <PackageReference Include="DocMigrator.Yaml" Version="10.0.3" />
-        <PackageReference Include="LibGit2Sharp" Version="0.31.0" />
-        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9" />
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9" />
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
-        <PackageReference Include="YamlDotNet" Version="18.0.0" />
+        <PackageReference Include="LibGit2Sharp" Version="0.32.0" />
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12" />
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.12" />
+        <PackageReference Include="YamlDotNet" Version="18.1.0" />
     </ItemGroup>
 
 </Project>

+ 29 - 0
RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs

@@ -0,0 +1,29 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.OtherHardware;
+
+public record OtherDescription(
+    string Name,
+    string? Model,
+    string? Description,
+    Dictionary<string, string> Labels
+);
+
+public class DescribeOtherUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<OtherDescription> ExecuteAsync(string name) {
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var other = await repository.GetByNameAsync(name) as Other;
+        if (other == null)
+            throw new NotFoundException($"Other hardware '{name}' not found.");
+
+        return new OtherDescription(
+            other.Name,
+            other.Model,
+            other.Description,
+            other.Labels
+        );
+    }
+}

+ 7 - 0
RackPeek.Domain/Resources/OtherHardware/Other.cs

@@ -0,0 +1,7 @@
+namespace RackPeek.Domain.Resources.OtherHardware;
+
+public class Other : Hardware.Hardware {
+    public const string KindLabel = "Other";
+    public string? Model { get; set; }
+    public string? Description { get; set; }
+}

+ 27 - 0
RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs

@@ -0,0 +1,27 @@
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.OtherHardware;
+
+public record OtherHardwareReport(
+    IReadOnlyList<OtherHardwareRow> Others
+);
+
+public record OtherHardwareRow(
+    string Name,
+    string Model,
+    string Description
+);
+
+public class OtherHardwareReportUseCase(IResourceCollection repository) : IUseCase {
+    public async Task<OtherHardwareReport> ExecuteAsync() {
+        IReadOnlyList<Other> others = await repository.GetAllOfTypeAsync<Other>();
+
+        var rows = others.Select(o => new OtherHardwareRow(
+            o.Name,
+            o.Model ?? "Unknown",
+            o.Description ?? ""
+        )).ToList();
+
+        return new OtherHardwareReport(rows);
+    }
+}

+ 29 - 0
RackPeek.Domain/Resources/OtherHardware/UpdateOtherUseCase.cs

@@ -0,0 +1,29 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+
+namespace RackPeek.Domain.Resources.OtherHardware;
+
+public class UpdateOtherUseCase(IResourceCollection repository) : IUseCase {
+    public async Task ExecuteAsync(
+        string name,
+        string? model = null,
+        string? description = null,
+        string? notes = null
+    ) {
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var other = await repository.GetByNameAsync(name) as Other;
+        if (other == null)
+            throw new InvalidOperationException($"Other hardware '{name}' not found.");
+
+        if (!string.IsNullOrWhiteSpace(model))
+            other.Model = model;
+
+        if (description != null)
+            other.Description = description;
+
+        if (notes != null) other.Notes = notes;
+        await repository.UpdateAsync(other);
+    }
+}

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

@@ -7,13 +7,14 @@ using RackPeek.Domain.Resources.Servers;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.UpsUnits;
 
 namespace RackPeek.Domain.Resources;
 
 public abstract class Resource {
     private static readonly string[] _hardwareTypes =
-        ["server", "switch", "firewall", "router", "accesspoint", "desktop", "laptop", "ups"];
+        ["server", "switch", "firewall", "router", "accesspoint", "desktop", "laptop", "ups", "other"];
 
     private static readonly Dictionary<string, string> _kindToPluralDictionary = new()
     {
@@ -26,6 +27,7 @@ public abstract class Resource {
         { "desktop", "desktops" },
         { "laptop", "laptops" },
         { "ups", "ups" },
+        { "other", "other" },
         { "system", "systems" },
         { "service", "services" }
     };
@@ -41,6 +43,7 @@ public abstract class Resource {
         { typeof(Desktop), "Desktop" },
         { typeof(Laptop), "Laptop" },
         { typeof(Ups), "Ups" },
+        { typeof(Other), "Other" },
         { typeof(SystemResource), "System" },
         { typeof(Service), "Service" }
     };

+ 1 - 1
RackPeek.Domain/RpkConstants.cs

@@ -1,7 +1,7 @@
 namespace RackPeek.Domain;
 
 public static class RpkConstants {
-    public const string Version = "v2.0.0";
+    public const string Version = "v2.1.0";
 
     public static bool HasGitServices { get; set; }
 }

+ 3 - 2
RackPeek.Domain/UseCases/DeleteResourceUseCase.cs

@@ -21,13 +21,14 @@ public class DeleteResourceUseCase<T>(IResourceCollection repo) : IDeleteResourc
 
         IReadOnlyList<Resource> dependants = await repo.GetDependantsAsync(name);
         foreach (Resource resource in dependants) {
-            resource.RunsOn.Remove(name);
+            resource.RunsOn.RemoveAll(p => p.Equals(name, StringComparison.OrdinalIgnoreCase));
             await repo.UpdateAsync(resource);
         }
 
         IReadOnlyList<Connection> connections = await repo.GetConnectionsAsync();
         foreach (Connection connection in connections) {
-            if (connection.A.Resource == name || connection.B.Resource == name) {
+            if (connection.A.Resource.Equals(name, StringComparison.OrdinalIgnoreCase)
+                || connection.B.Resource.Equals(name, StringComparison.OrdinalIgnoreCase)) {
                 await repo.RemoveConnectionAsync(connection);
             }
         }

+ 4 - 4
RackPeek.Domain/UseCases/RenameResourceUseCase.cs

@@ -32,9 +32,9 @@ public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourc
         IReadOnlyList<Resource> allResources = await repo.GetAllOfTypeAsync<Resource>();
 
         foreach (Resource resource in allResources) {
-            if (resource.RunsOn.Contains(originalName)) {
+            if (resource.RunsOn.Contains(originalName, StringComparer.OrdinalIgnoreCase)) {
                 resource.RunsOn = resource.RunsOn
-                    .ConvertAll(p => p == originalName ? newName : p);
+                    .ConvertAll(p => p.Equals(originalName, StringComparison.OrdinalIgnoreCase) ? newName : p);
 
                 await repo.UpdateAsync(resource);
             }
@@ -44,12 +44,12 @@ public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourc
         foreach (Connection connection in connections) {
             var updated = false;
 
-            if (connection.A.Resource == originalName) {
+            if (connection.A.Resource.Equals(originalName, StringComparison.OrdinalIgnoreCase)) {
                 connection.A.Resource = newName;
                 updated = true;
             }
 
-            if (connection.B.Resource == originalName) {
+            if (connection.B.Resource.Equals(originalName, StringComparison.OrdinalIgnoreCase)) {
                 connection.B.Resource = newName;
                 updated = true;
             }

+ 2 - 0
RackPeek.Web.Viewer/Program.cs

@@ -6,6 +6,7 @@ using RackPeek.Domain.Git;
 using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl;
+using Shared.Rcl.Docs;
 
 namespace RackPeek.Web.Viewer;
 
@@ -51,6 +52,7 @@ public class Program {
         builder.Services.AddUseCases();
 
         builder.Services.AddScoped(sp => new HttpClient { BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });
+        builder.Services.AddScoped<IDocsContentProvider, HttpDocsContentProvider>();
 
         await builder.Build().RunAsync();
     }

+ 3 - 3
RackPeek.Web.Viewer/RackPeek.Web.Viewer.csproj

@@ -8,13 +8,13 @@
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.9" />
-        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.9" PrivateAssets="all" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly" Version="10.0.12" />
+        <PackageReference Include="Microsoft.AspNetCore.Components.WebAssembly.DevServer" Version="10.0.12" PrivateAssets="all" />
         <!-- Microsoft.DotNet.HotReload.WebAssembly.Browser is provided by the
              .NET 10 BlazorWebAssembly SDK — adding an explicit PackageReference
              causes NU1504 "duplicate PackageReference" during restore. -->
         <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0" />
+        <PackageReference Include="Spectre.Console.Testing" Version="0.57.2" />
     </ItemGroup>
 
     <ItemGroup>

+ 7 - 4
RackPeek.Web/Dockerfile

@@ -48,10 +48,12 @@ RUN apt-get update \
     && apt-get install -y --no-install-recommends curl \
     && rm -rf /var/lib/apt/lists/*
 
-# Create shared config directory safely
-RUN mkdir -p /app/config \
-    && chown -R ${APP_UID}:0 /app/config \
-    && chmod -R g=u /app/config
+# Create shared config directory safely, plus an app-owned temp directory:
+# key persistence writes staging files via the process temp dir, and /tmp
+# can be unavailable in hardened/rootless Docker setups (#312).
+RUN mkdir -p /app/config /app/tmp \
+    && chown -R ${APP_UID}:0 /app/config /app/tmp \
+    && chmod -R g=u /app/config /app/tmp
 
 VOLUME ["/app/config"]
 
@@ -70,6 +72,7 @@ RUN if [ -f /usr/local/bin/rpk-dir/RackPeek ]; then \
 # Make sure ASP.NET binds correctly in containers
 ENV ASPNETCORE_URLS=http://+:8080
 ENV RPK_YAML_DIR=/app/config
+ENV TMPDIR=/app/tmp
 
 # Drop privileges
 USER ${APP_UID}

+ 30 - 15
RackPeek.Web/Program.cs

@@ -1,5 +1,6 @@
 using System.Text.Json.Serialization;
 using Microsoft.AspNetCore.Components;
+using Microsoft.AspNetCore.DataProtection;
 using Microsoft.AspNetCore.Hosting.StaticWebAssets;
 using RackPeek.Domain;
 using RackPeek.Domain.Git;
@@ -8,6 +9,8 @@ using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Web.Api;
 using RackPeek.Web.Components;
 using Shared.Rcl;
+using Shared.Rcl.Docs;
+using Shared.Rcl.Servers;
 
 namespace RackPeek.Web;
 
@@ -31,28 +34,39 @@ public class Program {
         var yamlFilePath = Path.Combine(yamlPath, yamlFileName);
 
         if (!File.Exists(yamlFilePath)) {
-            await using var fs = new FileStream(
-                yamlFilePath,
-                FileMode.CreateNew,
-                FileAccess.Write,
-                FileShare.None);
-
-            await using var writer = new StreamWriter(fs);
-            await writer.WriteLineAsync("# default config");
+            try {
+                await using var fs = new FileStream(
+                    yamlFilePath,
+                    FileMode.CreateNew,
+                    FileAccess.Write,
+                    FileShare.None);
+
+                await using var writer = new StreamWriter(fs);
+                await writer.WriteLineAsync("# default config");
+            }
+            catch (IOException) when (File.Exists(yamlFilePath)) {
+                // Another instance created the file between the existence
+                // check and CreateNew — the config is there, carry on.
+            }
         }
 
+        // Persist DataProtection keys next to the config so they live on the
+        // mounted volume: they survive container recreation, and key writes
+        // no longer depend on a writable user profile or /tmp — both of
+        // which are unavailable in hardened Docker setups (#312).
+        var keysPath = Path.Combine(yamlPath, ".dataprotection");
+        Directory.CreateDirectory(keysPath);
+        builder.Services.AddDataProtection()
+            .PersistKeysToFileSystem(new DirectoryInfo(keysPath))
+            .SetApplicationName("RackPeek");
+
         builder.Services.ConfigureHttpJsonOptions(options => {
             options.SerializerOptions.Converters.Add(
                 new JsonStringEnumConverter());
         });
         builder.Services.AddScoped<ITextFileStore, PhysicalTextFileStore>();
 
-        builder.Services.AddScoped(sp => {
-            NavigationManager nav = sp.GetRequiredService<NavigationManager>();
-            return new HttpClient {
-                BaseAddress = new Uri(nav.BaseUri)
-            };
-        });
+        builder.Services.AddScoped<IDocsContentProvider, StaticWebAssetDocsContentProvider>();
 
         builder.Services.AddGitServices(builder.Configuration, yamlPath);
 
@@ -96,7 +110,8 @@ public class Program {
         app.MapStaticAssets();
 
         app.MapRazorComponents<App>()
-            .AddInteractiveServerRenderMode();
+            .AddInteractiveServerRenderMode()
+            .AddAdditionalAssemblies(typeof(ServersListPage).Assembly);
 
         return app;
     }

+ 25 - 0
RackPeek.Web/StaticWebAssetDocsContentProvider.cs

@@ -0,0 +1,25 @@
+using Microsoft.Extensions.FileProviders;
+using Shared.Rcl.Docs;
+
+namespace RackPeek.Web;
+
+/// <summary>
+///     Reads the docs directly from the composed static web assets
+///     (Shared.Rcl's wwwroot in development, wwwroot/_content/Shared.Rcl in a
+///     published app), so the server never has to call back to its own public
+///     URL — which is unreachable from inside the container behind a reverse
+///     proxy (issue #304).
+/// </summary>
+public class StaticWebAssetDocsContentProvider(IWebHostEnvironment environment) : IDocsContentProvider {
+    public async Task<string> GetAsync(string fileName) {
+        IFileInfo file = environment.WebRootFileProvider
+            .GetFileInfo($"_content/Shared.Rcl/raw_docs/{fileName}");
+
+        if (!file.Exists)
+            throw new FileNotFoundException($"Docs file '{fileName}' not found.");
+
+        await using Stream stream = file.CreateReadStream();
+        using var reader = new StreamReader(stream);
+        return await reader.ReadToEndAsync();
+    }
+}

+ 11 - 11
RackPeek/RackPeek.csproj

@@ -5,21 +5,21 @@
         <TargetFramework>net10.0</TargetFramework>
         <ImplicitUsings>enable</ImplicitUsings>
         <Nullable>enable</Nullable>
-        <AssemblyVersion>2.0.0</AssemblyVersion>
+        <AssemblyVersion>2.1.0</AssemblyVersion>
     </PropertyGroup>
 
     <ItemGroup>
-        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.9"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.9"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.9"/>
-        <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.9"/>
-        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.9"/>
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9"/>
-        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.9"/>
-        <PackageReference Include="Spectre.Console" Version="0.56.0"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.12"/>
+        <PackageReference Include="Spectre.Console" Version="0.57.2"/>
         <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0"/>
-        <PackageReference Include="YamlDotNet" Version="18.0.0"/>
+        <PackageReference Include="Spectre.Console.Testing" Version="0.57.2"/>
+        <PackageReference Include="YamlDotNet" Version="18.1.0"/>
     </ItemGroup>
 
     <ItemGroup>

+ 44 - 0
Shared.Rcl/CliBootstrap.cs

@@ -59,6 +59,9 @@ using Shared.Rcl.Commands.Switches.Rename;
 using Shared.Rcl.Commands.Systems;
 using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Rename;
+using Shared.Rcl.Commands.OtherHardware;
+using Shared.Rcl.Commands.OtherHardware.Labels;
+using Shared.Rcl.Commands.OtherHardware.Rename;
 using Shared.Rcl.Commands.Tags;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
@@ -504,6 +507,47 @@ public static class CliBootstrap {
                 });
             });
 
+            // ----------------------------
+            // Other hardware
+            // ----------------------------
+            config.AddBranch("other", other => {
+                other.SetDescription("Manage other hardware that doesn't fit an existing category.");
+
+                other.AddCommand<OtherReportCommand>("summary")
+                    .WithDescription("Show a hardware report for all other hardware.");
+
+                other.AddCommand<OtherAddCommand>("add").WithDescription("Add new other hardware.");
+
+                other.AddCommand<OtherGetCommand>("list").WithDescription("List all other hardware.");
+
+                other.AddCommand<OtherGetByNameCommand>("get").WithDescription("Retrieve other hardware by name.");
+
+                other.AddCommand<OtherDescribeCommand>("describe")
+                    .WithDescription("Show detailed information about other hardware.");
+
+                other.AddCommand<OtherSetCommand>("set").WithDescription("Update properties of other hardware.");
+
+                other.AddCommand<OtherDeleteCommand>("del").WithDescription("Delete other hardware.");
+
+                other.AddCommand<OtherRenameCommand>("rename")
+                    .WithDescription("Rename other hardware to a new name.");
+
+                other.AddBranch("label", label => {
+                    label.SetDescription("Manage labels on other hardware.");
+                    label.AddCommand<OtherLabelAddCommand>("add").WithDescription("Add a label to other hardware.");
+                    label.AddCommand<OtherLabelRemoveCommand>("remove")
+                        .WithDescription("Remove a label from other hardware.");
+                });
+
+                other.AddBranch("tag", tag => {
+                    tag.SetDescription("Manage tags on other hardware.");
+                    tag.AddCommand<TagAddCommand<RackPeek.Domain.Resources.OtherHardware.Other>>("add")
+                        .WithDescription("Add a tag to other hardware.");
+                    tag.AddCommand<TagRemoveCommand<RackPeek.Domain.Resources.OtherHardware.Other>>("remove")
+                        .WithDescription("Remove a tag from other hardware.");
+                });
+            });
+
             // ----------------------------
             // Desktops
             // ----------------------------

+ 23 - 0
Shared.Rcl/Commands/OtherHardware/Labels/OtherLabelAddCommand.cs

@@ -0,0 +1,23 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases.Labels;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using OtherResource = RackPeek.Domain.Resources.OtherHardware.Other;
+
+namespace Shared.Rcl.Commands.OtherHardware.Labels;
+
+public class OtherLabelAddSettings : OtherNameSettings {
+    [CommandOption("--key <KEY>")] public string Key { get; set; } = default!;
+    [CommandOption("--value <VALUE>")] public string Value { get; set; } = default!;
+}
+
+public class OtherLabelAddCommand(IServiceProvider serviceProvider) : AsyncCommand<OtherLabelAddSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext context, OtherLabelAddSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IAddLabelUseCase<OtherResource> useCase = scope.ServiceProvider.GetRequiredService<IAddLabelUseCase<OtherResource>>();
+        await useCase.ExecuteAsync(settings.Name, settings.Key, settings.Value);
+        AnsiConsole.MarkupLine($"[green]Label '{settings.Key}' added to '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 22 - 0
Shared.Rcl/Commands/OtherHardware/Labels/OtherLabelRemoveCommand.cs

@@ -0,0 +1,22 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases.Labels;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using OtherResource = RackPeek.Domain.Resources.OtherHardware.Other;
+
+namespace Shared.Rcl.Commands.OtherHardware.Labels;
+
+public class OtherLabelRemoveSettings : OtherNameSettings {
+    [CommandOption("--key <KEY>")] public string Key { get; set; } = default!;
+}
+
+public class OtherLabelRemoveCommand(IServiceProvider serviceProvider) : AsyncCommand<OtherLabelRemoveSettings> {
+    protected override async Task<int> ExecuteAsync(CommandContext context, OtherLabelRemoveSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRemoveLabelUseCase<OtherResource> useCase = scope.ServiceProvider.GetRequiredService<IRemoveLabelUseCase<OtherResource>>();
+        await useCase.ExecuteAsync(settings.Name, settings.Key);
+        AnsiConsole.MarkupLine($"[green]Label '{settings.Key}' removed from '{settings.Name}'.[/]");
+        return 0;
+    }
+}

+ 28 - 0
Shared.Rcl/Commands/OtherHardware/OtherAddCommand.cs

@@ -0,0 +1,28 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using OtherResource = RackPeek.Domain.Resources.OtherHardware.Other;
+
+namespace Shared.Rcl.Commands.OtherHardware;
+
+public class OtherAddSettings : CommandSettings {
+    [CommandArgument(0, "<name>")] public string Name { get; set; } = default!;
+}
+
+public class OtherAddCommand(IServiceProvider provider)
+    : AsyncCommand<OtherAddSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        OtherAddSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        IAddResourceUseCase<OtherResource> useCase = scope.ServiceProvider
+            .GetRequiredService<IAddResourceUseCase<OtherResource>>();
+
+        await useCase.ExecuteAsync(settings.Name);
+
+        AnsiConsole.MarkupLine($"[green]Other hardware '{settings.Name}' added.[/]");
+        return 0;
+    }
+}

+ 28 - 0
Shared.Rcl/Commands/OtherHardware/OtherDeleteCommand.cs

@@ -0,0 +1,28 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using OtherResource = RackPeek.Domain.Resources.OtherHardware.Other;
+
+namespace Shared.Rcl.Commands.OtherHardware;
+
+public class OtherNameSettings : CommandSettings {
+    [CommandArgument(0, "<name>")] public string Name { get; set; } = default!;
+}
+
+public class OtherDeleteCommand(IServiceProvider provider)
+    : AsyncCommand<OtherNameSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        OtherNameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        IDeleteResourceUseCase<OtherResource> useCase = scope.ServiceProvider
+            .GetRequiredService<IDeleteResourceUseCase<OtherResource>>();
+
+        await useCase.ExecuteAsync(settings.Name);
+
+        AnsiConsole.MarkupLine($"[green]Other hardware '{settings.Name}' deleted.[/]");
+        return 0;
+    }
+}

+ 34 - 0
Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs

@@ -0,0 +1,34 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware;
+
+public class OtherDescribeCommand(IServiceProvider provider)
+    : AsyncCommand<OtherNameSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        OtherNameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        DescribeOtherUseCase useCase = scope.ServiceProvider.GetRequiredService<DescribeOtherUseCase>();
+
+        OtherDescription other = await useCase.ExecuteAsync(settings.Name);
+
+        Grid grid = new Grid()
+            .AddColumn()
+            .AddColumn();
+
+        grid.AddRow("Name:", other.Name.EscapeMarkup());
+        grid.AddRow("Model:", (other.Model ?? "Unknown").EscapeMarkup());
+        grid.AddRow("Description:", (other.Description ?? "Unknown").EscapeMarkup());
+
+        if (other.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", other.Labels.Select(kvp => $"{kvp.Key.EscapeMarkup()}: {kvp.Value.EscapeMarkup()}")));
+
+        AnsiConsole.Write(new Panel(grid).Header("Other").Border(BoxBorder.Rounded));
+
+        return 0;
+    }
+}

+ 24 - 0
Shared.Rcl/Commands/OtherHardware/OtherGetByNameCommand.cs

@@ -0,0 +1,24 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware;
+
+public class OtherGetByNameCommand(IServiceProvider provider)
+    : AsyncCommand<OtherNameSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        OtherNameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        DescribeOtherUseCase useCase = scope.ServiceProvider.GetRequiredService<DescribeOtherUseCase>();
+
+        OtherDescription other = await useCase.ExecuteAsync(settings.Name);
+
+        AnsiConsole.MarkupLine(
+            $"[green]{other.Name}[/]  Model: {other.Model ?? "Unknown"}, Description: {other.Description ?? "Unknown"}");
+
+        return 0;
+    }
+}

+ 39 - 0
Shared.Rcl/Commands/OtherHardware/OtherGetCommand.cs

@@ -0,0 +1,39 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware;
+
+public class OtherGetCommand(IServiceProvider provider)
+    : AsyncCommand {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        OtherHardwareReportUseCase useCase = scope.ServiceProvider.GetRequiredService<OtherHardwareReportUseCase>();
+
+        OtherHardwareReport report = await useCase.ExecuteAsync();
+
+        if (report.Others.Count == 0) {
+            AnsiConsole.MarkupLine("[yellow]No other hardware found.[/]");
+            return 0;
+        }
+
+        Table table = new Table()
+            .Border(TableBorder.Rounded)
+            .AddColumn("Name")
+            .AddColumn("Model")
+            .AddColumn("Description");
+
+        foreach (OtherHardwareRow other in report.Others)
+            table.AddRow(
+                other.Name.EscapeMarkup(),
+                other.Model.EscapeMarkup(),
+                other.Description.EscapeMarkup()
+            );
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 38 - 0
Shared.Rcl/Commands/OtherHardware/OtherReportCommand.cs

@@ -0,0 +1,38 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware;
+
+public class OtherReportCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand {
+    protected override async Task<int> ExecuteAsync(CommandContext context, CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        OtherHardwareReportUseCase useCase = scope.ServiceProvider.GetRequiredService<OtherHardwareReportUseCase>();
+
+        OtherHardwareReport report = await useCase.ExecuteAsync();
+
+        if (report.Others.Count == 0) {
+            AnsiConsole.MarkupLine("[yellow]No other hardware found.[/]");
+            return 0;
+        }
+
+        Table table = new Table()
+            .Border(TableBorder.Rounded)
+            .AddColumn("Name")
+            .AddColumn("Model")
+            .AddColumn("Description");
+
+        foreach (OtherHardwareRow other in report.Others)
+            table.AddRow(
+                other.Name,
+                other.Model,
+                other.Description
+            );
+
+        AnsiConsole.Write(table);
+        return 0;
+    }
+}

+ 27 - 0
Shared.Rcl/Commands/OtherHardware/OtherSetCommand.cs

@@ -0,0 +1,27 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Resources.OtherHardware;
+using Spectre.Console;
+using Spectre.Console.Cli;
+
+namespace Shared.Rcl.Commands.OtherHardware;
+
+public class OtherSetSettings : OtherNameSettings {
+    [CommandOption("--model")] public string? Model { get; set; }
+    [CommandOption("--description")] public string? Description { get; set; }
+}
+
+public class OtherSetCommand(IServiceProvider provider)
+    : AsyncCommand<OtherSetSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        OtherSetSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = provider.CreateScope();
+        UpdateOtherUseCase useCase = scope.ServiceProvider.GetRequiredService<UpdateOtherUseCase>();
+
+        await useCase.ExecuteAsync(settings.Name, settings.Model, settings.Description);
+
+        AnsiConsole.MarkupLine($"[green]Other hardware '{settings.Name}' updated.[/]");
+        return 0;
+    }
+}

+ 29 - 0
Shared.Rcl/Commands/OtherHardware/Rename/OtherRenameCommand.cs

@@ -0,0 +1,29 @@
+using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.UseCases;
+using Spectre.Console;
+using Spectre.Console.Cli;
+using OtherResource = RackPeek.Domain.Resources.OtherHardware.Other;
+
+namespace Shared.Rcl.Commands.OtherHardware.Rename;
+
+public class OtherRenameSettings : OtherNameSettings {
+    [CommandArgument(1, "<new-name>")]
+    public string NewName { get; set; } = default!;
+}
+
+public class OtherRenameCommand(
+    IServiceProvider serviceProvider
+) : AsyncCommand<OtherRenameSettings> {
+    protected override async Task<int> ExecuteAsync(
+        CommandContext context,
+        OtherRenameSettings settings,
+        CancellationToken cancellationToken) {
+        using IServiceScope scope = serviceProvider.CreateScope();
+        IRenameResourceUseCase<OtherResource> renameUseCase = scope.ServiceProvider.GetRequiredService<IRenameResourceUseCase<OtherResource>>();
+
+        await renameUseCase.ExecuteAsync(settings.Name, settings.NewName);
+
+        AnsiConsole.MarkupLine($"[green]Other hardware '{settings.Name}' renamed to '{settings.NewName}'.[/]");
+        return 0;
+    }
+}

+ 21 - 7
Shared.Rcl/Components/LabelPage.razor

@@ -7,11 +7,13 @@
 
 <PageTitle>Label: @LabelName</PageTitle>
 
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
+     data-testid="label-page-root">
 
     <!-- Header -->
     <div class="space-y-2">
-        <h1 class="text-lg text-zinc-100">
+        <h1 class="text-lg text-zinc-100"
+            data-testid="label-page-title">
             Label: <span class="text-emerald-400">@LabelName</span>
         </h1>
     </div>
@@ -24,10 +26,12 @@
             <!-- Filter -->
             <input placeholder="filter resources..."
                    class="bg-zinc-900 border border-zinc-800 px-3 py-1 rounded focus:outline-none focus:border-emerald-600"
+                   data-testid="label-page-filter"
                    @bind="_filter"/>
 
             <!-- Sort By -->
             <select class="bg-zinc-900 border border-zinc-800 px-2 py-1 rounded"
+                    data-testid="label-page-sort"
                     @bind="_sortBy">
                 <option value="Value">Value</option>
                 <option value="Name">Name</option>
@@ -36,6 +40,7 @@
 
             <!-- Direction -->
             <button class="border border-zinc-800 px-3 py-1 rounded hover:border-emerald-600"
+                    data-testid="label-page-sort-direction"
                     @onclick="ToggleSortDirection">
                 @(_ascending ? "Asc ↑" : "Desc ↓")
             </button>
@@ -50,21 +55,29 @@
     }
     else if (_resources.Count == 0)
     {
-        <div class="text-zinc-500">no resources found for this label</div>
+        <div class="text-zinc-500"
+             data-testid="label-page-empty">
+            no resources found for this label
+        </div>
     }
     else if (!GetProcessed().Any())
     {
-        <div class="text-zinc-500">no results match your filter</div>
+        <div class="text-zinc-500"
+             data-testid="label-page-no-results">
+            no results match your filter
+        </div>
     }
     else
     {
-        <div class="space-y-3">
+        <div class="space-y-3"
+             data-testid="label-page-list">
             @foreach (var item in GetProcessed())
             {
                 var resource = item.Resource;
                 var value = item.Value;
 
-                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition">
+                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition"
+                     data-testid=@($"label-page-item-{resource.Name.Replace(" ", "-")}")>
                     <NavLink href="@GetResourceUrl(resource)"
                              class="block hover:text-emerald-300">
 
@@ -79,7 +92,8 @@
                         </div>
 
                         <!-- Label value -->
-                        <div class="mt-2 text-xs text-emerald-400 flex items-center gap-2">
+                        <div class="mt-2 text-xs text-emerald-400 flex items-center gap-2"
+                             data-testid="label-page-item-value">
                             <span>
                                 Value: @FormatValue(value)
                             </span>

+ 12 - 5
Shared.Rcl/Components/MarkdownEditor.razor

@@ -23,8 +23,9 @@
     <textarea
         class="w-full h-64 bg-zinc-950 text-zinc-200 border border-zinc-700 rounded p-3 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500"
         data-testid="@($"{BaseTestId}-textarea")"
-        value="@Value"
-        @oninput="HandleInput">
+        @bind="_text"
+        @bind:event="oninput"
+        @bind:after="NotifyChanged">
     </textarea>
 
 </div>
@@ -45,10 +46,16 @@
             ? "markdown-editor"
             : $"{TestIdPrefix}-markdown-editor";
 
-    async Task HandleInput(ChangeEventArgs e)
+    private string? _text;
+
+    protected override void OnParametersSet()
+    {
+        _text = Value;
+    }
+
+    Task NotifyChanged()
     {
-        Value = e.Value?.ToString();
-        await ValueChanged.InvokeAsync(Value);
+        return ValueChanged.InvokeAsync(_text);
     }
 
     async Task HandleSave()

+ 12 - 5
Shared.Rcl/Components/TagPage.razor

@@ -7,11 +7,13 @@
 
 <PageTitle>Tag: @TagName</PageTitle>
 
-<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6"
+     data-testid="tag-page-root">
 
     <!-- Header -->
     <div class="space-y-2">
-        <h1 class="text-lg text-zinc-100">
+        <h1 class="text-lg text-zinc-100"
+            data-testid="tag-page-title">
             Tag: <span class="text-emerald-400">@TagName</span>
         </h1>
     </div>
@@ -22,14 +24,19 @@
     }
     else if (_resources.Count == 0)
     {
-        <div class="text-zinc-500">no resources found for this tag</div>
+        <div class="text-zinc-500"
+             data-testid="tag-page-empty">
+            no resources found for this tag
+        </div>
     }
     else
     {
-        <div class="space-y-3">
+        <div class="space-y-3"
+             data-testid="tag-page-list">
             @foreach (var resource in _resources.OrderBy(r => r.Name))
             {
-                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition">
+                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition"
+                     data-testid=@($"tag-page-item-{resource.Name.Replace(" ", "-")}")>
                     <NavLink href="@GetResourceUrl(resource)"
                              class="block hover:text-emerald-300">
 

+ 3 - 1
Shared.Rcl/Connections/ConnectionsPage.razor

@@ -1,12 +1,14 @@
 @page "/connections"
 
-<div class="p-6 space-y-4">
+<div class="p-6 space-y-4"
+     data-testid="connections-page-root">
 
     <div class="text-zinc-200 text-lg">
         Connections
     </div>
 
     <button class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+            data-testid="connections-add-button"
             @onclick="OpenModal">
         Add Connection
     </button>

+ 5 - 4
Shared.Rcl/Docs/DocsHomePage.razor

@@ -1,10 +1,11 @@
 @page "/docs"
 @page "/docs/{*Page}"
 
-@inject HttpClient Http
+@inject IDocsContentProvider Docs
 @inject NavigationManager Nav
 @inject IJSRuntime JS
 @using Markdig
+@using System.Text.Json
 @implements IDisposable
 
 <PageTitle>Docs@(!string.IsNullOrWhiteSpace(_activeTitle) ? $": {_activeTitle}" : "")</PageTitle>
@@ -164,7 +165,7 @@
             var url = $"_content/Shared.Rcl/raw_docs/{decoded}";
             _lastFetchUrl = url;
 
-            var markdown = await Http.GetStringAsync(url);
+            var markdown = await Docs.GetAsync(decoded);
             _htmlContent = Markdown.ToHtml(markdown, Pipeline);
 
             _activeTitle = DisplayName(decoded);
@@ -215,8 +216,8 @@
 
         try
         {
-            var url = "_content/Shared.Rcl/raw_docs/docs-index.json";
-            var items = await Http.GetFromJsonAsync<List<string>>(url);
+            var json = await Docs.GetAsync("docs-index.json");
+            var items = JsonSerializer.Deserialize<List<string>>(json);
 
             _docsIndex = (items ?? new List<string>())
                 .Where(x => !string.IsNullOrWhiteSpace(x))

+ 6 - 0
Shared.Rcl/Docs/HttpDocsContentProvider.cs

@@ -0,0 +1,6 @@
+namespace Shared.Rcl.Docs;
+
+public class HttpDocsContentProvider(HttpClient http) : IDocsContentProvider {
+    public Task<string> GetAsync(string fileName) =>
+        http.GetStringAsync($"_content/Shared.Rcl/raw_docs/{fileName}");
+}

+ 17 - 0
Shared.Rcl/Docs/IDocsContentProvider.cs

@@ -0,0 +1,17 @@
+namespace Shared.Rcl.Docs;
+
+/// <summary>
+///     Supplies the raw documentation assets shipped under
+///     _content/Shared.Rcl/raw_docs. The Blazor Server host reads them from
+///     the static web assets on disk; fetching them over HTTP would require
+///     the container to reach its own public URL, which fails behind a
+///     reverse proxy (issue #304). The WebAssembly viewer fetches them over
+///     HTTP relative to the app base, which is always reachable there.
+/// </summary>
+public interface IDocsContentProvider {
+    /// <summary>
+    ///     Returns the content of a file under raw_docs (e.g. "overview.md",
+    ///     "docs-index.json"). Throws if the file does not exist.
+    /// </summary>
+    Task<string> GetAsync(string fileName);
+}

+ 6 - 0
Shared.Rcl/Hardware/HardwareDetailsPage.razor

@@ -9,6 +9,7 @@
 @using RackPeek.Domain.Resources.Servers
 @using RackPeek.Domain.Resources.Switches
 @using RackPeek.Domain.Resources.SystemResources
+@using RackPeek.Domain.Resources.OtherHardware
 @using RackPeek.Domain.Resources.UpsUnits
 @using Shared.Rcl.AccessPoints
 @using Shared.Rcl.Connections
@@ -19,6 +20,7 @@
 @using Shared.Rcl.Servers
 @using Shared.Rcl.Switches
 @using Shared.Rcl.Ups
+@using Shared.Rcl.OtherHardware
 @using Router = RackPeek.Domain.Resources.Routers.Router
 
 @inject IResourceCollection Repo
@@ -81,6 +83,10 @@
             {
                 <UpsCardComponent Ups="ups" OnDeleted="DeleteCallback"/>
             }
+            else if (_hardware is Other other)
+            {
+                <OtherCardComponent Other="other" OnDeleted="DeleteCallback"/>
+            }
             else
             {
                 <div class="text-zinc-400">

+ 1 - 0
Shared.Rcl/Hardware/HardwareTreePage.razor

@@ -26,6 +26,7 @@
         <NavLink href="ups/list" data-testid="nav-ups">Ups</NavLink>
         <NavLink href="desktops/list" data-testid="nav-desktops">Desktops</NavLink>
         <NavLink href="laptops/list" data-testid="nav-laptops">Laptops</NavLink>
+        <NavLink href="other/list" data-testid="nav-other">Other</NavLink>
     </nav>
 
     @if (_tree is null)

+ 254 - 0
Shared.Rcl/OtherHardware/OtherCardComponent.razor

@@ -0,0 +1,254 @@
+@using RackPeek.Domain.Resources.OtherHardware
+@inject UpdateOtherUseCase UpdateUseCase
+@inject IGetResourceByNameUseCase<Other> GetByNameUseCase
+@inject IDeleteResourceUseCase<Other> DeleteUseCase
+@inject IRenameResourceUseCase<Other> RenameUseCase
+@inject ICloneResourceUseCase<Other> CloneUseCase
+@inject NavigationManager Nav
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900"
+     data-testid=@($"other-item-{Other.Name.Replace(" ", "-")}")>
+
+    <div class="flex justify-between items-center mb-3">
+
+        <div class="text-zinc-100 hover:text-emerald-300">
+            <NavLink href="@($"resources/hardware/{Uri.EscapeDataString(Other.Name)}")"
+                     class="block"
+                     data-testid=@($"other-item-{Other.Name.Replace(" ", "-")}-link")>
+                @Other.Name
+            </NavLink>
+        </div>
+
+        <div class="flex gap-3 text-xs">
+            @if (!_isEditing)
+            {
+                <button data-testid="edit-other-button"
+                        class="text-zinc-400 hover:text-zinc-200"
+                        @onclick="BeginEdit">
+                    Edit
+                </button>
+
+                <button data-testid="rename-other-button"
+                        class="text-blue-400 hover:text-blue-300 transition"
+                        @onclick="OpenRename">
+                    Rename
+                </button>
+
+                <button data-testid="clone-other-button"
+                        class="text-emerald-400 hover:text-emerald-300 transition"
+                        @onclick="OpenClone">
+                    Clone
+                </button>
+
+                <button data-testid="delete-other-button"
+                        class="text-red-400 hover:text-red-300 transition"
+                        @onclick="ConfirmDelete">
+                    Delete
+                </button>
+            }
+            else
+            {
+                <button data-testid="save-other-button"
+                        class="text-emerald-400 hover:text-emerald-300"
+                        @onclick="Save">
+                    Save
+                </button>
+
+                <button data-testid="cancel-other-button"
+                        class="text-zinc-500 hover:text-zinc-300"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+            }
+        </div>
+    </div>
+
+    <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
+
+        <!-- Model -->
+        <div data-testid="other-model-section">
+            <div class="text-zinc-400 mb-1">Model</div>
+
+            @if (_isEditing)
+            {
+                <input data-testid="other-model-input"
+                       class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
+                       @bind="_edit.Model" />
+            }
+            else if (!string.IsNullOrWhiteSpace(Other.Model))
+            {
+                <div class="text-zinc-300"
+                     data-testid="other-model-value">
+                    @Other.Model
+                </div>
+            }
+        </div>
+
+        <!-- Description -->
+        <div data-testid="other-description-section">
+            <div class="text-zinc-400 mb-1">Description</div>
+
+            @if (_isEditing)
+            {
+                <input data-testid="other-description-input"
+                       class="w-full px-3 py-2 rounded-md bg-zinc-800 text-zinc-100 border border-zinc-600"
+                       @bind="_edit.Description" />
+            }
+            else if (!string.IsNullOrWhiteSpace(Other.Description))
+            {
+                <div class="text-zinc-300"
+                     data-testid="other-description-value">
+                    @Other.Description
+                </div>
+            }
+        </div>
+
+        <ResourceTagEditor Resource="Other"
+                           TestIdPrefix="other" />
+
+        <ResourceLabelEditor Resource="Other"
+                             TestIdPrefix="other" />
+
+        <div class="md:col-span-2"
+             data-testid="other-notes-section">
+
+            <div class="text-zinc-400 mb-1">Notes</div>
+
+            @if (_isEditing)
+            {
+                <MarkdownEditor @bind-Value="_edit.Notes"
+                                ShowActionButtons="false"
+                                TestIdPrefix="other-notes-editor" />
+            }
+            else
+            {
+                <MarkdownViewer Value="@Other.Notes"
+                                ShowEditButton="false"
+                                TestIdPrefix="other-notes-viewer" />
+            }
+        </div>
+    </div>
+</div>
+
+<ConfirmModal IsOpen="_confirmDeleteOpen"
+              IsOpenChanged="v => _confirmDeleteOpen = v"
+              Title="Delete Other Hardware"
+              ConfirmText="Delete"
+              ConfirmClass="bg-red-600 hover:bg-red-500"
+              OnConfirm="DeleteOther"
+              TestIdPrefix="Other">
+    Are you sure you want to delete <strong>@Other.Name</strong>?
+</ConfirmModal>
+
+<StringValueModal IsOpen="_renameOpen"
+                  IsOpenChanged="v => _renameOpen = v"
+                  Title="Rename Other Hardware"
+                  Description="Enter a new name for this hardware"
+                  Label="New name"
+                  Value="@Other.Name"
+                  OnSubmit="HandleRenameSubmit"
+                  TestIdPrefix="other-rename" />
+
+<StringValueModal IsOpen="_cloneOpen"
+                  IsOpenChanged="v => _cloneOpen = v"
+                  Title="Clone resource"
+                  Description="Enter a name for the cloned resource"
+                  Label="New resource name"
+                  Value="@($"{Other.Name}-copy")"
+                  OnSubmit="HandleCloneSubmit"
+                  TestIdPrefix="other-clone" />
+
+@code {
+    [Parameter] [EditorRequired] public Other Other { get; set; } = default!;
+
+    [Parameter] public EventCallback<string> OnDeleted { get; set; }
+
+    bool _isEditing;
+    bool _confirmDeleteOpen;
+
+    OtherEditModel _edit = new();
+
+    void BeginEdit()
+    {
+        _edit = OtherEditModel.From(Other);
+        _isEditing = true;
+    }
+
+    async Task Save()
+    {
+        _isEditing = false;
+
+        await UpdateUseCase.ExecuteAsync(
+            Other.Name,
+            _edit.Model,
+            _edit.Description,
+            _edit.Notes);
+
+        Other = await GetByNameUseCase.ExecuteAsync(Other.Name);
+    }
+
+    void Cancel()
+    {
+        _isEditing = false;
+    }
+
+    void ConfirmDelete()
+    {
+        _confirmDeleteOpen = true;
+    }
+
+    async Task DeleteOther()
+    {
+        _confirmDeleteOpen = false;
+
+        await DeleteUseCase.ExecuteAsync(Other.Name);
+
+        if (OnDeleted.HasDelegate)
+            await OnDeleted.InvokeAsync(Other.Name);
+    }
+
+    bool _renameOpen;
+
+    void OpenRename()
+    {
+        _renameOpen = true;
+    }
+
+    async Task HandleRenameSubmit(string newName)
+    {
+        await RenameUseCase.ExecuteAsync(Other.Name, newName);
+        Nav.NavigateTo($"resources/hardware/{Uri.EscapeDataString(newName)}");
+    }
+
+    bool _cloneOpen;
+
+    void OpenClone()
+    {
+        _cloneOpen = true;
+    }
+
+    async Task HandleCloneSubmit(string newName)
+    {
+        await CloneUseCase.ExecuteAsync(Other.Name, newName);
+
+        Nav.NavigateTo($"resources/hardware/{Uri.EscapeDataString(newName)}");
+    }
+
+    public class OtherEditModel
+    {
+        public string? Model { get; set; }
+        public string? Description { get; set; }
+        public string? Notes { get; set; }
+
+        public static OtherEditModel From(Other other)
+        {
+            return new OtherEditModel
+            {
+                Model = other.Model,
+                Description = other.Description,
+                Notes = other.Notes
+            };
+        }
+    }
+
+}

+ 32 - 0
Shared.Rcl/OtherHardware/OtherListPage.razor

@@ -0,0 +1,32 @@
+@page "/other/list"
+@using RackPeek.Domain.Resources.OtherHardware
+@inject NavigationManager Nav
+
+<ResourcesListComponent TResource="Other"
+                        Title="Other"
+                        TestId="other"
+                        OnCreated="NavigateToNewResource">
+
+    <ItemTemplate Context="other">
+        <OtherCardComponent Other="other"
+                          OnDeleted="Reload"/>
+    </ItemTemplate>
+
+</ResourcesListComponent>
+
+@code {
+
+    [Inject] IGetAllResourcesByKindUseCase<Other> GetAllUseCase { get; set; } = default!;
+
+    private Task NavigateToNewResource(string name)
+    {
+        Nav.NavigateTo($"resources/hardware/{Uri.EscapeDataString(name)}");
+        return Task.CompletedTask;
+    }
+
+    private async Task Reload(string _)
+    {
+        await GetAllUseCase.ExecuteAsync();
+    }
+
+}

+ 3 - 3
Shared.Rcl/Shared.Rcl.csproj

@@ -14,10 +14,10 @@
     </ItemGroup>
 
     <ItemGroup>
-        <PackageReference Include="Markdig" Version="1.2.0"/>
-        <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.9" />
+        <PackageReference Include="Markdig" Version="1.4.0"/>
+        <PackageReference Include="Microsoft.AspNetCore.Components.Web" Version="10.0.12" />
         <PackageReference Include="Spectre.Console.Cli" Version="0.55.0"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0" />
+        <PackageReference Include="Spectre.Console.Testing" Version="0.57.2" />
     </ItemGroup>
 
     <ItemGroup>

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

@@ -94,7 +94,7 @@
             }
             else if (!string.IsNullOrWhiteSpace(System.Type))
             {
-                <div class="text-zinc-300">@System.Type</div>
+                <div class="text-zinc-300" data-testid="system-type-value">@System.Type</div>
             }
         </div>
 
@@ -179,7 +179,8 @@
             }
             else if (!string.IsNullOrWhiteSpace(EffectiveIp))
             {
-                <div class="text-zinc-300">
+                <div class="text-zinc-300"
+                     data-testid="system-ip-value">
                     @EffectiveIp
                     @if (string.IsNullOrWhiteSpace(System.Ip))
                     {

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

@@ -23,7 +23,10 @@ public sealed class SystemEditModel {
     public static SystemEditModel From(SystemResource system) {
         return new SystemEditModel {
             Name = system.Name,
-            Type = system.Type,
+            // The type dropdown has no empty option, so a system without a
+            // type still displays the first choice; default to it so what the
+            // user sees is what gets saved (#306).
+            Type = system.Type ?? SystemResource.ValidSystemTypes[0],
             Os = system.Os,
             Cores = system.Cores,
             Ram = system.Ram,

+ 2 - 1
Shared.Rcl/YamlFileComponent.razor

@@ -98,7 +98,8 @@
     }
     else
     {
-        <pre class="text-zinc-300 text-xs whitespace-pre-wrap">@_currentText</pre>
+        <pre class="text-zinc-300 text-xs whitespace-pre-wrap"
+             data-testid="yaml-file-content">@_currentText</pre>
     }
 </div>
 

+ 31 - 1
Shared.Rcl/YamlImportPage.razor

@@ -110,7 +110,8 @@
                 Import Summary
             </div>
 
-            @if (!_added.Any() && !_updated.Any() && !_replaced.Any())
+            @if (!_added.Any() && !_updated.Any() && !_replaced.Any()
+                 && !_connectionsAdded.Any() && !_connectionsRemoved.Any())
             {
                 <div class="text-zinc-500 italic mt-2">
                     No changes detected.
@@ -194,6 +195,27 @@
                 }
             }
 
+            @if (_connectionsAdded.Any() || _connectionsRemoved.Any())
+            {
+                <div class="text-zinc-400 mt-4 mb-2" data-testid="import-connections-summary">
+                    Connections
+                </div>
+
+                @foreach (var connection in _connectionsAdded)
+                {
+                    <div class="ml-4 text-emerald-400" data-testid="import-connection-added">
+                        + @connection
+                    </div>
+                }
+
+                @foreach (var connection in _connectionsRemoved)
+                {
+                    <div class="ml-4 text-red-400" data-testid="import-connection-removed">
+                        - @connection
+                    </div>
+                }
+            }
+
         </div>
     }
 
@@ -204,6 +226,8 @@
     private List<string> _added = new();
     private List<string> _updated = new();
     private List<string> _replaced = new();
+    private List<string> _connectionsAdded = new();
+    private List<string> _connectionsRemoved = new();
 
     private Dictionary<string, string> _oldYaml = new(StringComparer.OrdinalIgnoreCase);
     private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
@@ -228,6 +252,8 @@
         _added.Clear();
         _updated.Clear();
         _replaced.Clear();
+        _connectionsAdded.Clear();
+        _connectionsRemoved.Clear();
         _oldYaml.Clear();
         _newYaml.Clear();
 
@@ -246,6 +272,8 @@
             _added = result.Added;
             _updated = result.Updated;
             _replaced = result.Replaced;
+            _connectionsAdded = result.ConnectionsAdded;
+            _connectionsRemoved = result.ConnectionsRemoved;
 
             _oldYaml = result.OldYaml;
             _newYaml = result.NewYaml;
@@ -278,6 +306,8 @@
             _added.Clear();
             _updated.Clear();
             _replaced.Clear();
+            _connectionsAdded.Clear();
+            _connectionsRemoved.Clear();
         }
         catch (Exception ex)
         {

+ 15 - 0
Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md

@@ -135,6 +135,21 @@
     - [tag](docs/Commands.md#rpk-ups-tag) - Manage tags on a UPS unit
       - [add](docs/Commands.md#rpk-ups-tag-add) - Add a tag to a UPS unit
       - [remove](docs/Commands.md#rpk-ups-tag-remove) - Remove a tag from a UPS unit
+  - [other](docs/Commands.md#rpk-other) - Manage other hardware that doesn't fit an existing category
+    - [summary](docs/Commands.md#rpk-other-summary) - Show a hardware report for all other hardware
+    - [add](docs/Commands.md#rpk-other-add) - Add new other hardware
+    - [list](docs/Commands.md#rpk-other-list) - List all other hardware
+    - [get](docs/Commands.md#rpk-other-get) - Retrieve other hardware by name
+    - [describe](docs/Commands.md#rpk-other-describe) - Show detailed information about other hardware
+    - [set](docs/Commands.md#rpk-other-set) - Update properties of other hardware
+    - [del](docs/Commands.md#rpk-other-del) - Delete other hardware
+    - [rename](docs/Commands.md#rpk-other-rename) - Rename other hardware to a new name
+    - [label](docs/Commands.md#rpk-other-label) - Manage labels on other hardware
+      - [add](docs/Commands.md#rpk-other-label-add) - Add a label to other hardware
+      - [remove](docs/Commands.md#rpk-other-label-remove) - Remove a label from other hardware
+    - [tag](docs/Commands.md#rpk-other-tag) - Manage tags on other hardware
+      - [add](docs/Commands.md#rpk-other-tag-add) - Add a tag to other hardware
+      - [remove](docs/Commands.md#rpk-other-tag-remove) - Remove a tag from other hardware
   - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
     - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
     - [list](docs/Commands.md#rpk-desktops-list) - List all desktops

+ 256 - 17
Shared.Rcl/wwwroot/raw_docs/cli-commands.md

@@ -10,23 +10,24 @@ OPTIONS:
     -v, --version    Prints version information
 
 COMMANDS:
-    summary         Show a summarized report of all resources in the system
-    servers         Manage servers and their components                    
-    switches        Manage network switches                                
-    routers         Manage network routers                                 
-    firewalls       Manage firewalls                                       
-    systems         Manage systems and their dependencies                  
-    accesspoints    Manage access points                                   
-    ups             Manage UPS units                                       
-    desktops        Manage desktop computers and their components          
-    laptops         Manage Laptop computers and their components           
-    services        Manage services and their configurations               
-    ansible         Generate and manage Ansible inventory                  
-    ssh             Generate SSH configuration from infrastructure         
-    hosts           Generate a hosts file from infrastructure              
-    graph           Render inventory as graph diagrams                     
-    tags            Discover tags across resources                         
-    connections     Manage physical or logical port connections            
+    summary         Show a summarized report of all resources in the system    
+    servers         Manage servers and their components                        
+    switches        Manage network switches                                    
+    routers         Manage network routers                                     
+    firewalls       Manage firewalls                                           
+    systems         Manage systems and their dependencies                      
+    accesspoints    Manage access points                                       
+    ups             Manage UPS units                                           
+    other           Manage other hardware that doesn't fit an existing category
+    desktops        Manage desktop computers and their components              
+    laptops         Manage Laptop computers and their components               
+    services        Manage services and their configurations                   
+    ansible         Generate and manage Ansible inventory                      
+    ssh             Generate SSH configuration from infrastructure             
+    hosts           Generate a hosts file from infrastructure                  
+    graph           Render inventory as graph diagrams                         
+    tags            Discover tags across resources                             
+    connections     Manage physical or logical port connections                
 ```
 
 ## `rpk summary`
@@ -2231,6 +2232,244 @@ OPTIONS:
     -h, --help    Prints help information
 ```
 
+## `rpk other`
+```
+DESCRIPTION:
+Manage other hardware that doesn't fit an existing category
+
+USAGE:
+    rpk other [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    summary                     Show a hardware report for all other hardware 
+    add <name>                  Add new other hardware                        
+    list                        List all other hardware                       
+    get <name>                  Retrieve other hardware by name               
+    describe <name>             Show detailed information about other hardware
+    set <name>                  Update properties of other hardware           
+    del <name>                  Delete other hardware                         
+    rename <name> <new-name>    Rename other hardware to a new name           
+    label                       Manage labels on other hardware               
+    tag                         Manage tags on other hardware                 
+```
+
+## `rpk other summary`
+```
+DESCRIPTION:
+Show a hardware report for all other hardware
+
+USAGE:
+    rpk other summary [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other add`
+```
+DESCRIPTION:
+Add new other hardware
+
+USAGE:
+    rpk other add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other list`
+```
+DESCRIPTION:
+List all other hardware
+
+USAGE:
+    rpk other list [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other get`
+```
+DESCRIPTION:
+Retrieve other hardware by name
+
+USAGE:
+    rpk other get <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other describe`
+```
+DESCRIPTION:
+Show detailed information about other hardware
+
+USAGE:
+    rpk other describe <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other set`
+```
+DESCRIPTION:
+Update properties of other hardware
+
+USAGE:
+    rpk other set <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help           Prints help information
+        --model                                 
+        --description                           
+```
+
+## `rpk other del`
+```
+DESCRIPTION:
+Delete other hardware
+
+USAGE:
+    rpk other del <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other rename`
+```
+DESCRIPTION:
+Rename other hardware to a new name
+
+USAGE:
+    rpk other rename <name> <new-name> [OPTIONS]
+
+ARGUMENTS:
+    <name>         
+    <new-name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other label`
+```
+DESCRIPTION:
+Manage labels on other hardware
+
+USAGE:
+    rpk other label [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    add <name>       Add a label to other hardware     
+    remove <name>    Remove a label from other hardware
+```
+
+## `rpk other label add`
+```
+DESCRIPTION:
+Add a label to other hardware
+
+USAGE:
+    rpk other label add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help             Prints help information
+        --key <KEY>                               
+        --value <VALUE>                           
+```
+
+## `rpk other label remove`
+```
+DESCRIPTION:
+Remove a label from other hardware
+
+USAGE:
+    rpk other label remove <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help         Prints help information
+        --key <KEY>                           
+```
+
+## `rpk other tag`
+```
+DESCRIPTION:
+Manage tags on other hardware
+
+USAGE:
+    rpk other tag [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    add <name> <tag>       Add a tag to other hardware     
+    remove <name> <tag>    Remove a tag from other hardware
+```
+
+## `rpk other tag add`
+```
+DESCRIPTION:
+Add a tag to other hardware
+
+USAGE:
+    rpk other tag add <name> <tag> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+    <tag>      
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other tag remove`
+```
+DESCRIPTION:
+Remove a tag from other hardware
+
+USAGE:
+    rpk other tag remove <name> <tag> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+    <tag>      
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
 ## `rpk desktops`
 ```
 DESCRIPTION:

+ 8 - 0
Shared.Rcl/wwwroot/raw_docs/install-guide.md

@@ -58,6 +58,14 @@ http://localhost:8080
 
 This uses a **named volume**, which avoids permission issues and is recommended for most users.
 
+RackPeek stores its ASP.NET DataProtection keys under `config/.dataprotection` on the same volume, so browser sessions survive container upgrades. If you run the container with a **read-only root filesystem**, also mount a tmpfs for the app's temp directory:
+
+```yaml
+    read_only: true
+    tmpfs:
+      - /app/tmp:uid=1654,gid=0
+```
+
 ---
 
 ## Portainer

+ 1 - 1
Shared.Rcl/wwwroot/raw_docs/inventory-api.md

@@ -118,7 +118,7 @@ in the repository.
 
 The `kind` discriminator is case-sensitive. Use the exact value for each
 resource type: `Server`, `Switch`, `Router`, `Firewall`, `AccessPoint`,
-`Ups`, `Desktop`, `Laptop`, `Service`, `System`.
+`Ups`, `Desktop`, `Laptop`, `Other`, `Service`, `System`.
 
 #### Response
 

+ 1 - 0
Shared.Rcl/wwwroot/raw_docs/resource-levels.md

@@ -42,6 +42,7 @@ network switches, Wi-Fi access points, UPS units, and workstations.
 | **Desktop**      | Desktop workstation          | Custom build, Mac Mini                   |
 | **Laptop**       | Laptop                       | ThinkPad T14s                            |
 | **UPS**          | Uninterruptible power supply | APC SMT1500                              |
+| **Other**        | Anything that doesn't fit an existing category | Microwave radio bridge, KVM switch |
 
 ### Sub-Resources
 

+ 66 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -457,4 +457,70 @@ public class AccessPointCardTests(
             await context.CloseAsync();
         }
     }
+
+    [Fact]
+    public async Task User_Can_Type_In_The_Middle_Of_Notes_Without_The_Cursor_Jumping() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        await BlazorLatency.AddAsync(page, TimeSpan.FromMilliseconds(50));
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+
+        try {
+            AccessPointCardPom card = await CreateAccessPointAsync(page, name);
+
+            await card.BeginEditAsync(name);
+            await card.SetNotesAsync(name, "Line one\nLine three");
+            await card.TypeNotesLineAfterFirstLineAsync(name, "Line two typed in the middle");
+
+            await Assertions.Expect(card.NotesEditorTextarea(name))
+                .ToHaveValueAsync("Line one\nLine two typed in the middle\nLine three");
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Type_Notes_Quickly_Over_A_Slow_Connection_And_Save() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        await BlazorLatency.AddAsync(page, TimeSpan.FromMilliseconds(50));
+        var name = $"e2e-ap-{Guid.NewGuid():N}"[..16];
+        var notes = "This is currently in bridge mode and uplinked to the core switch.";
+
+        try {
+            AccessPointCardPom card = await CreateAccessPointAsync(page, name);
+
+            await card.BeginEditAsync(name);
+            await card.TypeNotesAsync(name, notes);
+
+            await Assertions.Expect(card.NotesEditorTextarea(name)).ToHaveValueAsync(notes);
+
+            await card.SaveAsync(name);
+
+            await Assertions.Expect(card.NotesViewerContent(name)).ToHaveTextAsync(notes);
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    private async Task<AccessPointCardPom> CreateAccessPointAsync(IPage page, string name) {
+        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);
+        return card;
+    }
 }

+ 182 - 0
Tests.E2e/ConnectionsPageTests.cs

@@ -0,0 +1,182 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the global /connections page — previously the only page in
+///     the app with no test ids at all, and the only route from which a
+///     connection can be made without going through a resource card.
+///     Fixtures are seeded through the YAML import rather than by clicking port
+///     groups into existence, which keeps the setup to one step.
+/// </summary>
+public class ConnectionsPageTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    private const string _portType = "rj45";
+    private const string _portSpeed = "1";
+    private const int _portCount = 4;
+
+    private static string TwoSwitchesNoConnection(string switchA, string switchB) =>
+        $"""
+         version: 3
+         resources:
+           - kind: Switch
+             name: {switchA}
+             ports:
+               - type: {_portType}
+                 speed: {_portSpeed}
+                 count: {_portCount}
+           - kind: Switch
+             name: {switchB}
+             ports:
+               - type: {_portType}
+                 speed: {_portSpeed}
+                 count: {_portCount}
+         """;
+
+    // =============================================================
+    // Creating a connection from the global page
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Connect_Two_Resources_From_The_Connections_Page() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-cxa-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-cxb-{Guid.NewGuid():N}"[..14];
+
+        try {
+            await SeedSwitchesAsync(page, switchA, switchB);
+
+            var connections = new ConnectionsPagePom(page);
+            await connections.GotoAsync(_fixture.BaseUrl);
+            await connections.OpenModalAsync();
+
+            await connections.Modal.CreateConnectionAsync(
+                switchA,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(1),
+                switchB,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(1));
+
+            // Submitting closes the modal.
+            await connections.Modal.AssertClosedAsync();
+
+            // The saved config is the source of truth.
+            await page.GotoAsync($"{_fixture.BaseUrl}/yaml");
+
+            ILocator yaml = page.GetByTestId("yaml-file-content");
+            await Assertions.Expect(yaml).ToBeVisibleAsync();
+            await Assertions.Expect(yaml).ToContainTextAsync("connections:");
+            await Assertions.Expect(yaml).ToContainTextAsync(switchA);
+            await Assertions.Expect(yaml).ToContainTextAsync(switchB);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Connection labels survive the round trip
+    // =============================================================
+
+    [Fact]
+    public async Task A_Connection_Label_Is_Persisted_With_The_Connection() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-cla-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-clb-{Guid.NewGuid():N}"[..14];
+        var label = $"uplink-{Guid.NewGuid():N}"[..14];
+
+        try {
+            await SeedSwitchesAsync(page, switchA, switchB);
+
+            var connections = new ConnectionsPagePom(page);
+            await connections.GotoAsync(_fixture.BaseUrl);
+            await connections.OpenModalAsync();
+
+            await connections.Modal.CreateConnectionAsync(
+                switchA,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(2),
+                switchB,
+                ConnectionModalPom.GroupLabel(_portType, _portSpeed, _portCount),
+                ConnectionModalPom.PortLabel(2),
+                label);
+
+            await connections.Modal.AssertClosedAsync();
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/yaml");
+
+            ILocator yaml = page.GetByTestId("yaml-file-content");
+            await Assertions.Expect(yaml).ToBeVisibleAsync();
+            await Assertions.Expect(yaml).ToContainTextAsync(label);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Opening and dismissing the modal
+    // =============================================================
+
+    [Fact]
+    public async Task Connections_Page_Opens_The_Connection_Modal() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            var connections = new ConnectionsPagePom(page);
+            await connections.GotoAsync(_fixture.BaseUrl);
+
+            // The modal is not mounted until asked for.
+            await connections.Modal.AssertClosedAsync();
+
+            await connections.OpenModalAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task SeedSwitchesAsync(IPage page, string switchA, string switchB) {
+        var import = new YamlImportPom(page);
+        await import.GotoAsync(_fixture.BaseUrl);
+        await import.PasteAsync(TwoSwitchesNoConnection(switchA, switchB));
+        await import.AssertNoErrorAsync();
+        await import.ApplyAsync();
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}

+ 157 - 0
Tests.E2e/DocsTests.cs

@@ -0,0 +1,157 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the /docs knowledge base. The Blazor Server host reads the
+///     docs assets off disk via StaticWebAssetDocsContentProvider; fetching them
+///     over HTTP breaks behind a reverse proxy (issue #304), and the failure is
+///     silent — the page still renders, just with a "document not found"
+///     placeholder. These tests assert on resolved content for that reason.
+/// </summary>
+public class DocsTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Overview renders (issue #304 regression)
+    // =============================================================
+
+    [Fact]
+    public async Task Docs_Overview_Renders_Content_Not_A_Not_Found_Placeholder() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+
+            // The index and the default overview document must both resolve.
+            await docs.AssertIndexResolvedAsync();
+            await docs.AssertContentResolvedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Deep link renders
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Deep_Link_Straight_To_A_Docs_Page() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            // Navigating directly (rather than clicking through) is the path a
+            // bookmark or an external link takes.
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs/ssh-config-export");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+            await docs.AssertContentResolvedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Navigation between docs
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Navigate_Between_Docs_From_The_Index() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+            await docs.AssertIndexResolvedAsync();
+
+            await docs.OpenDocAsync("install-guide.md");
+            await docs.AssertContentResolvedAsync();
+
+            // A second hop exercises OnParametersSetAsync re-fetching for an
+            // already-initialised component, not just the first render.
+            await docs.OpenDocAsync("versioning.md");
+            await docs.AssertContentResolvedAsync();
+
+            // Back to the overview via the header link.
+            await docs.HomeLink.ClickAsync();
+            await page.WaitForURLAsync("**/docs");
+            await docs.AssertContentResolvedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Sidebar search
+    // =============================================================
+
+    [Fact]
+    public async Task Docs_Search_Filters_The_Index() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/docs");
+
+            var docs = new DocsPom(page);
+            await docs.AssertLoadedAsync();
+            await docs.AssertIndexResolvedAsync();
+
+            var totalCount = await docs.IndexLinks.CountAsync();
+            Assert.True(totalCount > 1, $"Expected a multi-entry docs index, found {totalCount}.");
+
+            await docs.SearchAsync("ansible");
+
+            // Only the ansible guide should survive the filter.
+            await Assertions.Expect(docs.IndexLink("ansible-generator-guide.md")).ToBeVisibleAsync();
+            await Assertions.Expect(docs.IndexLink("versioning.md")).ToHaveCountAsync(0);
+
+            // Clearing restores the full index.
+            await docs.SearchAsync("");
+            await Assertions.Expect(docs.IndexLinks).ToHaveCountAsync(totalCount);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}

+ 160 - 0
Tests.E2e/ExportTests.cs

@@ -0,0 +1,160 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the /ssh/export and /hosts/export generators. Both render
+///     from whatever is in the config, so each test creates the system it
+///     asserts on rather than relying on seed data — the container starts with
+///     an empty config volume.
+///     Kept in one class so both pages share a single container.
+/// </summary>
+public class ExportTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Hosts export — localhost defaults toggle
+    // =============================================================
+
+    [Fact]
+    public async Task Hosts_Export_Honours_The_Localhost_Defaults_Toggle() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/hosts/export");
+
+            var hosts = new HostsExportPom(page);
+            await hosts.AssertVisibleAsync();
+
+            // Defaults on: the loopback block is emitted.
+            await hosts.SetIncludeLocalhostAsync(true);
+            await hosts.GenerateAsync();
+            await hosts.AssertOutputContainsAsync("127.0.0.1");
+            await hosts.AssertNoWarningsAsync();
+
+            // Defaults off: it is not.
+            await hosts.SetIncludeLocalhostAsync(false);
+            await hosts.GenerateAsync();
+            await hosts.AssertOutputDoesNotContainAsync("127.0.0.1");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Hosts export — a real system reaches the output
+    // =============================================================
+
+    [Fact]
+    public async Task Hosts_Export_Emits_An_Entry_For_A_System_With_An_Ip() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-hx-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.42.7.21";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/hosts/export");
+
+            var hosts = new HostsExportPom(page);
+            await hosts.AssertVisibleAsync();
+
+            await hosts.SetDomainSuffixAsync("home.local");
+            await hosts.GenerateAsync();
+
+            await hosts.AssertOutputContainsAsync(ip);
+            await hosts.AssertOutputContainsAsync(name);
+            await hosts.AssertOutputContainsAsync($"{name}.home.local");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // SSH export — a real system reaches the output
+    // =============================================================
+
+    [Fact]
+    public async Task Ssh_Export_Emits_A_Host_Block_For_A_System_With_An_Ip() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-sx-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.42.7.22";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/ssh/export");
+
+            var ssh = new SshExportPom(page);
+            await ssh.AssertVisibleAsync();
+
+            await ssh.SetDefaultUserAsync("ansible");
+            await ssh.SetDefaultPortAsync("2222");
+            await ssh.SetDefaultIdentityAsync("~/.ssh/id_ed25519");
+            await ssh.GenerateAsync();
+
+            await ssh.AssertOutputContainsAsync($"Host {name}");
+            await ssh.AssertOutputContainsAsync($"HostName {ip}");
+            await ssh.AssertOutputContainsAsync("User ansible");
+            await ssh.AssertOutputContainsAsync("Port 2222");
+            await ssh.AssertOutputContainsAsync("IdentityFile ~/.ssh/id_ed25519");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task CreateSystemWithIpAsync(IPage page, string name, string ip) {
+        await page.GotoAsync($"{_fixture.BaseUrl}/systems/list");
+
+        var list = new SystemsListPom(page);
+        await list.AssertLoadedAsync();
+        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.IpInput(name).FillAsync(ip);
+        await card.SaveAsync(name);
+
+        await Assertions.Expect(card.IpValue(name)).ToContainTextAsync(ip);
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}

+ 41 - 0
Tests.E2e/Infra/BlazorLatency.cs

@@ -0,0 +1,41 @@
+using System.Text.RegularExpressions;
+using System.Threading.Channels;
+using Microsoft.Playwright;
+
+namespace Tests.E2e.Infra;
+
+/// <summary>
+///     Simulates a slow network between the browser and the Blazor Server circuit
+///     by delaying every SignalR WebSocket frame in both directions. Frame order is preserved.
+///     Must be called before the page navigates, so the circuit's socket is routed.
+/// </summary>
+public static partial class BlazorLatency {
+    public static Task AddAsync(IPage page, TimeSpan oneWayDelay) =>
+        page.RouteWebSocketAsync(BlazorSocket(), ws => {
+            IWebSocketRoute server = ws.ConnectToServer();
+            ws.OnMessage(DelayedForwarder(oneWayDelay, server));
+            server.OnMessage(DelayedForwarder(oneWayDelay, ws));
+        });
+
+    private static Action<IWebSocketFrame> DelayedForwarder(TimeSpan delay, IWebSocketRoute target) {
+        var queue = Channel.CreateUnbounded<(DateTime Due, string? Text, byte[]? Binary)>();
+
+        _ = Task.Run(async () => {
+            await foreach ((DateTime due, var text, var binary) in queue.Reader.ReadAllAsync()) {
+                TimeSpan wait = due - DateTime.UtcNow;
+                if (wait > TimeSpan.Zero)
+                    await Task.Delay(wait);
+
+                if (binary is not null)
+                    target.Send(binary);
+                else
+                    target.Send(text ?? string.Empty);
+            }
+        });
+
+        return frame => queue.Writer.TryWrite((DateTime.UtcNow + delay, frame.Text, frame.Binary));
+    }
+
+    [GeneratedRegex("/_blazor")]
+    private static partial Regex BlazorSocket();
+}

+ 225 - 0
Tests.E2e/OtherCardTests.cs

@@ -0,0 +1,225 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class OtherCardTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Rename + Clone + Delete Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Rename_Clone_And_Delete_Other_From_Details_Page() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var originalName = $"e2e-oth-{Guid.NewGuid():N}"[..16];
+        var renamedName = $"e2e-oth-rn-{Guid.NewGuid():N}"[..16];
+        var cloneName = $"e2e-oth-cl-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/other/list");
+
+            var list = new OtherListPom(page);
+            await list.AddOtherAsync(originalName);
+
+            if (!page.Url.Contains($"/resources/hardware/{originalName}",
+                    StringComparison.OrdinalIgnoreCase))
+                await list.OpenOtherAsync(originalName);
+
+            var card = new OtherCardPom(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_Other() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-oth-edit-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/other/list");
+
+            var list = new OtherListPom(page);
+            await list.AddOtherAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+                await list.OpenOtherAsync(name);
+
+            var card = new OtherCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.ModelInput(name).FillAsync("Building Bridge XG");
+            await card.DescriptionInput(name).FillAsync("Microwave radio bridge");
+
+            await card.SaveAsync(name);
+
+            await Assertions.Expect(
+                card.ModelValue(name)
+            ).ToContainTextAsync("Building Bridge XG");
+
+            await Assertions.Expect(
+                card.DescriptionValue(name)
+            ).ToContainTextAsync("Microwave radio bridge");
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Cancel Edit Flow
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Cancel_Other_Edit_Without_Saving() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-oth-cancel-{Guid.NewGuid():N}"[..16];
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/other/list");
+
+            var list = new OtherListPom(page);
+            await list.AddOtherAsync(name);
+
+            if (!page.Url.Contains($"/resources/hardware/{name}",
+                    StringComparison.OrdinalIgnoreCase))
+                await list.OpenOtherAsync(name);
+
+            var card = new OtherCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            await card.BeginEditAsync(name);
+
+            await card.ModelInput(name).FillAsync("ShouldNotPersist");
+            await card.DescriptionInput(name).FillAsync("ShouldNotPersist");
+
+            await card.CancelAsync(name);
+
+            // Verify edit mode exited
+            await Assertions.Expect(
+                card.EditButton(name)
+            ).ToBeVisibleAsync();
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Tags_From_Other_Card() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-oth-{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.GotoOtherListAsync();
+
+            var list = new OtherListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddOtherAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new OtherCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            TagsPom tags = card.Tags;
+
+            // -------------------------------------------------
+            // Add multiple tags in one modal interaction
+            // -------------------------------------------------
+
+            await tags.AddTagsAsync("other", "Foo", "Bar", "Baz");
+
+            await tags.AssertTagVisibleAsync("other", "Foo");
+            await tags.AssertTagVisibleAsync("other", "Bar");
+            await tags.AssertTagVisibleAsync("other", "Baz");
+
+            // -------------------------------------------------
+            // Remove a single tag
+            // -------------------------------------------------
+
+            await tags.RemoveTagAsync("other", "Bar");
+
+            await tags.AssertTagNotVisibleAsync("other", "Bar");
+            await tags.AssertTagVisibleAsync("other", "Foo");
+            await tags.AssertTagVisibleAsync("other", "Baz");
+
+            // -------------------------------------------------
+            // Reload to verify persistence
+            // -------------------------------------------------
+
+            await page.ReloadAsync();
+
+            await tags.AssertTagVisibleAsync("other", "Foo");
+            await tags.AssertTagVisibleAsync("other", "Baz");
+            await tags.AssertTagNotVisibleAsync("other", "Bar");
+
+            await context.CloseAsync();
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+}

+ 58 - 0
Tests.E2e/OtherTests.cs

@@ -0,0 +1,58 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+public class OtherTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    [Fact]
+    public async Task User_Can_Add_And_Delete_Other() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var resourceName = $"e2e-other-{Guid.NewGuid():N}"[..16];
+
+        try {
+            // Go home
+            await page.GotoAsync(_fixture.BaseUrl);
+
+            _output.WriteLine($"URL after Goto: {page.Url}");
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwarePage = new HardwareTreePom(page);
+            await hardwarePage.AssertLoadedAsync();
+            await hardwarePage.GotoOtherListAsync();
+
+            var listPage = new OtherListPom(page);
+            await listPage.AssertLoadedAsync();
+            await listPage.AddOtherAsync(resourceName);
+            await listPage.AssertOtherExists(resourceName);
+            await listPage.DeleteOtherAsync(resourceName);
+            await listPage.AssertOtherDoesNotExist(resourceName);
+
+            await context.CloseAsync();
+        }
+        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();
+        }
+    }
+}

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

@@ -119,6 +119,33 @@ public class AccessPointCardPom(IPage page) {
     public async Task SetSpeedAsync(string accessPointName, double speed) =>
         await SpeedInput(accessPointName).FillAsync(speed.ToString(CultureInfo.InvariantCulture));
 
+    public async Task SetNotesAsync(string accessPointName, string notes) =>
+        await NotesEditorTextarea(accessPointName).FillAsync(notes);
+
+    public async Task TypeNotesAsync(string accessPointName, string text) =>
+        await NotesEditorTextarea(accessPointName).PressSequentiallyAsync(
+            text,
+            new LocatorPressSequentiallyOptions { Delay = 30 });
+
+    public async Task TypeNotesLineAfterFirstLineAsync(string accessPointName, string line) {
+        // Place the caret at the end of the first line via setSelectionRange:
+        // Home/End/Control+Home don't move the caret on macOS Chromium, so
+        // keyboard-based positioning silently types at the wrong place there.
+        // The typing itself stays as real key events.
+        ILocator textarea = NotesEditorTextarea(accessPointName);
+        await textarea.FocusAsync();
+
+        await textarea.EvaluateAsync(
+            @"el => {
+                const pos = el.value.indexOf('\n');
+                const at = pos < 0 ? el.value.length : pos;
+                el.setSelectionRange(at, at);
+            }");
+
+        await page.Keyboard.PressAsync("Enter");
+        await TypeNotesAsync(accessPointName, line);
+    }
+
     public async Task SaveAsync(string accessPointName) {
         await SaveButton(accessPointName).ClickAsync();
         await Assertions.Expect(ModelSection(accessPointName)).ToBeVisibleAsync();

+ 81 - 0
Tests.E2e/PageObjectModels/ConnectionModalPom.cs

@@ -0,0 +1,81 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     Drives PortConnectionModal, wherever it is mounted.
+///     The modal builds its test ids from its TestIdPrefix parameter, which
+///     differs by host: a resource card passes "{kind}-ports-port-group", while
+///     the global /connections page passes plain "connections". This POM is
+///     keyed on that base so both can share one implementation.
+/// </summary>
+public class ConnectionModalPom(IPage page, string baseTestId) {
+    private string Id(string suffix) => $"{baseTestId}-connection-modal-{suffix}";
+
+    public ILocator Container => page.GetByTestId(Id("container"));
+
+    public ILocator ResourceASelect => page.GetByTestId(Id("resource-a"));
+    public ILocator GroupASelect => page.GetByTestId(Id("group-a"));
+    public ILocator PortASelect => page.GetByTestId(Id("port-a"));
+
+    public ILocator ResourceBSelect => page.GetByTestId(Id("resource-b"));
+    public ILocator GroupBSelect => page.GetByTestId(Id("group-b"));
+    public ILocator PortBSelect => page.GetByTestId(Id("port-b"));
+
+    public ILocator LabelInput => page.GetByTestId(Id("label"));
+    public ILocator SubmitButton => page.GetByTestId(Id("submit"));
+    public ILocator CancelButton => page.GetByTestId(Id("cancel"));
+
+    // -------------------------------------------------
+    // Assertions
+    // -------------------------------------------------
+
+    public async Task AssertOpenAsync()
+        => await Assertions.Expect(Container).ToBeVisibleAsync();
+
+    /// <summary>
+    ///     The modal is wrapped in an @if, so when closed it is absent from the
+    ///     DOM rather than merely hidden.
+    /// </summary>
+    public async Task AssertClosedAsync()
+        => await Assertions.Expect(Container).ToHaveCountAsync(0);
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task CreateConnectionAsync(
+        string resourceA,
+        string groupA,
+        string portA,
+        string resourceB,
+        string groupB,
+        string portB,
+        string? label = null) {
+        await ResourceASelect.SelectOptionAsync(new SelectOptionValue { Label = resourceA });
+        await GroupASelect.SelectOptionAsync(new SelectOptionValue { Label = groupA });
+        await PortASelect.SelectOptionAsync(new SelectOptionValue { Label = portA });
+
+        await ResourceBSelect.SelectOptionAsync(new SelectOptionValue { Label = resourceB });
+        await GroupBSelect.SelectOptionAsync(new SelectOptionValue { Label = groupB });
+        await PortBSelect.SelectOptionAsync(new SelectOptionValue { Label = portB });
+
+        if (label is not null)
+            await LabelInput.FillAsync(label);
+
+        await SubmitButton.ClickAsync();
+    }
+
+    public async Task CancelAsync()
+        => await CancelButton.ClickAsync();
+
+    // -------------------------------------------------
+    // Option label formats, as rendered by PortConnectionModal
+    // -------------------------------------------------
+
+    public static string GroupLabel(string type, string speed, int count)
+        => $"{type} — {speed} Gbps ({count})";
+
+    public static string PortLabel(int oneBasedIndex)
+        => $"Port {oneBasedIndex}";
+}

+ 37 - 0
Tests.E2e/PageObjectModels/ConnectionsPagePom.cs

@@ -0,0 +1,37 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     The global /connections page. It mounts PortConnectionModal with
+///     TestIdPrefix="connections" — note that is the modal's base directly,
+///     without the "-port-group" segment a resource card contributes.
+/// </summary>
+public class ConnectionsPagePom(IPage page) {
+    private const string _modalBaseTestId = "connections";
+
+    public ConnectionModalPom Modal => new(page, _modalBaseTestId);
+
+    public ILocator Root
+        => page.GetByTestId("connections-page-root");
+
+    public ILocator AddButton
+        => page.GetByTestId("connections-add-button");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl) {
+        await page.GotoAsync($"{baseUrl}/connections");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task OpenModalAsync() {
+        await AddButton.ClickAsync();
+        await Modal.AssertOpenAsync();
+    }
+}

+ 86 - 0
Tests.E2e/PageObjectModels/DocsPom.cs

@@ -0,0 +1,86 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class DocsPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Viewer
+        => page.GetByTestId("docs-viewer");
+
+    public ILocator HomeLink
+        => page.GetByTestId("docs-home-link");
+
+    public ILocator SearchInput
+        => page.GetByTestId("docs-search-input");
+
+    // -------------------------------------------------
+    // Index
+    // -------------------------------------------------
+
+    /// <summary>
+    ///     Every sidebar entry. Matched on the test-id prefix so the assertions
+    ///     survive docs being added to or removed from docs-index.json.
+    /// </summary>
+    public ILocator IndexLinks
+        => page.Locator("[data-testid^='docs-index-link-']");
+
+    /// <summary>
+    ///     A single sidebar entry, addressed by its source file name
+    ///     (e.g. "overview.md" -> docs-index-link-overview-md).
+    /// </summary>
+    public ILocator IndexLink(string docFileName)
+        => page.GetByTestId($"docs-index-link-{Sanitize(docFileName)}");
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-")
+            .Replace("/", "-")
+            .Replace("\\", "-")
+            .Replace(".", "-");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertLoadedAsync() {
+        await Assertions.Expect(Viewer).ToBeVisibleAsync();
+        await Assertions.Expect(SearchInput).ToBeVisibleAsync();
+    }
+
+    public async Task OpenDocAsync(string docFileName) {
+        await IndexLink(docFileName).ClickAsync();
+        await page.WaitForURLAsync($"**/docs/{docFileName.Replace(".md", "")}");
+    }
+
+    /// <summary>
+    ///     The search box is a plain Blazor @bind, which updates on the change
+    ///     event rather than on input. Playwright's FillAsync only raises input,
+    ///     so the value must be committed with an explicit blur — elsewhere in
+    ///     the app a Generate button happens to do that as a side effect.
+    /// </summary>
+    public async Task SearchAsync(string filter) {
+        await SearchInput.FillAsync(filter);
+        await SearchInput.BlurAsync();
+    }
+
+    public async Task AssertContentContainsAsync(string text)
+        => await Assertions.Expect(Viewer).ToContainTextAsync(text);
+
+    /// <summary>
+    ///     Guards issue #304: when the Blazor Server host cannot read the docs
+    ///     assets it renders the "document not found" placeholder rather than
+    ///     failing outright, so a passing page load is not on its own proof the
+    ///     content resolved.
+    /// </summary>
+    public async Task AssertContentResolvedAsync() {
+        await Assertions.Expect(Viewer).Not.ToContainTextAsync("document not found");
+        await Assertions.Expect(Viewer).Not.ToContainTextAsync("loading documentation…");
+    }
+
+    public async Task AssertIndexResolvedAsync() {
+        await Assertions.Expect(Viewer).Not.ToContainTextAsync("docs index not found");
+        await Assertions.Expect(IndexLinks.First).ToBeVisibleAsync();
+    }
+}

+ 6 - 0
Tests.E2e/PageObjectModels/HardwareTreePom.cs

@@ -27,6 +27,7 @@ public class HardwareTreePom(IPage page) {
     public ILocator NavUps => page.GetByTestId("nav-ups");
     public ILocator NavDesktops => page.GetByTestId("nav-desktops");
     public ILocator NavLaptops => page.GetByTestId("nav-laptops");
+    public ILocator NavOther => page.GetByTestId("nav-other");
 
     public async Task GotoAsync(string baseUrl) {
         await page.GotoAsync($"{baseUrl}/hardware/tree");
@@ -145,4 +146,9 @@ public class HardwareTreePom(IPage page) {
         await NavLaptops.ClickAsync();
         await page.WaitForURLAsync("**/laptops/list");
     }
+
+    public async Task GotoOtherListAsync() {
+        await NavOther.ClickAsync();
+        await page.WaitForURLAsync("**/other/list");
+    }
 }

+ 73 - 0
Tests.E2e/PageObjectModels/HostsExportPom.cs

@@ -0,0 +1,73 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class HostsExportPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Page
+        => page.GetByTestId("hosts-export-page");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public ILocator GenerateButton
+        => page.GetByTestId("generate-hosts-button");
+
+    // -------------------------------------------------
+    // Inputs
+    // -------------------------------------------------
+
+    public ILocator IncludeTagsInput
+        => page.GetByTestId("hosts-include-tags-input");
+
+    public ILocator DomainSuffixInput
+        => page.GetByTestId("hosts-domain-suffix-input");
+
+    public ILocator IncludeLocalhostCheckbox
+        => page.GetByTestId("hosts-include-localhost-checkbox");
+
+    // -------------------------------------------------
+    // Output
+    // -------------------------------------------------
+
+    public ILocator Output
+        => page.GetByTestId("hosts-output");
+
+    public ILocator WarningsContainer
+        => page.GetByTestId("hosts-warnings");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync()
+        => await Assertions.Expect(Page).ToBeVisibleAsync();
+
+    public async Task SetIncludeTagsAsync(string value)
+        => await IncludeTagsInput.FillAsync(value);
+
+    public async Task SetDomainSuffixAsync(string value)
+        => await DomainSuffixInput.FillAsync(value);
+
+    public async Task SetIncludeLocalhostAsync(bool value)
+        => await IncludeLocalhostCheckbox.SetCheckedAsync(value);
+
+    public async Task GenerateAsync()
+        => await GenerateButton.ClickAsync();
+
+    public async Task<string> GetOutputTextAsync()
+        => await Output.InputValueAsync();
+
+    public async Task AssertOutputContainsAsync(string text)
+        => await Assertions.Expect(Output).ToContainTextAsync(text);
+
+    public async Task AssertOutputDoesNotContainAsync(string text)
+        => await Assertions.Expect(Output).Not.ToContainTextAsync(text);
+
+    public async Task AssertNoWarningsAsync()
+        => await Assertions.Expect(WarningsContainer).ToHaveCountAsync(0);
+}

+ 103 - 0
Tests.E2e/PageObjectModels/LabelPagePom.cs

@@ -0,0 +1,103 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     The /labels/{LabelName} detail page — everything carrying one label key,
+///     with that resource's value for it. Distinct from <see cref="LabelsPom" />,
+///     which drives the label editor on a resource card.
+/// </summary>
+public class LabelPagePom(IPage page) {
+    public ILocator Root
+        => page.GetByTestId("label-page-root");
+
+    public ILocator Title
+        => page.GetByTestId("label-page-title");
+
+    public ILocator List
+        => page.GetByTestId("label-page-list");
+
+    public ILocator Empty
+        => page.GetByTestId("label-page-empty");
+
+    public ILocator NoResults
+        => page.GetByTestId("label-page-no-results");
+
+    public ILocator Filter
+        => page.GetByTestId("label-page-filter");
+
+    public ILocator SortSelect
+        => page.GetByTestId("label-page-sort");
+
+    public ILocator SortDirectionButton
+        => page.GetByTestId("label-page-sort-direction");
+
+    public ILocator Item(string resourceName)
+        => page.GetByTestId($"label-page-item-{resourceName.Replace(" ", "-")}");
+
+    public ILocator ItemValue(string resourceName)
+        => Item(resourceName).GetByTestId("label-page-item-value");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl, string labelKey) {
+        await page.GotoAsync($"{baseUrl}/labels/{Uri.EscapeDataString(labelKey)}");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task AssertTitleContainsAsync(string labelKey)
+        => await Assertions.Expect(Title).ToContainTextAsync(labelKey);
+
+    public async Task AssertListsAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToBeVisibleAsync();
+
+    public async Task AssertDoesNotListAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToHaveCountAsync(0);
+
+    /// <summary>
+    ///     Asserts the resource's value for this label, plus the type the page
+    ///     detected for it (TEXT / NUMBER / BOOL / DATE).
+    /// </summary>
+    public async Task AssertValueAsync(string resourceName, string value, string detectedType) {
+        await Assertions.Expect(ItemValue(resourceName)).ToContainTextAsync(value);
+        await Assertions.Expect(ItemValue(resourceName)).ToContainTextAsync(detectedType);
+    }
+
+    /// <summary>
+    ///     The filter is a plain @bind, committing on change rather than input,
+    ///     so the value needs an explicit blur — see DocsPom.SearchAsync.
+    /// </summary>
+    public async Task FilterAsync(string value) {
+        await Filter.FillAsync(value);
+        await Filter.BlurAsync();
+    }
+
+    public async Task SortByAsync(string option)
+        => await SortSelect.SelectOptionAsync(option);
+
+    public async Task ToggleSortDirectionAsync()
+        => await SortDirectionButton.ClickAsync();
+
+    public async Task AssertNoResultsAsync()
+        => await Assertions.Expect(NoResults).ToBeVisibleAsync();
+
+    /// <summary>
+    ///     Names of the listed resources, in render order.
+    /// </summary>
+    public async Task<IReadOnlyList<string>> ListedOrderAsync() {
+        var ids = await List.Locator("[data-testid^='label-page-item-']")
+            .EvaluateAllAsync<string[]>(
+                "els => els.map(e => e.getAttribute('data-testid'))");
+
+        return ids
+            .Where(id => id.StartsWith("label-page-item-", StringComparison.Ordinal))
+            .Select(id => id["label-page-item-".Length..])
+            .Where(name => name != "value")
+            .ToList();
+    }
+}

+ 142 - 0
Tests.E2e/PageObjectModels/OtherCardPom.cs

@@ -0,0 +1,142 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class OtherCardPom(IPage page) {
+    public TagsPom Tags => new(page);
+    public LabelsPom Labels => new(page);
+
+    // -------------------------------------------------
+    // Notes
+    // -------------------------------------------------
+
+    public ILocator NotesViewer
+        => page.GetByTestId("other-notes-viewer-container");
+
+    public ILocator NotesEditor
+        => page.GetByTestId("other-notes-editor-container");
+
+    // -------------------------------------------------
+    // Confirm Modal (TestIdPrefix="Other")
+    // -------------------------------------------------
+
+    public ILocator ConfirmDeleteButton
+        => page.GetByTestId("Other-confirm-modal-confirm");
+
+    // -------------------------------------------------
+    // Rename Modal (TestIdPrefix="other-rename")
+    // -------------------------------------------------
+
+    public ILocator RenameInput
+        => page.GetByTestId("other-rename-string-value-modal-input");
+
+    public ILocator RenameSubmit
+        => page.GetByTestId("other-rename-string-value-modal-submit");
+
+    // -------------------------------------------------
+    // Clone Modal (TestIdPrefix="other-clone")
+    // -------------------------------------------------
+
+    public ILocator CloneInput
+        => page.GetByTestId("other-clone-string-value-modal-input");
+
+    public ILocator CloneSubmit
+        => page.GetByTestId("other-clone-string-value-modal-submit");
+
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    private static string Sanitize(string value)
+        => value.Replace(" ", "-");
+
+    public ILocator Card(string name)
+        => page.GetByTestId($"other-item-{Sanitize(name)}");
+
+    public ILocator Link(string name)
+        => page.GetByTestId($"other-item-{Sanitize(name)}-link");
+
+    // -------------------------------------------------
+    // Action Buttons
+    // -------------------------------------------------
+
+    public ILocator EditButton(string name)
+        => Card(name).GetByTestId("edit-other-button");
+
+    public ILocator SaveButton(string name)
+        => Card(name).GetByTestId("save-other-button");
+
+    public ILocator CancelButton(string name)
+        => Card(name).GetByTestId("cancel-other-button");
+
+    public ILocator RenameButton(string name)
+        => Card(name).GetByTestId("rename-other-button");
+
+    public ILocator CloneButton(string name)
+        => Card(name).GetByTestId("clone-other-button");
+
+    public ILocator DeleteButton(string name)
+        => Card(name).GetByTestId("delete-other-button");
+
+    // -------------------------------------------------
+    // Edit Inputs
+    // -------------------------------------------------
+
+    public ILocator ModelInput(string name)
+        => Card(name).GetByTestId("other-model-input");
+
+    public ILocator DescriptionInput(string name)
+        => Card(name).GetByTestId("other-description-input");
+
+    // -------------------------------------------------
+    // View Values
+    // -------------------------------------------------
+
+    public ILocator ModelValue(string name)
+        => Card(name).GetByTestId("other-model-value");
+
+    public ILocator DescriptionValue(string name)
+        => Card(name).GetByTestId("other-description-value");
+
+    // -------------------------------------------------
+    // 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();
+    }
+}

+ 112 - 0
Tests.E2e/PageObjectModels/OtherListPom.cs

@@ -0,0 +1,112 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class OtherListPom(IPage page) {
+    public AddResourceComponent AddOther => new(page, "other");
+
+    public ILocator PageRoot => page.GetByTestId("other-page-root");
+    public ILocator PageTitle => page.GetByTestId("other-page-title");
+
+    public ILocator Loading => page.GetByTestId("other-loading");
+    public ILocator EmptyState => page.GetByTestId("other-empty");
+    public ILocator OtherList => page.GetByTestId("other-list");
+
+    public ILocator AddSection => page.GetByTestId("other-add-section");
+
+    // Must match AddResourceComponent test IDs
+    public ILocator AddInput => page.GetByTestId("add-other-input");
+    public ILocator AddButton => page.GetByTestId("add-other-button");
+
+    // -------------------------------------------------
+    // Dynamic Other Items
+    // -------------------------------------------------
+
+    public ILocator OtherItem(string name) => page.GetByTestId($"other-item-{Sanitize(name)}");
+
+    public ILocator OpenLink(string name) {
+        return OtherItem(name)
+            .GetByTestId($"other-item-{Sanitize(name)}-link");
+    }
+
+    public ILocator EditButton(string name) {
+        return OtherItem(name)
+            .GetByTestId("edit-other-button");
+    }
+
+    public ILocator SaveButton(string name) {
+        return OtherItem(name)
+            .GetByTestId("save-other-button");
+    }
+
+    public ILocator CancelButton(string name) {
+        return OtherItem(name)
+            .GetByTestId("cancel-other-button");
+    }
+
+    public ILocator RenameButton(string name) {
+        return OtherItem(name)
+            .GetByTestId("rename-other-button");
+    }
+
+    public ILocator CloneButton(string name) {
+        return OtherItem(name)
+            .GetByTestId("clone-other-button");
+    }
+
+    public ILocator DeleteButton(string name) {
+        return OtherItem(name)
+            .GetByTestId("delete-other-button");
+    }
+
+    // -------------------------------------------------
+    // Navigation
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl) {
+        await page.GotoAsync($"{baseUrl}/other/list");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync() {
+        await Assertions.Expect(PageRoot).ToBeVisibleAsync();
+        await Assertions.Expect(PageTitle).ToBeVisibleAsync();
+    }
+
+    public async Task WaitForListAsync() => await Assertions.Expect(OtherList).ToBeVisibleAsync();
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public async Task AddOtherAsync(string name) {
+        await AddOther.AddAsync(name);
+        await Assertions.Expect(OtherItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task DeleteOtherAsync(string name) {
+        await DeleteButton(name).ClickAsync();
+        await page.GetByTestId("Other-confirm-modal-confirm").ClickAsync();
+
+        await Assertions.Expect(OtherItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    public async Task OpenOtherAsync(string name) {
+        await OpenLink(name).ClickAsync();
+        await page.WaitForURLAsync($"**/resources/hardware/{name}");
+    }
+
+    public async Task AssertOtherExists(string name) {
+        await Assertions.Expect(OtherItem(name))
+            .ToBeVisibleAsync();
+    }
+
+    public async Task AssertOtherDoesNotExist(string name) {
+        await Assertions.Expect(OtherItem(name))
+            .Not.ToBeVisibleAsync();
+    }
+
+    private static string Sanitize(string value) => value.Replace(" ", "-");
+}

+ 19 - 33
Tests.E2e/PageObjectModels/PortsPom.cs

@@ -50,32 +50,39 @@ public class PortsPom(IPage page) {
     // Connection Modal
     // -------------------------------------------------
 
+    /// <summary>
+    ///     A resource card mounts PortConnectionModal under its ports prefix,
+    ///     so the modal's own test ids are built from "{prefix}-port-group".
+    /// </summary>
+    public ConnectionModalPom ConnectionModalFor(string testIdPrefix)
+        => new(page, $"{testIdPrefix}-port-group");
+
     public ILocator ConnectionModal(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-container");
+        => ConnectionModalFor(testIdPrefix).Container;
 
     public ILocator ResourceASelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-resource-a");
+        => ConnectionModalFor(testIdPrefix).ResourceASelect;
 
     public ILocator GroupASelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-group-a");
+        => ConnectionModalFor(testIdPrefix).GroupASelect;
 
     public ILocator PortASelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-port-a");
+        => ConnectionModalFor(testIdPrefix).PortASelect;
 
     public ILocator ResourceBSelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-resource-b");
+        => ConnectionModalFor(testIdPrefix).ResourceBSelect;
 
     public ILocator GroupBSelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-group-b");
+        => ConnectionModalFor(testIdPrefix).GroupBSelect;
 
     public ILocator PortBSelect(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-port-b");
+        => ConnectionModalFor(testIdPrefix).PortBSelect;
 
     public ILocator SubmitConnection(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-submit");
+        => ConnectionModalFor(testIdPrefix).SubmitButton;
 
     public ILocator LabelInput(string testIdPrefix)
-        => page.GetByTestId($"{testIdPrefix}-port-group-connection-modal-label");
+        => ConnectionModalFor(testIdPrefix).LabelInput;
 
     // -------------------------------------------------
     // Assertions
@@ -109,30 +116,9 @@ public class PortsPom(IPage page) {
         string resourceB,
         string groupB,
         string portB,
-        string? label = null) {
-        await ResourceASelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = resourceA });
-
-        await GroupASelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = groupA });
-
-        await PortASelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = portA });
-
-        await ResourceBSelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = resourceB });
-
-        await GroupBSelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = groupB });
-
-        await PortBSelect(prefix).SelectOptionAsync(
-            new SelectOptionValue { Label = portB });
-
-        if (label is not null)
-            await LabelInput(prefix).FillAsync(label);
-
-        await SubmitConnection(prefix).ClickAsync();
-    }
+        string? label = null)
+        => await ConnectionModalFor(prefix).CreateConnectionAsync(
+            resourceA, groupA, portA, resourceB, groupB, portB, label);
 
     // -------------------------------------------------
     // Port Modal Fields

+ 76 - 0
Tests.E2e/PageObjectModels/SshExportPom.cs

@@ -0,0 +1,76 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SshExportPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Page
+        => page.GetByTestId("ssh-export-page");
+
+    // -------------------------------------------------
+    // Actions
+    // -------------------------------------------------
+
+    public ILocator GenerateButton
+        => page.GetByTestId("generate-ssh-button");
+
+    // -------------------------------------------------
+    // Inputs
+    // -------------------------------------------------
+
+    public ILocator IncludeTagsInput
+        => page.GetByTestId("ssh-include-tags-input");
+
+    public ILocator DefaultUserInput
+        => page.GetByTestId("ssh-default-user-input");
+
+    public ILocator DefaultPortInput
+        => page.GetByTestId("ssh-default-port-input");
+
+    public ILocator DefaultIdentityInput
+        => page.GetByTestId("ssh-default-identity-input");
+
+    // -------------------------------------------------
+    // Output
+    // -------------------------------------------------
+
+    public ILocator Output
+        => page.GetByTestId("ssh-output");
+
+    public ILocator WarningsContainer
+        => page.GetByTestId("ssh-warnings");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertVisibleAsync()
+        => await Assertions.Expect(Page).ToBeVisibleAsync();
+
+    public async Task SetIncludeTagsAsync(string value)
+        => await IncludeTagsInput.FillAsync(value);
+
+    public async Task SetDefaultUserAsync(string value)
+        => await DefaultUserInput.FillAsync(value);
+
+    public async Task SetDefaultPortAsync(string value)
+        => await DefaultPortInput.FillAsync(value);
+
+    public async Task SetDefaultIdentityAsync(string value)
+        => await DefaultIdentityInput.FillAsync(value);
+
+    public async Task GenerateAsync()
+        => await GenerateButton.ClickAsync();
+
+    public async Task<string> GetOutputTextAsync()
+        => await Output.InputValueAsync();
+
+    public async Task AssertOutputContainsAsync(string text)
+        => await Assertions.Expect(Output).ToContainTextAsync(text);
+
+    public async Task AssertNoWarningsAsync()
+        => await Assertions.Expect(WarningsContainer).ToHaveCountAsync(0);
+}

+ 62 - 0
Tests.E2e/PageObjectModels/SubnetBrowserPom.cs

@@ -0,0 +1,62 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class SubnetBrowserPom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Root
+        => page.GetByTestId("subnet-browser-root");
+
+    public ILocator Title
+        => page.GetByTestId("subnet-browser-title");
+
+    public ILocator Filter
+        => page.GetByTestId("subnet-browser-filter");
+
+    // -------------------------------------------------
+    // States
+    // -------------------------------------------------
+
+    public ILocator Loading
+        => page.GetByTestId("subnet-browser-loading");
+
+    public ILocator Empty
+        => page.GetByTestId("subnet-browser-empty");
+
+    public ILocator List
+        => page.GetByTestId("subnet-browser-list");
+
+    /// <summary>
+    ///     A subnet group, addressed by its /24 key. The page groups on the
+    ///     first three octets, so 10.42.7.21 lands in the "10.42.7.x" group.
+    /// </summary>
+    public ILocator Group(string subnetKey)
+        => page.GetByTestId($"subnet-group-{subnetKey.Replace('.', '-')}");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertLoadedAsync() {
+        await Assertions.Expect(Root).ToBeVisibleAsync();
+        await Assertions.Expect(Title).ToBeVisibleAsync();
+    }
+
+    /// <summary>
+    ///     The filter binds on oninput, so no explicit blur is needed here.
+    /// </summary>
+    public async Task FilterAsync(string value)
+        => await Filter.FillAsync(value);
+
+    public async Task AssertGroupVisibleAsync(string subnetKey)
+        => await Assertions.Expect(Group(subnetKey)).ToBeVisibleAsync();
+
+    public async Task AssertGroupContainsAsync(string subnetKey, string text)
+        => await Assertions.Expect(Group(subnetKey)).ToContainTextAsync(text);
+
+    public async Task AssertEmptyAsync()
+        => await Assertions.Expect(Empty).ToBeVisibleAsync();
+}

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

@@ -81,6 +81,9 @@ public class SystemCardPom(IPage page) {
     public ILocator TypeSelect(string name)
         => Card(name).GetByTestId("system-type-select");
 
+    public ILocator TypeValue(string name)
+        => Card(name).GetByTestId("system-type-value");
+
     public ILocator OsInput(string name)
         => Card(name).GetByTestId("system-os-input");
 
@@ -90,6 +93,16 @@ public class SystemCardPom(IPage page) {
     public ILocator RamInput(string name)
         => Card(name).GetByTestId("system-ram-input");
 
+    public ILocator IpInput(string name)
+        => Card(name).GetByTestId("system-ip-input");
+
+    /// <summary>
+    ///     Read-mode IP. Renders the effective IP, which may be inherited from
+    ///     the host rather than set on the system itself.
+    /// </summary>
+    public ILocator IpValue(string name)
+        => Card(name).GetByTestId("system-ip-value");
+
     public ILocator RunsOnButton(string name)
         => Card(name).GetByTestId("system-runs-on-button");
 

+ 57 - 0
Tests.E2e/PageObjectModels/TagPagePom.cs

@@ -0,0 +1,57 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+/// <summary>
+///     The /tags/{TagName} detail page — everything carrying one tag.
+///     Distinct from <see cref="TagsPom" />, which drives the tag editor on a
+///     resource card.
+/// </summary>
+public class TagPagePom(IPage page) {
+    public ILocator Root
+        => page.GetByTestId("tag-page-root");
+
+    public ILocator Title
+        => page.GetByTestId("tag-page-title");
+
+    public ILocator List
+        => page.GetByTestId("tag-page-list");
+
+    public ILocator Empty
+        => page.GetByTestId("tag-page-empty");
+
+    public ILocator Item(string resourceName)
+        => page.GetByTestId($"tag-page-item-{resourceName.Replace(" ", "-")}");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl, string tag) {
+        await page.GotoAsync($"{baseUrl}/tags/{Uri.EscapeDataString(tag)}");
+        await AssertLoadedAsync();
+    }
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task AssertTitleContainsAsync(string tag)
+        => await Assertions.Expect(Title).ToContainTextAsync(tag);
+
+    public async Task AssertListsAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToBeVisibleAsync();
+
+    public async Task AssertDoesNotListAsync(string resourceName)
+        => await Assertions.Expect(Item(resourceName)).ToHaveCountAsync(0);
+
+    public async Task AssertEmptyAsync()
+        => await Assertions.Expect(Empty).ToBeVisibleAsync();
+
+    /// <summary>
+    ///     Follows the link on a listed resource through to its card.
+    /// </summary>
+    public async Task OpenResourceAsync(string resourceName) {
+        await Item(resourceName).GetByRole(AriaRole.Link).First.ClickAsync();
+        await page.WaitForURLAsync($"**/resources/**/{resourceName}");
+    }
+}

+ 54 - 0
Tests.E2e/PageObjectModels/VisualisePom.cs

@@ -0,0 +1,54 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class VisualisePom(IPage page) {
+    // -------------------------------------------------
+    // Root
+    // -------------------------------------------------
+
+    public ILocator Root
+        => page.GetByTestId("visualise-page-root");
+
+    // -------------------------------------------------
+    // Tabs
+    // -------------------------------------------------
+
+    public ILocator TopologyTab
+        => page.GetByTestId("visualise-tab-topology");
+
+    public ILocator LogicalTab
+        => page.GetByTestId("visualise-tab-logical");
+
+    // -------------------------------------------------
+    // Exports
+    // -------------------------------------------------
+
+    public ILocator ExportPngButton
+        => page.GetByTestId("visualise-export-png");
+
+    public ILocator ExportSourceButton
+        => page.GetByTestId("visualise-export-source");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task AssertLoadedAsync()
+        => await Assertions.Expect(Root).ToBeVisibleAsync();
+
+    public async Task SelectTopologyAsync() {
+        await TopologyTab.ClickAsync();
+        await page.WaitForURLAsync("**/visualise/topology");
+    }
+
+    public async Task SelectLogicalAsync() {
+        await LogicalTab.ClickAsync();
+        await page.WaitForURLAsync("**/visualise/logical");
+    }
+
+    public async Task AssertExportsEnabledAsync() {
+        await Assertions.Expect(ExportPngButton).ToBeEnabledAsync();
+        await Assertions.Expect(ExportSourceButton).ToBeEnabledAsync();
+    }
+}

+ 72 - 0
Tests.E2e/PageObjectModels/YamlImportPom.cs

@@ -0,0 +1,72 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class YamlImportPom(IPage page) {
+    // -------------------------------------------------
+    // Input
+    // -------------------------------------------------
+
+    /// <summary>
+    ///     The YAML textarea carries no test id, so it is addressed by its
+    ///     placeholder. It binds on oninput and recomputes the diff after each
+    ///     change, so filling it is enough to render the preview.
+    /// </summary>
+    public ILocator Input
+        => page.GetByPlaceholder("Paste YAML here...");
+
+    public ILocator ApplyButton
+        => page.GetByRole(AriaRole.Button, new PageGetByRoleOptions { Name = "Apply" });
+
+    // -------------------------------------------------
+    // Errors
+    // -------------------------------------------------
+
+    public ILocator Error
+        => page.GetByTestId("yaml-import-error");
+
+    public ILocator ErrorSnippet
+        => page.GetByTestId("yaml-import-error-snippet");
+
+    // -------------------------------------------------
+    // Connections preview (issue #308)
+    // -------------------------------------------------
+
+    public ILocator ConnectionsSummary
+        => page.GetByTestId("import-connections-summary");
+
+    public ILocator ConnectionsAdded
+        => page.GetByTestId("import-connection-added");
+
+    public ILocator ConnectionsRemoved
+        => page.GetByTestId("import-connection-removed");
+
+    // -------------------------------------------------
+    // High-Level Actions
+    // -------------------------------------------------
+
+    public async Task GotoAsync(string baseUrl) {
+        await page.GotoAsync($"{baseUrl}/yaml/import");
+        await Assertions.Expect(Input).ToBeVisibleAsync();
+    }
+
+    public async Task PasteAsync(string yaml)
+        => await Input.FillAsync(yaml);
+
+    public async Task ApplyAsync() {
+        await Assertions.Expect(ApplyButton).ToBeEnabledAsync();
+        await ApplyButton.ClickAsync();
+    }
+
+    public async Task AssertNoErrorAsync()
+        => await Assertions.Expect(Error).ToHaveCountAsync(0);
+
+    public async Task AssertErrorShownAsync()
+        => await Assertions.Expect(Error).ToBeVisibleAsync();
+
+    public async Task AssertConnectionsSummaryVisibleAsync()
+        => await Assertions.Expect(ConnectionsSummary).ToBeVisibleAsync();
+
+    public async Task AssertConnectionAddedContainsAsync(string text)
+        => await Assertions.Expect(ConnectionsAdded.First).ToContainTextAsync(text);
+}

+ 165 - 0
Tests.E2e/SubnetAndVisualiseTests.cs

@@ -0,0 +1,165 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for the two read-only overview pages, /subnets and /visualise.
+///     Both derive entirely from the resources in the config, so the tests
+///     create what they assert on. Kept in one class so they share a container.
+/// </summary>
+public class SubnetAndVisualiseTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    // =============================================================
+    // Subnet browser
+    // =============================================================
+
+    [Fact]
+    public async Task Subnet_Browser_Groups_A_System_Under_Its_Slash_24() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-sn-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.99.4.17";
+        const string subnet = "10.99.4.x";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/subnets");
+
+            var subnets = new SubnetBrowserPom(page);
+            await subnets.AssertLoadedAsync();
+
+            await subnets.AssertGroupVisibleAsync(subnet);
+            await subnets.AssertGroupContainsAsync(subnet, ip);
+            await subnets.AssertGroupContainsAsync(subnet, name);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task Subnet_Browser_Filter_Narrows_And_Can_Match_Nothing() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-sf-{Guid.NewGuid():N}"[..14];
+        const string ip = "10.88.3.9";
+        const string subnet = "10.88.3.x";
+
+        try {
+            await CreateSystemWithIpAsync(page, name, ip);
+
+            await page.GotoAsync($"{_fixture.BaseUrl}/subnets");
+
+            var subnets = new SubnetBrowserPom(page);
+            await subnets.AssertLoadedAsync();
+            await subnets.AssertGroupVisibleAsync(subnet);
+
+            // A matching prefix keeps the group.
+            await subnets.FilterAsync("10.88.3");
+            await subnets.AssertGroupVisibleAsync(subnet);
+
+            // A subnet nothing lives in falls through to the empty state.
+            await subnets.FilterAsync("203.0.113");
+            await subnets.AssertEmptyAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Visualise
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Switch_Between_Visualise_Views() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/visualise");
+
+            var visualise = new VisualisePom(page);
+            await visualise.AssertLoadedAsync();
+
+            await visualise.SelectLogicalAsync();
+            await visualise.AssertLoadedAsync();
+
+            await visualise.SelectTopologyAsync();
+            await visualise.AssertLoadedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task Visualise_Deep_Links_Straight_To_A_View() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            await page.GotoAsync($"{_fixture.BaseUrl}/visualise/logical");
+
+            var visualise = new VisualisePom(page);
+            await visualise.AssertLoadedAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task CreateSystemWithIpAsync(IPage page, string name, string ip) {
+        await page.GotoAsync($"{_fixture.BaseUrl}/systems/list");
+
+        var list = new SystemsListPom(page);
+        await list.AssertLoadedAsync();
+        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.IpInput(name).FillAsync(ip);
+        await card.SaveAsync(name);
+
+        await Assertions.Expect(card.IpValue(name)).ToContainTextAsync(ip);
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}

+ 34 - 0
Tests.E2e/SystemCardTests.cs

@@ -119,6 +119,40 @@ public class SystemCardTests(
         }
     }
 
+    [Fact]
+    public async Task Saving_Without_Touching_Type_Persists_The_Displayed_Default() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+        var name = $"e2e-sys-type-{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);
+
+            // The dropdown shows 'baremetal' for a fresh system; save without
+            // touching it — the displayed default must actually persist (#306).
+            await card.BeginEditAsync(name);
+            await card.SaveAsync(name);
+
+            await Assertions.Expect(card.TypeValue(name)).ToHaveTextAsync("baremetal");
+
+            await page.ReloadAsync();
+            await card.AssertVisibleAsync(name);
+            await Assertions.Expect(card.TypeValue(name)).ToHaveTextAsync("baremetal");
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
     // ============================================================
     // Cancel Edit
     // ============================================================

+ 282 - 0
Tests.E2e/TagAndLabelPageTests.cs

@@ -0,0 +1,282 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for /tags/{TagName} and /labels/{LabelName} — the pages that
+///     aggregate resources across kinds. The tag and label editors on the cards
+///     were already covered; where those links lead was not.
+///     Both pages read the whole config and tests share a container, so every
+///     test uses a unique tag or label key to stay deterministic.
+/// </summary>
+public class TagAndLabelPageTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    private static string Unique(string prefix) => $"{prefix}{Guid.NewGuid():N}"[..14];
+
+    // =============================================================
+    // Tag page — aggregation across kinds
+    // =============================================================
+
+    [Fact]
+    public async Task Tag_Page_Lists_Resources_Of_Every_Kind_Carrying_The_Tag() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var tag = Unique("e2etag");
+        var serverName = Unique("e2e-tsv-");
+        var systemName = Unique("e2e-tsy-");
+
+        try {
+            await CreateServerWithTagAsync(page, serverName, tag);
+            await CreateSystemWithTagAsync(page, systemName, tag);
+
+            var tagPage = new TagPagePom(page);
+            await tagPage.GotoAsync(_fixture.BaseUrl, tag);
+
+            await tagPage.AssertTitleContainsAsync(tag);
+
+            // A hardware resource and a system, aggregated onto one page.
+            await tagPage.AssertListsAsync(serverName);
+            await tagPage.AssertListsAsync(systemName);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Tag page — click-through from a card, and back out again
+    // =============================================================
+
+    [Fact]
+    public async Task User_Can_Click_A_Card_Tag_Through_To_The_Tag_Page_And_Back() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var tag = Unique("e2enav");
+        var serverName = Unique("e2e-tnv-");
+
+        try {
+            await CreateServerWithTagAsync(page, serverName, tag);
+
+            // Follow the tag chip on the card.
+            var card = new ServerCardPom(page);
+            await card.Tags.NavigateToTagAsync("server", tag);
+
+            var tagPage = new TagPagePom(page);
+            await tagPage.AssertLoadedAsync();
+            await tagPage.AssertListsAsync(serverName);
+
+            // And back out to the resource it points at.
+            await tagPage.OpenResourceAsync(serverName);
+            await card.AssertVisibleAsync(serverName);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task Tag_Page_Reports_An_Unused_Tag_As_Empty() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            var tagPage = new TagPagePom(page);
+            await tagPage.GotoAsync(_fixture.BaseUrl, Unique("e2enone"));
+
+            await tagPage.AssertEmptyAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Label page — values and detected types
+    // =============================================================
+
+    [Fact]
+    public async Task Label_Page_Shows_Each_Resource_Value_And_Its_Detected_Type() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var labelKey = Unique("e2elbl");
+        var textServer = Unique("e2e-ltx-");
+        var numberServer = Unique("e2e-lnm-");
+
+        try {
+            await CreateServerWithLabelAsync(page, textServer, labelKey, "production");
+            await CreateServerWithLabelAsync(page, numberServer, labelKey, "42");
+
+            var labelPage = new LabelPagePom(page);
+            await labelPage.GotoAsync(_fixture.BaseUrl, labelKey);
+
+            await labelPage.AssertTitleContainsAsync(labelKey);
+
+            // The page classifies each value, not just displays it.
+            await labelPage.AssertValueAsync(textServer, "production", "TEXT");
+            await labelPage.AssertValueAsync(numberServer, "42", "NUMBER");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Label page — filtering
+    // =============================================================
+
+    [Fact]
+    public async Task Label_Page_Filter_Narrows_The_List_And_Can_Match_Nothing() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var labelKey = Unique("e2efil");
+        var keptServer = Unique("e2e-lkp-");
+        var filteredServer = Unique("e2e-lfl-");
+
+        try {
+            await CreateServerWithLabelAsync(page, keptServer, labelKey, "alpha");
+            await CreateServerWithLabelAsync(page, filteredServer, labelKey, "beta");
+
+            var labelPage = new LabelPagePom(page);
+            await labelPage.GotoAsync(_fixture.BaseUrl, labelKey);
+
+            await labelPage.AssertListsAsync(keptServer);
+            await labelPage.AssertListsAsync(filteredServer);
+
+            // The filter matches on the label value as well as the name.
+            await labelPage.FilterAsync("alpha");
+            await labelPage.AssertListsAsync(keptServer);
+            await labelPage.AssertDoesNotListAsync(filteredServer);
+
+            // Nothing matching falls through to its own state, not an empty list.
+            await labelPage.FilterAsync(Unique("nomatch"));
+            await labelPage.AssertNoResultsAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Label page — sort direction
+    // =============================================================
+
+    [Fact]
+    public async Task Label_Page_Sort_Direction_Reverses_The_List() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var labelKey = Unique("e2esrt");
+        var lowServer = Unique("e2e-slo-");
+        var highServer = Unique("e2e-shi-");
+
+        try {
+            // Numeric values so the default "Value" sort is unambiguous.
+            await CreateServerWithLabelAsync(page, lowServer, labelKey, "1");
+            await CreateServerWithLabelAsync(page, highServer, labelKey, "2");
+
+            var labelPage = new LabelPagePom(page);
+            await labelPage.GotoAsync(_fixture.BaseUrl, labelKey);
+
+            IReadOnlyList<string> ascending = await labelPage.ListedOrderAsync();
+            Assert.Equal(new[] { lowServer, highServer }, ascending);
+
+            await labelPage.ToggleSortDirectionAsync();
+
+            IReadOnlyList<string> descending = await labelPage.ListedOrderAsync();
+            Assert.Equal(new[] { highServer, lowServer }, descending);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Helpers
+    // =============================================================
+
+    private async Task CreateServerAsync(IPage page, string name) {
+        await page.GotoAsync($"{_fixture.BaseUrl}/servers/list");
+
+        var list = new ServersListPom(page);
+        await list.AssertLoadedAsync();
+        await list.AddServerAsync(name);
+
+        if (!page.Url.Contains($"/resources/hardware/{name}", StringComparison.OrdinalIgnoreCase))
+            await list.OpenServerAsync(name);
+
+        var card = new ServerCardPom(page);
+        await card.AssertVisibleAsync(name);
+    }
+
+    private async Task CreateServerWithTagAsync(IPage page, string name, string tag) {
+        await CreateServerAsync(page, name);
+
+        var card = new ServerCardPom(page);
+        await card.Tags.AddTagsAsync("server", tag);
+        await card.Tags.AssertTagVisibleAsync("server", tag);
+    }
+
+    private async Task CreateServerWithLabelAsync(IPage page, string name, string key, string value) {
+        await CreateServerAsync(page, name);
+
+        var card = new ServerCardPom(page);
+        await card.Labels.AddLabelAsync("server", key, value);
+        await card.Labels.AssertLabelVisibleAsync("server", key);
+    }
+
+    private async Task CreateSystemWithTagAsync(IPage page, string name, string tag) {
+        await page.GotoAsync($"{_fixture.BaseUrl}/systems/list");
+
+        var list = new SystemsListPom(page);
+        await list.AssertLoadedAsync();
+        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.Tags.AddTagsAsync("system", tag);
+        await card.Tags.AssertTagVisibleAsync("system", tag);
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}

+ 6 - 6
Tests.E2e/Tests.E2e.csproj

@@ -12,13 +12,13 @@
             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
             <PrivateAssets>all</PrivateAssets>
         </PackageReference>
-        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
-        <PackageReference Include="Microsoft.Playwright" Version="1.60.0" />
-        <PackageReference Include="Microsoft.Playwright.Xunit" Version="1.60.0" />
-        <PackageReference Include="Testcontainers" Version="4.12.0" />
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
+        <PackageReference Include="Microsoft.Playwright" Version="1.62.0" />
+        <PackageReference Include="Microsoft.Playwright.Xunit" Version="1.62.0" />
+        <PackageReference Include="Testcontainers" Version="4.15.0" />
         <PackageReference Include="xunit" Version="2.9.3"/>
-        <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
+        <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0">
             <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
             <PrivateAssets>all</PrivateAssets>
         </PackageReference>

+ 164 - 0
Tests.E2e/YamlImportTests.cs

@@ -0,0 +1,164 @@
+using Microsoft.Playwright;
+using Tests.E2e.Infra;
+using Tests.E2e.PageObjectModels;
+using Xunit.Abstractions;
+
+namespace Tests.E2e;
+
+/// <summary>
+///     Coverage for /yaml/import and the /yaml config view.
+///     Issue #308: connections in an imported document were parsed but silently
+///     dropped — they never showed in the preview and never reached the config.
+///     The preview assertions here are the regression guard; the /yaml
+///     round-trip proves the connection actually persisted.
+/// </summary>
+public class YamlImportTests(
+    PlaywrightFixture fixture,
+    ITestOutputHelper output) : E2ETestBase(fixture, output) {
+    private readonly PlaywrightFixture _fixture = fixture;
+    private readonly ITestOutputHelper _output = output;
+
+    private static string TwoSwitchesWithConnection(string switchA, string switchB) =>
+        $"""
+         version: 3
+         resources:
+           - kind: Switch
+             name: {switchA}
+             ports:
+               - type: rj45
+                 speed: 1
+                 count: 4
+           - kind: Switch
+             name: {switchB}
+             ports:
+               - type: rj45
+                 speed: 1
+                 count: 4
+         connections:
+           - a:
+               resource: {switchA}
+               portGroup: 0
+               portIndex: 0
+             b:
+               resource: {switchB}
+               portGroup: 0
+               portIndex: 0
+         """;
+
+    // =============================================================
+    // Connections reach the preview (issue #308 regression)
+    // =============================================================
+
+    [Fact]
+    public async Task Import_Preview_Lists_Connections_From_The_Document() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-ia-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-ib-{Guid.NewGuid():N}"[..14];
+
+        try {
+            var import = new YamlImportPom(page);
+            await import.GotoAsync(_fixture.BaseUrl);
+
+            await import.PasteAsync(TwoSwitchesWithConnection(switchA, switchB));
+
+            await import.AssertNoErrorAsync();
+
+            // Before the #308 fix this section never rendered: the connection
+            // was dropped between parsing and the diff.
+            await import.AssertConnectionsSummaryVisibleAsync();
+            await Assertions.Expect(import.ConnectionsAdded).ToHaveCountAsync(1);
+            await import.AssertConnectionAddedContainsAsync(switchA);
+            await import.AssertConnectionAddedContainsAsync(switchB);
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Applying the import persists the connection
+    // =============================================================
+
+    [Fact]
+    public async Task Applying_An_Import_Persists_Resources_And_Their_Connection() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        var switchA = $"e2e-pa-{Guid.NewGuid():N}"[..14];
+        var switchB = $"e2e-pb-{Guid.NewGuid():N}"[..14];
+
+        try {
+            var import = new YamlImportPom(page);
+            await import.GotoAsync(_fixture.BaseUrl);
+
+            await import.PasteAsync(TwoSwitchesWithConnection(switchA, switchB));
+            await import.AssertNoErrorAsync();
+            await import.ApplyAsync();
+
+            // The saved config is the source of truth — read it back rather
+            // than trusting the preview we just asserted on.
+            await page.GotoAsync($"{_fixture.BaseUrl}/yaml");
+
+            ILocator yaml = page.GetByTestId("yaml-file-content");
+            await Assertions.Expect(yaml).ToBeVisibleAsync();
+
+            await Assertions.Expect(yaml).ToContainTextAsync(switchA);
+            await Assertions.Expect(yaml).ToContainTextAsync(switchB);
+            await Assertions.Expect(yaml).ToContainTextAsync("connections:");
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    // =============================================================
+    // Malformed input is reported, not swallowed
+    // =============================================================
+
+    [Fact]
+    public async Task Import_Surfaces_An_Error_For_Malformed_Yaml() {
+        (IBrowserContext context, IPage page) = await CreatePageAsync();
+
+        try {
+            var import = new YamlImportPom(page);
+            await import.GotoAsync(_fixture.BaseUrl);
+
+            // Unclosed bracket — a parser error rather than a schema violation.
+            await import.PasteAsync("""
+                                    version: 3
+                                    resources: [
+                                      - kind: Switch
+                                    """);
+
+            await import.AssertErrorShownAsync();
+
+            // A broken document must not be applyable.
+            await Assertions.Expect(import.ApplyButton).ToBeDisabledAsync();
+        }
+        catch (Exception) {
+            await DumpAsync(page);
+            throw;
+        }
+        finally {
+            await context.CloseAsync();
+        }
+    }
+
+    private async Task DumpAsync(IPage page) {
+        _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 ====");
+    }
+}

+ 2 - 0
Tests/Api/ApiTestBase.cs

@@ -25,6 +25,8 @@ public abstract class ApiTestBase : IDisposable {
 
         Factory = new WebApplicationFactory<Program>()
             .WithWebHostBuilder(builder => {
+                builder.UseSetting("RPK_YAML_DIR", _tempDir);
+
                 builder.ConfigureAppConfiguration((context, configBuilder) => {
                     var baseConfig = new Dictionary<string, string?> {
                         ["RPK_API_KEY"] = "test-key-123"

+ 171 - 0
Tests/Api/InventoryEndpointTests.cs

@@ -848,4 +848,175 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
         Assert.Contains("team: backend", newYaml);
         Assert.DoesNotContain("env: production", newYaml);
     }
+
+    [Fact]
+    public async Task Import_Persists_And_Updates_Connections() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      version: 3
+                      resources:
+                      - kind: Switch
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 8
+                        name: switch1
+                      - kind: Server
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 1
+                        name: server1
+                      connections:
+                      - a:
+                          resource: server1
+                          portGroup: 0
+                          portIndex: 0
+                        b:
+                          resource: switch1
+                          portGroup: 0
+                          portIndex: 0
+                      """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = initial, mode = "Merge" });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Single(result!.ConnectionsAdded);
+        Assert.Empty(result.ConnectionsRemoved);
+
+        // Re-importing the same config is idempotent
+        HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = initial, dryRun = true, mode = "Merge" });
+        ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Empty(result2!.ConnectionsAdded);
+        Assert.Empty(result2.ConnectionsRemoved);
+
+        // Adjusting the switch port replaces the old connection (#308)
+        var adjusted = initial.Replace(
+            """
+                resource: switch1
+                portGroup: 0
+                portIndex: 0
+            """,
+            """
+                resource: switch1
+                portGroup: 0
+                portIndex: 3
+            """);
+
+        HttpResponseMessage response3 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = adjusted, mode = "Merge" });
+        ImportYamlResponse? result3 = await response3.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result3!.ConnectionsAdded);
+        Assert.Contains("switch1[0.3]", result3.ConnectionsAdded[0]);
+        Assert.Single(result3.ConnectionsRemoved);
+        Assert.Contains("switch1[0.0]", result3.ConnectionsRemoved[0]);
+
+        // And the adjusted state is now stable
+        HttpResponseMessage response4 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = adjusted, dryRun = true, mode = "Merge" });
+        ImportYamlResponse? result4 = await response4.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Empty(result4!.ConnectionsAdded);
+        Assert.Empty(result4.ConnectionsRemoved);
+    }
+
+    [Fact]
+    public async Task Import_Without_Connections_Section_Keeps_Existing_Connections() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      version: 3
+                      resources:
+                      - kind: Switch
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 8
+                        name: sw-keep
+                      - kind: Server
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 1
+                        name: srv-keep
+                      connections:
+                      - a:
+                          resource: srv-keep
+                          portGroup: 0
+                          portIndex: 0
+                        b:
+                          resource: sw-keep
+                          portGroup: 0
+                          portIndex: 0
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial, mode = "Merge" });
+
+        // An import that says nothing about connections must not drop them
+        var resourcesOnly = """
+                            resources:
+                            - kind: Server
+                              name: srv-keep
+                              notes: updated
+                            """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = resourcesOnly, mode = "Merge" });
+
+        // The original connection still exists: re-importing the initial
+        // config reports no connection changes.
+        HttpResponseMessage check = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = initial, dryRun = true, mode = "Merge" });
+        ImportYamlResponse? result = await check.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Empty(result!.ConnectionsAdded);
+        Assert.Empty(result.ConnectionsRemoved);
+    }
+
+    [Fact]
+    public async Task Merge_Other_Hardware_Persists() {
+        HttpClient client = CreateClient(true);
+
+        var yaml = """
+                   resources:
+                     - kind: Other
+                       name: radio-merge
+                       model: Building Bridge XG
+                       description: Microwave radio bridge
+                   """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new {
+                Yaml = yaml,
+                mode = "Merge"
+            });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("radio-merge", result!.Added);
+
+        var update = """
+                     resources:
+                       - kind: Other
+                         name: radio-merge
+                         description: Site-to-site connectivity
+                     """;
+
+        HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = update, mode = "Merge" });
+
+        Assert.Equal(HttpStatusCode.OK, response2.StatusCode);
+
+        ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Contains("radio-merge", result2!.Updated);
+
+        var newYaml = result2.NewYaml["radio-merge"];
+        Assert.Contains("model: Building Bridge XG", newYaml);
+        Assert.Contains("description: Site-to-site connectivity", newYaml);
+    }
 }

+ 41 - 0
Tests/EndToEnd/ConnectionTests/RenameResourceTests.cs

@@ -142,6 +142,47 @@ public class RenameResourceTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.Contains("switch-uplink", yaml);
     }
 
+    [Fact]
+    public async Task rename_typed_in_different_case_preserves_connection() {
+        await ExecuteAsync("servers", "add", "Case01");
+        await ExecuteAsync("servers", "add", "Case02");
+
+        await ExecuteAsync("servers", "nic", "add", "Case01",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("servers", "nic", "add", "Case02",
+            "--type", "RJ45", "--speed", "10", "--ports", "2");
+
+        await ExecuteAsync("connections", "add",
+            "Case01", "0", "0",
+            "Case02", "0", "0",
+            "--label", "mixed-case-link");
+
+        await ExecuteAsync("servers", "rename", "case01", "Case01-renamed");
+
+        (_, var yaml) = await ExecuteAsync("servers", "get", "Case01-renamed");
+
+        Assert.Contains("name: Case01-renamed", yaml);
+        Assert.Contains("mixed-case-link", yaml);
+        // The connection endpoint should follow the rename, not keep pointing at the old name
+        Assert.Contains("resource: Case01-renamed", yaml);
+    }
+
+    [Fact]
+    public async Task rename_typed_in_different_case_updates_runs_on() {
+        await ExecuteAsync("servers", "add", "Case11");
+        await ExecuteAsync("systems", "add", "sys-case-11");
+        await ExecuteAsync("systems", "set", "sys-case-11", "--runs-on", "Case11");
+
+        await ExecuteAsync("servers", "rename", "case11", "Case12");
+
+        (_, var yaml) = await ExecuteAsync("servers", "get", "Case12");
+
+        Assert.Contains("name: Case12", yaml);
+        // The dependant system should follow the rename
+        Assert.Contains("- Case12", yaml);
+    }
+
     [Fact]
     public async Task rename_with_special_naming_preserves_connections() {
         await ExecuteAsync("servers", "add", "srv-prod-web-01");

+ 47 - 0
Tests/EndToEnd/DeleteResourceTests.cs

@@ -105,6 +105,53 @@ public class DeleteResourceTests(TempYamlCliFixture fs, ITestOutputHelper output
         Assert.DoesNotContain("srv01", yaml);
     }
 
+    [Fact]
+    public async Task deleting_resource_typed_in_different_case_removes_dependant_runs_on() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+        await ExecuteAsync("servers", "add", "Srv01");
+        await ExecuteAsync("systems", "add", "sys01");
+        await ExecuteAsync("systems", "set", "sys01", "--runs-on", "Srv01");
+
+        (var output, var yaml) = await ExecuteAsync("servers", "del", "srv01");
+
+        Assert.Contains("Server 'srv01' deleted.", output);
+        Assert.Contains("sys01", yaml);
+        // The runs-on reference should be removed even though the name was typed in a different case
+        Assert.DoesNotContain("Srv01", yaml, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task deleting_resource_typed_in_different_case_removes_connections() {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+        await ExecuteAsync("servers", "add", "Srv01");
+        await ExecuteAsync("servers", "add", "Srv02");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "Srv01",
+            "--type", "RJ45",
+            "--speed", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "servers", "nic", "add", "Srv02",
+            "--type", "RJ45",
+            "--speed", "10",
+            "--ports", "2");
+
+        await ExecuteAsync(
+            "connections", "add",
+            "Srv01", "0", "0",
+            "Srv02", "0", "0",
+            "--label", "mixed-case-connection");
+
+        (var output, var yaml) = await ExecuteAsync("servers", "del", "srv01");
+
+        Assert.Contains("Server 'srv01' deleted.", output);
+        Assert.Contains("Srv02", yaml);
+        Assert.DoesNotContain("mixed-case-connection", yaml);
+        Assert.DoesNotContain("Srv01", yaml, StringComparison.OrdinalIgnoreCase);
+    }
+
     [Fact]
     public async Task deleting_resource_with_multiple_connections_removes_all() {
         await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");

+ 69 - 0
Tests/EndToEnd/OtherTests/OtherCommandTests.cs

@@ -0,0 +1,69 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.OtherTests;
+
+[Collection("Yaml CLI tests")]
+public class OtherCommandTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string, string)> ExecuteAsync(params string[] args) {
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task describe_returns_detailed_information() {
+        // given
+        await ExecuteAsync("other", "add", "radio01");
+        await ExecuteAsync("other", "set", "radio01", "--model", "Building Bridge XG", "--description", "Microwave radio bridge");
+
+        // when
+        (var output, var _) = await ExecuteAsync("other", "describe", "radio01");
+
+        // then
+        Assert.Contains("Name:", output);
+        Assert.Contains("radio01", output);
+
+        Assert.Contains("Model:", output);
+        Assert.Contains("Building Bridge XG", output);
+
+        Assert.Contains("Description:", output);
+        Assert.Contains("Microwave radio bridge", output);
+    }
+
+    [Fact]
+    public async Task help_outputs_do_not_throw() {
+        (var rootHelp, var _) = await ExecuteAsync("other", "--help");
+        Assert.Contains("Manage other hardware", rootHelp);
+
+        (var addHelp, var _) = await ExecuteAsync("other", "add", "--help");
+        Assert.Contains("Add new other hardware", addHelp);
+
+        (var setHelp, var _) = await ExecuteAsync("other", "set", "--help");
+        Assert.Contains("Update properties", setHelp);
+
+        (var describeHelp, var _) = await ExecuteAsync("other", "describe", "--help");
+        Assert.Contains("Show detailed information", describeHelp);
+
+        (var delHelp, var _) = await ExecuteAsync("other", "del", "--help");
+        Assert.Contains("Delete other hardware", delHelp);
+        (var renameHelp, var _) = await ExecuteAsync("other", "rename", "--help");
+        Assert.Contains("Rename other hardware", renameHelp);
+    }
+
+    [Fact]
+    public async Task rename_successfully_updates_name() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var yaml) = await ExecuteAsync("other", "rename", "radio01", "radio01-new");
+
+        Assert.Equal("Other hardware 'radio01' renamed to 'radio01-new'.\n", output);
+        Assert.Contains("name: radio01-new", yaml);
+    }
+}

+ 60 - 0
Tests/EndToEnd/OtherTests/OtherErrorTests.cs

@@ -0,0 +1,60 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.OtherTests;
+
+[Collection("Yaml CLI tests")]
+public class OtherErrorTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string, string)> ExecuteAsync(params string[] args) {
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task adding_duplicate_other_returns_error() {
+        await ExecuteAsync("other", "add", "radio01");
+
+        (var output, var _) = await ExecuteAsync("other", "add", "radio01");
+
+        Assert.Contains("already exists", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task get_missing_other_returns_error() {
+        (var output, var _) = await ExecuteAsync("other", "get", "ghost");
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task set_missing_other_returns_error() {
+        (var output, var _) = await ExecuteAsync(
+            "other", "set", "ghost",
+            "--model", "X",
+            "--description", "Y"
+        );
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task delete_missing_other_returns_error() {
+        (var output, var _) = await ExecuteAsync("other", "del", "ghost");
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+
+    [Fact]
+    public async Task rename_missing_other_returns_error() {
+        (var output, var _) = await ExecuteAsync("other", "rename", "ghost", "ghost-new");
+
+        Assert.Contains("not found", output, StringComparison.OrdinalIgnoreCase);
+    }
+}

+ 104 - 0
Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs

@@ -0,0 +1,104 @@
+using Tests.EndToEnd.Infra;
+using Xunit.Abstractions;
+
+namespace Tests.EndToEnd.OtherTests;
+
+[Collection("Yaml CLI tests")]
+public class OtherWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper outputHelper)
+    : IClassFixture<TempYamlCliFixture> {
+    private async Task<(string, string)> ExecuteAsync(params string[] args) {
+        outputHelper.WriteLine($"rpk {string.Join(" ", args)}");
+
+        var output = await YamlCliTestHost.RunAsync(
+            args,
+            fs.Root,
+            outputHelper,
+            "config.yaml");
+
+        outputHelper.WriteLine(output);
+
+        var yaml = await File.ReadAllTextAsync(Path.Combine(fs.Root, "config.yaml"));
+        return (output, yaml);
+    }
+
+    [Fact]
+    public async Task other_cli_workflow_test() {
+        // Add other hardware
+        (var output, var yaml) = await ExecuteAsync("other", "add", "radio01");
+        Assert.Equal("Other hardware 'radio01' added.\n", output);
+        Assert.Contains("name: radio01", yaml);
+
+        // Update other hardware
+        (output, yaml) = await ExecuteAsync(
+            "other", "set", "radio01",
+            "--model", "Building-Bridge-XG",
+            "--description", "Microwave radio bridge"
+        );
+        Assert.Equal("Other hardware 'radio01' updated.\n", output);
+
+        Assert.Equal("""
+                     version: 3
+                     resources:
+                     - kind: Other
+                       model: Building-Bridge-XG
+                       description: Microwave radio bridge
+                       name: radio01
+                     connections: []
+
+                     """, yaml);
+
+        // Add second other hardware
+        (output, yaml) = await ExecuteAsync("other", "add", "bridge01");
+        Assert.Equal("Other hardware 'bridge01' added.\n", output);
+
+        (output, yaml) = await ExecuteAsync(
+            "other", "set", "bridge01",
+            "--model", "Interop-Bridge-2",
+            "--description", "Radio interoperability bridge"
+        );
+        Assert.Equal("Other hardware 'bridge01' updated.\n", output);
+
+        Assert.Equal("""
+                     version: 3
+                     resources:
+                     - kind: Other
+                       model: Building-Bridge-XG
+                       description: Microwave radio bridge
+                       name: radio01
+                     - kind: Other
+                       model: Interop-Bridge-2
+                       description: Radio interoperability bridge
+                       name: bridge01
+                     connections: []
+
+                     """, yaml);
+
+        // Get other hardware
+        (output, yaml) = await ExecuteAsync("other", "get", "radio01");
+        Assert.Contains("radio01", output);
+        Assert.Contains("Building-Bridge-XG", output);
+        Assert.Contains("Microwave radio bridge", output);
+
+        // List other hardware
+        (output, yaml) = await ExecuteAsync("other", "list");
+        Assert.Contains("radio01", output);
+        Assert.Contains("bridge01", output);
+
+        // Summary
+        (output, yaml) = await ExecuteAsync("other", "summary");
+        Assert.Contains("radio01", output);
+        Assert.Contains("bridge01", output);
+
+        // Delete other hardware
+        (output, yaml) = await ExecuteAsync("other", "del", "bridge01");
+        Assert.Equal("""
+                     Other hardware 'bridge01' deleted.
+
+                     """, output);
+
+        // List again
+        (output, yaml) = await ExecuteAsync("other", "list");
+        Assert.Contains("radio01", output);
+        Assert.DoesNotContain("bridge01", output);
+    }
+}

+ 8 - 0
Tests/TestConfigs/v3/12-other.yaml

@@ -0,0 +1,8 @@
+version: 3
+resources:
+  - kind: Other
+    name: unifi-radio-01
+    tags:
+      - radio
+    model: Building Bridge XG
+    description: Microwave radio bridge for site-to-site connectivity

+ 14 - 9
Tests/Tests.csproj

@@ -12,15 +12,15 @@
           <PrivateAssets>all</PrivateAssets>
           <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
         </PackageReference>
-        <PackageReference Include="JsonSchema.Net" Version="9.2.1"/>
-        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.6.0" />
-        <PackageReference Include="NSubstitute" Version="5.3.0"/>
+        <PackageReference Include="JsonSchema.Net" Version="9.4.0"/>
+        <PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.10.1" />
+        <PackageReference Include="NSubstitute" Version="6.2.0"/>
         <PackageReference Include="xunit" Version="2.9.3"/>
-        <PackageReference Include="xunit.runner.visualstudio" Version="3.1.5"/>
-        <PackageReference Include="Spectre.Console.Testing" Version="0.56.0" />
-        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.9" />
-        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.8"/>
-        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.8"/>
+        <PackageReference Include="xunit.runner.visualstudio" Version="4.0.0"/>
+        <PackageReference Include="Spectre.Console.Testing" Version="0.57.2" />
+        <PackageReference Include="Microsoft.Extensions.Logging" Version="10.0.12" />
+        <PackageReference Include="Microsoft.Extensions.Logging.Console" Version="10.0.12"/>
+        <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.12"/>
     </ItemGroup>
 
     <ItemGroup>
@@ -39,7 +39,9 @@
         <Folder Include="EndToEnd\ServiceTests\"/>
     </ItemGroup>
     <ItemGroup>
-        <None Include="schemas\**\*.json" CopyToOutputDirectory="PreserveNewest"/>
+        <!-- Validate against the published schemas directly so the test and
+             published copies can never drift apart again (#310, #311). -->
+        <None Include="..\schemas\**\*.json" Link="schemas\%(Filename)%(Extension)" CopyToOutputDirectory="PreserveNewest"/>
         <None Include="TestConfigs\**\*.yaml" CopyToOutputDirectory="PreserveNewest"/>
         <None Update="TestConfigs\v3\01-server.yaml">
             <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@@ -74,5 +76,8 @@
         <None Update="TestConfigs\v3\11-demo-config.yaml">
             <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
         </None>
+        <None Update="TestConfigs\v3\12-other.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
     </ItemGroup>
 </Project>

+ 0 - 606
Tests/schemas/schema.v1.json

@@ -1,606 +0,0 @@
-{
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
-  "title": "RackPeek Infrastructure Specification",
-  "type": "object",
-  "additionalProperties": false,
-  "required": [
-    "version",
-    "resources"
-  ],
-  "properties": {
-    "version": {
-      "type": "integer",
-      "const": 1
-    },
-    "resources": {
-      "type": "array",
-      "items": {
-        "$ref": "#/$defs/resource"
-      }
-    }
-  },
-  "$defs": {
-    "labels": {
-      "type": "object",
-      "additionalProperties": {
-        "type": "string"
-      }
-    },
-    "resourceBase": {
-      "type": "object",
-      "required": [
-        "kind",
-        "name"
-      ],
-      "properties": {
-        "kind": {
-          "type": "string"
-        },
-        "name": {
-          "type": "string",
-          "minLength": 1
-        },
-        "tags": {
-          "type": "array",
-          "items": {
-            "type": "string"
-          },
-          "default": []
-        },
-        "labels": {
-          "$ref": "#/$defs/labels",
-          "default": {}
-        },
-        "notes": {
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "runsOn": {
-          "type": [
-            "string",
-            "null"
-          ]
-        }
-      }
-    },
-    "resource": {
-      "oneOf": [
-        {
-          "$ref": "#/$defs/server"
-        },
-        {
-          "$ref": "#/$defs/firewall"
-        },
-        {
-          "$ref": "#/$defs/router"
-        },
-        {
-          "$ref": "#/$defs/switch"
-        },
-        {
-          "$ref": "#/$defs/accessPoint"
-        },
-        {
-          "$ref": "#/$defs/ups"
-        },
-        {
-          "$ref": "#/$defs/desktop"
-        },
-        {
-          "$ref": "#/$defs/laptop"
-        },
-        {
-          "$ref": "#/$defs/service"
-        },
-        {
-          "$ref": "#/$defs/system"
-        }
-      ]
-    },
-    "ram": {
-      "type": "object",
-      "required": [
-        "size"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "size": {
-          "type": "number",
-          "minimum": 0
-        },
-        "mts": {
-          "type": "integer",
-          "minimum": 0
-        }
-      }
-    },
-    "cpu": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "model": {
-          "type": "string"
-        },
-        "cores": {
-          "type": "integer",
-          "minimum": 1
-        },
-        "threads": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "drive": {
-      "type": "object",
-      "required": [
-        "size"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string",
-          "enum": [
-            "nvme",
-            "ssd",
-            "hdd",
-            "sas",
-            "sata",
-            "usb",
-            "sdcard",
-            "micro-sd"
-          ]
-        },
-        "size": {
-          "type": "number",
-          "minimum": 1
-        }
-      }
-    },
-    "gpu": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "model": {
-          "type": "string"
-        },
-        "vram": {
-          "type": "number",
-          "minimum": 0
-        }
-      }
-    },
-    "nic": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string",
-          "enum": [
-            "rj45",
-            "sfp",
-            "sfp+",
-            "sfp28",
-            "sfp56",
-            "qsfp+",
-            "qsfp28",
-            "qsfp56",
-            "qsfp-dd",
-            "osfp",
-            "xfp",
-            "cx4",
-            "mgmt"
-          ]
-        },
-        "speed": {
-          "type": "number",
-          "minimum": 0
-        },
-        "ports": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "port": {
-      "type": "object",
-      "required": [
-        "type",
-        "speed",
-        "count"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string"
-        },
-        "speed": {
-          "type": "number",
-          "minimum": 0
-        },
-        "count": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "network": {
-      "type": "object",
-      "required": [
-        "ip",
-        "port",
-        "protocol"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "ip": {
-          "type": "string",
-          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
-        },
-        "port": {
-          "type": "integer",
-          "minimum": 1,
-          "maximum": 65535
-        },
-        "protocol": {
-          "type": "string",
-          "enum": [
-            "TCP",
-            "UDP"
-          ]
-        },
-        "url": {
-          "type": "string",
-          "format": "uri"
-        }
-      }
-    },
-    "server": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Server"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "ipmi": {
-              "type": "boolean"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            },
-            "gpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/gpu"
-              }
-            },
-            "nics": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/nic"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "desktop": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Desktop"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            },
-            "gpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/gpu"
-              }
-            },
-            "nics": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/nic"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "laptop": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Laptop"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "firewall": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Firewall"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "router": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Router"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "switch": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Switch"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "accessPoint": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "AccessPoint"
-            },
-            "model": {
-              "type": "string"
-            },
-            "speed": {
-              "type": "number",
-              "minimum": 0
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "ups": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Ups"
-            },
-            "model": {
-              "type": "string"
-            },
-            "va": {
-              "type": "integer",
-              "minimum": 1
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "service": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "network"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Service"
-            },
-            "network": {
-              "$ref": "#/$defs/network"
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "system": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
-          "properties": {
-            "kind": {
-              "const": "System"
-            },
-            "type": {
-              "type": "string",
-              "enum": [
-                "baremetal",
-                "Baremetal",
-                "hypervisor",
-                "Hypervisor",
-                "vm",
-                "VM",
-                "container",
-                "embedded",
-                "cloud",
-                "other"
-              ]
-            },
-            "os": {
-              "type": "string"
-            },
-            "cores": {
-              "type": "integer",
-              "minimum": 1
-            },
-            "ram": {
-              "type": "number",
-              "minimum": 0
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    }
-  }
-}

+ 0 - 624
Tests/schemas/schema.v2.json

@@ -1,624 +0,0 @@
-{
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "https://timmoth.github.io/RackPeek/schemas/v2/schema.v2.json",
-  "title": "RackPeek Infrastructure Specification",
-  "type": "object",
-  "additionalProperties": false,
-  "required": [
-    "version",
-    "resources"
-  ],
-  "properties": {
-    "version": {
-      "type": "integer",
-      "const": 2
-    },
-    "resources": {
-      "type": "array",
-      "items": {
-        "$ref": "#/$defs/resource"
-      }
-    }
-  },
-  "$defs": {
-    "labels": {
-      "type": "object",
-      "additionalProperties": {
-        "type": "string"
-      }
-    },
-    "runsOn": {
-      "type": [
-        "array",
-        "null"
-      ],
-      "items": {
-        "type": "string",
-        "minLength": 1
-      }
-    },
-    "resourceBase": {
-      "type": "object",
-      "required": [
-        "kind",
-        "name"
-      ],
-      "properties": {
-        "kind": {
-          "type": "string"
-        },
-        "name": {
-          "type": "string",
-          "minLength": 1
-        },
-        "tags": {
-          "type": "array",
-          "items": {
-            "type": "string"
-          },
-          "default": []
-        },
-        "labels": {
-          "$ref": "#/$defs/labels",
-          "default": {}
-        },
-        "notes": {
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "runsOn": {
-          "type": [
-            "array",
-            "null"
-          ],
-          "items": {
-            "type": "string"
-          }
-        }
-      }
-    },
-    "resource": {
-      "oneOf": [
-        {
-          "$ref": "#/$defs/server"
-        },
-        {
-          "$ref": "#/$defs/firewall"
-        },
-        {
-          "$ref": "#/$defs/router"
-        },
-        {
-          "$ref": "#/$defs/switch"
-        },
-        {
-          "$ref": "#/$defs/accessPoint"
-        },
-        {
-          "$ref": "#/$defs/ups"
-        },
-        {
-          "$ref": "#/$defs/desktop"
-        },
-        {
-          "$ref": "#/$defs/laptop"
-        },
-        {
-          "$ref": "#/$defs/service"
-        },
-        {
-          "$ref": "#/$defs/system"
-        }
-      ]
-    },
-    "ram": {
-      "type": "object",
-      "required": [
-        "size"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "size": {
-          "type": "number",
-          "minimum": 0
-        },
-        "mts": {
-          "type": "integer",
-          "minimum": 0
-        }
-      }
-    },
-    "cpu": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "model": {
-          "type": "string"
-        },
-        "cores": {
-          "type": "integer",
-          "minimum": 1
-        },
-        "threads": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "drive": {
-      "type": "object",
-      "required": [
-        "size"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string",
-          "enum": [
-            "nvme",
-            "ssd",
-            "hdd",
-            "sas",
-            "sata",
-            "usb",
-            "sdcard",
-            "micro-sd"
-          ]
-        },
-        "size": {
-          "type": "number",
-          "minimum": 1
-        }
-      }
-    },
-    "gpu": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "model": {
-          "type": "string"
-        },
-        "vram": {
-          "type": "number",
-          "minimum": 0
-        }
-      }
-    },
-    "nic": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string",
-          "enum": [
-            "rj45",
-            "sfp",
-            "sfp+",
-            "sfp28",
-            "sfp56",
-            "qsfp+",
-            "qsfp28",
-            "qsfp56",
-            "qsfp-dd",
-            "osfp",
-            "xfp",
-            "cx4",
-            "mgmt"
-          ]
-        },
-        "speed": {
-          "type": "number",
-          "minimum": 0
-        },
-        "ports": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "port": {
-      "type": "object",
-      "required": [
-        "type",
-        "speed",
-        "count"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string"
-        },
-        "speed": {
-          "type": "number",
-          "minimum": 0
-        },
-        "count": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "network": {
-      "type": "object",
-      "required": [
-        "ip",
-        "port",
-        "protocol"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "ip": {
-          "type": "string",
-          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
-        },
-        "port": {
-          "type": "integer",
-          "minimum": 1,
-          "maximum": 65535
-        },
-        "protocol": {
-          "type": "string",
-          "enum": [
-            "TCP",
-            "UDP"
-          ]
-        },
-        "url": {
-          "type": "string",
-          "format": "uri"
-        }
-      }
-    },
-    "server": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Server"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "ipmi": {
-              "type": "boolean"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            },
-            "gpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/gpu"
-              }
-            },
-            "nics": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/nic"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "desktop": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Desktop"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            },
-            "gpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/gpu"
-              }
-            },
-            "nics": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/nic"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "laptop": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Laptop"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "firewall": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Firewall"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "router": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Router"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "switch": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Switch"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "accessPoint": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "AccessPoint"
-            },
-            "model": {
-              "type": "string"
-            },
-            "speed": {
-              "type": "number",
-              "minimum": 0
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "ups": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Ups"
-            },
-            "model": {
-              "type": "string"
-            },
-            "va": {
-              "type": "integer",
-              "minimum": 1
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "service": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "network"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Service"
-            },
-            "network": {
-              "$ref": "#/$defs/network"
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "system": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
-          "properties": {
-            "kind": {
-              "const": "System"
-            },
-            "type": {
-              "type": "string",
-              "enum": [
-                "baremetal",
-                "Baremetal",
-                "cluster",
-                "Cluster",
-                "hypervisor",
-                "Hypervisor",
-                "vm",
-                "VM",
-                "container",
-                "embedded",
-                "cloud",
-                "other"
-              ]
-            },
-            "ip": {
-              "type": "string"
-            },
-            "os": {
-              "type": "string"
-            },
-            "cores": {
-              "type": "integer",
-              "minimum": 1
-            },
-            "ram": {
-              "type": "number",
-              "minimum": 0
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    }
-  }
-}

+ 0 - 667
Tests/schemas/schema.v3.json

@@ -1,667 +0,0 @@
-{
-  "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "https://timmoth.github.io/RackPeek/schemas/v3/schema.v3.json",
-  "title": "RackPeek Infrastructure Specification",
-  "type": "object",
-  "additionalProperties": false,
-  "required": [
-    "version",
-    "resources"
-  ],
-  "properties": {
-    "version": {
-      "type": "integer",
-      "const": 3
-    },
-    "resources": {
-      "type": "array",
-      "items": {
-        "$ref": "#/$defs/resource"
-      }
-    },
-    "connections": {
-      "type": [
-        "array",
-        "null"
-      ],
-      "items": {
-        "$ref": "#/$defs/connection"
-      }
-    }
-  },
-  "$defs": {
-    "labels": {
-      "type": "object",
-      "additionalProperties": {
-        "type": "string"
-      }
-    },
-    "runsOn": {
-      "type": [
-        "array",
-        "null"
-      ],
-      "items": {
-        "type": "string",
-        "minLength": 1
-      }
-    },
-    "resourceBase": {
-      "type": "object",
-      "required": [
-        "kind",
-        "name"
-      ],
-      "properties": {
-        "kind": {
-          "type": "string"
-        },
-        "name": {
-          "type": "string",
-          "minLength": 1
-        },
-        "tags": {
-          "type": "array",
-          "items": {
-            "type": "string"
-          },
-          "default": []
-        },
-        "labels": {
-          "$ref": "#/$defs/labels",
-          "default": {}
-        },
-        "notes": {
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "runsOn": {
-          "$ref": "#/$defs/runsOn"
-        }
-      }
-    },
-    "resource": {
-      "oneOf": [
-        {
-          "$ref": "#/$defs/server"
-        },
-        {
-          "$ref": "#/$defs/firewall"
-        },
-        {
-          "$ref": "#/$defs/router"
-        },
-        {
-          "$ref": "#/$defs/switch"
-        },
-        {
-          "$ref": "#/$defs/accessPoint"
-        },
-        {
-          "$ref": "#/$defs/ups"
-        },
-        {
-          "$ref": "#/$defs/desktop"
-        },
-        {
-          "$ref": "#/$defs/laptop"
-        },
-        {
-          "$ref": "#/$defs/service"
-        },
-        {
-          "$ref": "#/$defs/system"
-        }
-      ]
-    },
-    "portReference": {
-      "type": "object",
-      "required": [
-        "resource",
-        "portGroup",
-        "portIndex"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "resource": {
-          "type": "string",
-          "minLength": 1
-        },
-        "portGroup": {
-          "type": "integer",
-          "minimum": 0
-        },
-        "portIndex": {
-          "type": "integer",
-          "minimum": 0
-        }
-      }
-    },
-    "connection": {
-      "type": "object",
-      "required": [
-        "a",
-        "b"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "a": {
-          "$ref": "#/$defs/portReference"
-        },
-        "b": {
-          "$ref": "#/$defs/portReference"
-        },
-        "label": {
-          "type": [
-            "string",
-            "null"
-          ]
-        },
-        "notes": {
-          "type": [
-            "string",
-            "null"
-          ]
-        }
-      }
-    },
-    "ram": {
-      "type": "object",
-      "required": [
-        "size"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "size": {
-          "type": "number",
-          "minimum": 0
-        },
-        "mts": {
-          "type": "integer",
-          "minimum": 0
-        }
-      }
-    },
-    "cpu": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "model": {
-          "type": "string"
-        },
-        "cores": {
-          "type": "integer",
-          "minimum": 1
-        },
-        "threads": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "drive": {
-      "type": "object",
-      "required": [
-        "size"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string",
-          "enum": [
-            "nvme",
-            "ssd",
-            "hdd",
-            "sas",
-            "sata",
-            "usb",
-            "sdcard",
-            "micro-sd"
-          ]
-        },
-        "size": {
-          "type": "number",
-          "minimum": 1
-        }
-      }
-    },
-    "gpu": {
-      "type": "object",
-      "additionalProperties": false,
-      "properties": {
-        "model": {
-          "type": "string"
-        },
-        "vram": {
-          "type": "number",
-          "minimum": 0
-        }
-      }
-    },
-    "port": {
-      "type": "object",
-      "required": [
-        "type",
-        "speed",
-        "count"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "type": {
-          "type": "string",
-          "enum": [
-            "rj45",
-            "sfp",
-            "sfp+",
-            "sfp28",
-            "sfp56",
-            "qsfp+",
-            "qsfp28",
-            "qsfp56",
-            "qsfp-dd",
-            "osfp",
-            "xfp",
-            "cx4",
-            "mgmt"
-          ]
-        },
-        "speed": {
-          "type": "number",
-          "minimum": 0
-        },
-        "count": {
-          "type": "integer",
-          "minimum": 1
-        }
-      }
-    },
-    "network": {
-      "type": "object",
-      "required": [
-        "ip",
-        "port",
-        "protocol"
-      ],
-      "additionalProperties": false,
-      "properties": {
-        "ip": {
-          "type": "string",
-          "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
-        },
-        "port": {
-          "type": "integer",
-          "minimum": 1,
-          "maximum": 65535
-        },
-        "protocol": {
-          "type": "string",
-          "enum": [
-            "TCP",
-            "UDP"
-          ]
-        },
-        "url": {
-          "type": "string",
-          "format": "uri"
-        }
-      }
-    },
-    "server": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Server"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "ipmi": {
-              "type": "boolean"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            },
-            "gpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/gpu"
-              }
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "desktop": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Desktop"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            },
-            "gpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/gpu"
-              }
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "laptop": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Laptop"
-            },
-            "ram": {
-              "$ref": "#/$defs/ram"
-            },
-            "cpus": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/cpu"
-              }
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "firewall": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Firewall"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "router": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Router"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "switch": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "ports"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Switch"
-            },
-            "model": {
-              "type": "string"
-            },
-            "managed": {
-              "type": "boolean"
-            },
-            "poe": {
-              "type": "boolean"
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "accessPoint": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "AccessPoint"
-            },
-            "model": {
-              "type": "string"
-            },
-            "speed": {
-              "type": "number",
-              "minimum": 0
-            },
-            "ports": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/port"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "ups": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "properties": {
-            "kind": {
-              "const": "Ups"
-            },
-            "model": {
-              "type": "string"
-            },
-            "va": {
-              "type": "integer",
-              "minimum": 1
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "service": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "network"
-          ],
-          "properties": {
-            "kind": {
-              "const": "Service"
-            },
-            "network": {
-              "$ref": "#/$defs/network"
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    },
-    "system": {
-      "allOf": [
-        {
-          "$ref": "#/$defs/resourceBase"
-        },
-        {
-          "type": "object",
-          "required": [
-            "type",
-            "os",
-            "cores",
-            "ram"
-          ],
-          "properties": {
-            "kind": {
-              "const": "System"
-            },
-            "type": {
-              "type": "string",
-              "enum": [
-                "baremetal",
-                "Baremetal",
-                "cluster",
-                "Cluster",
-                "hypervisor",
-                "Hypervisor",
-                "vm",
-                "VM",
-                "container",
-                "embedded",
-                "cloud",
-                "other"
-              ]
-            },
-            "ip": {
-              "type": "string"
-            },
-            "os": {
-              "type": "string"
-            },
-            "cores": {
-              "type": "integer",
-              "minimum": 1
-            },
-            "ram": {
-              "type": "number",
-              "minimum": 0
-            },
-            "drives": {
-              "type": "array",
-              "items": {
-                "$ref": "#/$defs/drive"
-              }
-            }
-          }
-        }
-      ],
-      "unevaluatedProperties": false
-    }
-  }
-}

+ 15 - 0
docs/CommandIndex.md

@@ -135,6 +135,21 @@
     - [tag](docs/Commands.md#rpk-ups-tag) - Manage tags on a UPS unit
       - [add](docs/Commands.md#rpk-ups-tag-add) - Add a tag to a UPS unit
       - [remove](docs/Commands.md#rpk-ups-tag-remove) - Remove a tag from a UPS unit
+  - [other](docs/Commands.md#rpk-other) - Manage other hardware that doesn't fit an existing category
+    - [summary](docs/Commands.md#rpk-other-summary) - Show a hardware report for all other hardware
+    - [add](docs/Commands.md#rpk-other-add) - Add new other hardware
+    - [list](docs/Commands.md#rpk-other-list) - List all other hardware
+    - [get](docs/Commands.md#rpk-other-get) - Retrieve other hardware by name
+    - [describe](docs/Commands.md#rpk-other-describe) - Show detailed information about other hardware
+    - [set](docs/Commands.md#rpk-other-set) - Update properties of other hardware
+    - [del](docs/Commands.md#rpk-other-del) - Delete other hardware
+    - [rename](docs/Commands.md#rpk-other-rename) - Rename other hardware to a new name
+    - [label](docs/Commands.md#rpk-other-label) - Manage labels on other hardware
+      - [add](docs/Commands.md#rpk-other-label-add) - Add a label to other hardware
+      - [remove](docs/Commands.md#rpk-other-label-remove) - Remove a label from other hardware
+    - [tag](docs/Commands.md#rpk-other-tag) - Manage tags on other hardware
+      - [add](docs/Commands.md#rpk-other-tag-add) - Add a tag to other hardware
+      - [remove](docs/Commands.md#rpk-other-tag-remove) - Remove a tag from other hardware
   - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
     - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
     - [list](docs/Commands.md#rpk-desktops-list) - List all desktops

+ 256 - 17
docs/Commands.md

@@ -10,23 +10,24 @@ OPTIONS:
     -v, --version    Prints version information
 
 COMMANDS:
-    summary         Show a summarized report of all resources in the system
-    servers         Manage servers and their components                    
-    switches        Manage network switches                                
-    routers         Manage network routers                                 
-    firewalls       Manage firewalls                                       
-    systems         Manage systems and their dependencies                  
-    accesspoints    Manage access points                                   
-    ups             Manage UPS units                                       
-    desktops        Manage desktop computers and their components          
-    laptops         Manage Laptop computers and their components           
-    services        Manage services and their configurations               
-    ansible         Generate and manage Ansible inventory                  
-    ssh             Generate SSH configuration from infrastructure         
-    hosts           Generate a hosts file from infrastructure              
-    graph           Render inventory as graph diagrams                     
-    tags            Discover tags across resources                         
-    connections     Manage physical or logical port connections            
+    summary         Show a summarized report of all resources in the system    
+    servers         Manage servers and their components                        
+    switches        Manage network switches                                    
+    routers         Manage network routers                                     
+    firewalls       Manage firewalls                                           
+    systems         Manage systems and their dependencies                      
+    accesspoints    Manage access points                                       
+    ups             Manage UPS units                                           
+    other           Manage other hardware that doesn't fit an existing category
+    desktops        Manage desktop computers and their components              
+    laptops         Manage Laptop computers and their components               
+    services        Manage services and their configurations                   
+    ansible         Generate and manage Ansible inventory                      
+    ssh             Generate SSH configuration from infrastructure             
+    hosts           Generate a hosts file from infrastructure                  
+    graph           Render inventory as graph diagrams                         
+    tags            Discover tags across resources                             
+    connections     Manage physical or logical port connections                
 ```
 
 ## `rpk summary`
@@ -2231,6 +2232,244 @@ OPTIONS:
     -h, --help    Prints help information
 ```
 
+## `rpk other`
+```
+DESCRIPTION:
+Manage other hardware that doesn't fit an existing category
+
+USAGE:
+    rpk other [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    summary                     Show a hardware report for all other hardware 
+    add <name>                  Add new other hardware                        
+    list                        List all other hardware                       
+    get <name>                  Retrieve other hardware by name               
+    describe <name>             Show detailed information about other hardware
+    set <name>                  Update properties of other hardware           
+    del <name>                  Delete other hardware                         
+    rename <name> <new-name>    Rename other hardware to a new name           
+    label                       Manage labels on other hardware               
+    tag                         Manage tags on other hardware                 
+```
+
+## `rpk other summary`
+```
+DESCRIPTION:
+Show a hardware report for all other hardware
+
+USAGE:
+    rpk other summary [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other add`
+```
+DESCRIPTION:
+Add new other hardware
+
+USAGE:
+    rpk other add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other list`
+```
+DESCRIPTION:
+List all other hardware
+
+USAGE:
+    rpk other list [OPTIONS]
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other get`
+```
+DESCRIPTION:
+Retrieve other hardware by name
+
+USAGE:
+    rpk other get <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other describe`
+```
+DESCRIPTION:
+Show detailed information about other hardware
+
+USAGE:
+    rpk other describe <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other set`
+```
+DESCRIPTION:
+Update properties of other hardware
+
+USAGE:
+    rpk other set <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help           Prints help information
+        --model                                 
+        --description                           
+```
+
+## `rpk other del`
+```
+DESCRIPTION:
+Delete other hardware
+
+USAGE:
+    rpk other del <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other rename`
+```
+DESCRIPTION:
+Rename other hardware to a new name
+
+USAGE:
+    rpk other rename <name> <new-name> [OPTIONS]
+
+ARGUMENTS:
+    <name>         
+    <new-name>     
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other label`
+```
+DESCRIPTION:
+Manage labels on other hardware
+
+USAGE:
+    rpk other label [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    add <name>       Add a label to other hardware     
+    remove <name>    Remove a label from other hardware
+```
+
+## `rpk other label add`
+```
+DESCRIPTION:
+Add a label to other hardware
+
+USAGE:
+    rpk other label add <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help             Prints help information
+        --key <KEY>                               
+        --value <VALUE>                           
+```
+
+## `rpk other label remove`
+```
+DESCRIPTION:
+Remove a label from other hardware
+
+USAGE:
+    rpk other label remove <name> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+
+OPTIONS:
+    -h, --help         Prints help information
+        --key <KEY>                           
+```
+
+## `rpk other tag`
+```
+DESCRIPTION:
+Manage tags on other hardware
+
+USAGE:
+    rpk other tag [OPTIONS] <COMMAND>
+
+OPTIONS:
+    -h, --help    Prints help information
+
+COMMANDS:
+    add <name> <tag>       Add a tag to other hardware     
+    remove <name> <tag>    Remove a tag from other hardware
+```
+
+## `rpk other tag add`
+```
+DESCRIPTION:
+Add a tag to other hardware
+
+USAGE:
+    rpk other tag add <name> <tag> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+    <tag>      
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
+## `rpk other tag remove`
+```
+DESCRIPTION:
+Remove a tag from other hardware
+
+USAGE:
+    rpk other tag remove <name> <tag> [OPTIONS]
+
+ARGUMENTS:
+    <name>     
+    <tag>      
+
+OPTIONS:
+    -h, --help    Prints help information
+```
+
 ## `rpk desktops`
 ```
 DESCRIPTION:

+ 431 - 143
schemas/v2/schema.v2.json

@@ -4,104 +4,188 @@
   "title": "RackPeek Infrastructure Specification",
   "type": "object",
   "additionalProperties": false,
-  "required": ["version", "resources"],
+  "required": [
+    "version",
+    "resources"
+  ],
   "properties": {
-    "version": { "type": "integer", "const": 2 },
+    "version": {
+      "type": "integer",
+      "const": 2
+    },
     "resources": {
       "type": "array",
-      "items": { "$ref": "#/$defs/resource" }
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
     }
   },
-
   "$defs": {
     "labels": {
       "type": "object",
-      "additionalProperties": { "type": "string" }
+      "additionalProperties": {
+        "type": "string"
+      }
     },
-
     "runsOn": {
-      "type": ["array", "null"],
+      "type": [
+        "array",
+        "null"
+      ],
       "items": {
         "type": "string",
         "minLength": 1
       }
     },
-
     "resourceBase": {
       "type": "object",
-      "required": ["kind", "name"],
+      "required": [
+        "kind",
+        "name"
+      ],
       "properties": {
-        "kind": { "type": "string" },
-        "name": { "type": "string", "minLength": 1 },
-
-        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
-        "labels": { "$ref": "#/$defs/labels", "default": {} },
-        "notes": { "type": ["string", "null"] },
-
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
         "runsOn": {
-          "type": ["array", "null"],
-          "items": { "type": "string" }
+          "type": [
+            "array",
+            "null"
+          ],
+          "items": {
+            "type": "string"
+          }
         }
       }
     },
-
     "resource": {
       "oneOf": [
-        { "$ref": "#/$defs/server" },
-        { "$ref": "#/$defs/firewall" },
-        { "$ref": "#/$defs/router" },
-        { "$ref": "#/$defs/switch" },
-        { "$ref": "#/$defs/accessPoint" },
-        { "$ref": "#/$defs/ups" },
-        { "$ref": "#/$defs/desktop" },
-        { "$ref": "#/$defs/laptop" },
-        { "$ref": "#/$defs/service" },
-        { "$ref": "#/$defs/system" }
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
       ]
     },
-
     "ram": {
       "type": "object",
-      "required": ["size"],
+      "required": [
+        "size"
+      ],
       "additionalProperties": false,
       "properties": {
-        "size": { "type": "number", "minimum": 0 },
-        "mts": { "type": "integer", "minimum": 0 }
+        "size": {
+          "type": "number",
+          "minimum": 0
+        },
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
       }
     },
-
     "cpu": {
       "type": "object",
       "additionalProperties": false,
       "properties": {
-        "model": { "type": "string" },
-        "cores": { "type": "integer", "minimum": 1 },
-        "threads": { "type": "integer", "minimum": 1 }
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
       }
     },
-
     "drive": {
       "type": "object",
-      "required": ["size"],
+      "required": [
+        "size"
+      ],
       "additionalProperties": false,
       "properties": {
         "type": {
           "type": "string",
-          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
+          "enum": [
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
+          ]
         },
-        "size": { "type": "number", "minimum": 1 }
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
       }
     },
-
     "gpu": {
       "type": "object",
       "additionalProperties": false,
       "properties": {
-        "model": { "type": "string" },
-        "vram": { "type": "number", "minimum": 0 }
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
       }
     },
-
     "nic": {
       "type": "object",
       "additionalProperties": false,
@@ -109,224 +193,428 @@
         "type": {
           "type": "string",
           "enum": [
-            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
-            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
-            "osfp", "xfp", "cx4", "mgmt"
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt"
           ]
         },
-        "speed": { "type": "number", "minimum": 0 },
-        "ports": { "type": "integer", "minimum": 1 }
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "ports": {
+          "type": "integer",
+          "minimum": 1
+        }
       }
     },
-
     "port": {
       "type": "object",
-      "required": ["type", "speed", "count"],
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
       "additionalProperties": false,
       "properties": {
-        "type": { "type": "string" },
-        "speed": { "type": "number", "minimum": 0 },
-        "count": { "type": "integer", "minimum": 1 }
+        "type": {
+          "type": "string"
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
       }
     },
-
     "network": {
       "type": "object",
-      "required": ["ip", "port", "protocol"],
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
       "additionalProperties": false,
       "properties": {
         "ip": {
           "type": "string",
           "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
         },
-        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
-        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
-        "url": { "type": "string", "format": "uri" }
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
       }
     },
-
     "server": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Server" },
-
-            "ram": { "$ref": "#/$defs/ram" },
-            "ipmi": { "type": "boolean" },
-            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "nics": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/nic"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "desktop": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Desktop" },
-
-            "ram": { "$ref": "#/$defs/ram" },
-            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "nics": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/nic"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "laptop": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Laptop" },
-
-            "ram": { "$ref": "#/$defs/ram" },
-            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "firewall": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["ports"],
+          "required": [
+            "ports"
+          ],
           "properties": {
-            "kind": { "const": "Firewall" },
-
-            "model": { "type": "string" },
-            "managed": { "type": "boolean" },
-            "poe": { "type": "boolean" },
-            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "router": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["ports"],
+          "required": [
+            "ports"
+          ],
           "properties": {
-            "kind": { "const": "Router" },
-
-            "model": { "type": "string" },
-            "managed": { "type": "boolean" },
-            "poe": { "type": "boolean" },
-            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "switch": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["ports"],
+          "required": [
+            "ports"
+          ],
           "properties": {
-            "kind": { "const": "Switch" },
-
-            "model": { "type": "string" },
-            "managed": { "type": "boolean" },
-            "poe": { "type": "boolean" },
-            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "accessPoint": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "AccessPoint" },
-
-            "model": { "type": "string" },
-            "speed": { "type": "number", "minimum": 0 }
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "ups": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Ups" },
-
-            "model": { "type": "string" },
-            "va": { "type": "integer", "minimum": 1 }
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "service": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["network"],
+          "required": [
+            "network"
+          ],
           "properties": {
-            "kind": { "const": "Service" },
-            "network": { "$ref": "#/$defs/network" }
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "system": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["type", "os", "cores", "ram"],
+          "required": [
+            "type",
+            "os",
+            "cores",
+            "ram"
+          ],
           "properties": {
-            "kind": { "const": "System" },
-
+            "kind": {
+              "const": "System"
+            },
             "type": {
               "type": "string",
               "enum": [
-                "baremetal", "Baremetal",
-                "hypervisor", "Hypervisor",
-                "vm", "VM",
-                "container", "embedded", "cloud", "other"
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
               ]
             },
-            "os": { "type": "string" },
-            "cores": { "type": "integer", "minimum": 1 },
-            "ram": { "type": "number", "minimum": 0 },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+            "ip": {
+              "type": "string"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
           }
         }
       ],

+ 509 - 152
schemas/v3/schema.v3.json

@@ -1,336 +1,693 @@
 {
   "$schema": "https://json-schema.org/draft/2020-12/schema",
-  "$id": "https://timmoth.github.io/RackPeek/schemas/v2/schema.v2.json",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v3/schema.v3.json",
   "title": "RackPeek Infrastructure Specification",
   "type": "object",
   "additionalProperties": false,
-  "required": ["version", "resources"],
+  "required": [
+    "version",
+    "resources"
+  ],
   "properties": {
-    "version": { "type": "integer", "const": 2 },
+    "version": {
+      "type": "integer",
+      "const": 3
+    },
     "resources": {
       "type": "array",
-      "items": { "$ref": "#/$defs/resource" }
+      "items": {
+        "$ref": "#/$defs/resource"
+      }
+    },
+    "connections": {
+      "type": [
+        "array",
+        "null"
+      ],
+      "items": {
+        "$ref": "#/$defs/connection"
+      }
     }
   },
-
   "$defs": {
     "labels": {
       "type": "object",
-      "additionalProperties": { "type": "string" }
+      "additionalProperties": {
+        "type": "string"
+      }
     },
-
     "runsOn": {
-      "type": ["array", "null"],
+      "type": [
+        "array",
+        "null"
+      ],
       "items": {
         "type": "string",
         "minLength": 1
       }
     },
-
     "resourceBase": {
       "type": "object",
-      "required": ["kind", "name"],
+      "required": [
+        "kind",
+        "name"
+      ],
       "properties": {
-        "kind": { "type": "string" },
-        "name": { "type": "string", "minLength": 1 },
-
-        "tags": { "type": "array", "items": { "type": "string" }, "default": [] },
-        "labels": { "$ref": "#/$defs/labels", "default": {} },
-        "notes": { "type": ["string", "null"] },
-
+        "kind": {
+          "type": "string"
+        },
+        "name": {
+          "type": "string",
+          "minLength": 1
+        },
+        "tags": {
+          "type": "array",
+          "items": {
+            "type": "string"
+          },
+          "default": []
+        },
+        "labels": {
+          "$ref": "#/$defs/labels",
+          "default": {}
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
         "runsOn": {
-          "type": ["array", "null"],
-          "items": { "type": "string" }
+          "$ref": "#/$defs/runsOn"
         }
       }
     },
-
     "resource": {
       "oneOf": [
-        { "$ref": "#/$defs/server" },
-        { "$ref": "#/$defs/firewall" },
-        { "$ref": "#/$defs/router" },
-        { "$ref": "#/$defs/switch" },
-        { "$ref": "#/$defs/accessPoint" },
-        { "$ref": "#/$defs/ups" },
-        { "$ref": "#/$defs/desktop" },
-        { "$ref": "#/$defs/laptop" },
-        { "$ref": "#/$defs/service" },
-        { "$ref": "#/$defs/system" }
+        {
+          "$ref": "#/$defs/server"
+        },
+        {
+          "$ref": "#/$defs/firewall"
+        },
+        {
+          "$ref": "#/$defs/router"
+        },
+        {
+          "$ref": "#/$defs/switch"
+        },
+        {
+          "$ref": "#/$defs/accessPoint"
+        },
+        {
+          "$ref": "#/$defs/ups"
+        },
+        {
+          "$ref": "#/$defs/other"
+        },
+        {
+          "$ref": "#/$defs/desktop"
+        },
+        {
+          "$ref": "#/$defs/laptop"
+        },
+        {
+          "$ref": "#/$defs/service"
+        },
+        {
+          "$ref": "#/$defs/system"
+        }
       ]
     },
-
-    "ram": {
+    "portReference": {
       "type": "object",
-      "required": ["size"],
+      "required": [
+        "resource",
+        "portGroup",
+        "portIndex"
+      ],
       "additionalProperties": false,
       "properties": {
-        "size": { "type": "number", "minimum": 0 },
-        "mts": { "type": "integer", "minimum": 0 }
+        "resource": {
+          "type": "string",
+          "minLength": 1
+        },
+        "portGroup": {
+          "type": "integer",
+          "minimum": 0
+        },
+        "portIndex": {
+          "type": "integer",
+          "minimum": 0
+        }
       }
     },
-
-    "cpu": {
+    "connection": {
       "type": "object",
+      "required": [
+        "a",
+        "b"
+      ],
       "additionalProperties": false,
       "properties": {
-        "model": { "type": "string" },
-        "cores": { "type": "integer", "minimum": 1 },
-        "threads": { "type": "integer", "minimum": 1 }
+        "a": {
+          "$ref": "#/$defs/portReference"
+        },
+        "b": {
+          "$ref": "#/$defs/portReference"
+        },
+        "label": {
+          "type": [
+            "string",
+            "null"
+          ]
+        },
+        "notes": {
+          "type": [
+            "string",
+            "null"
+          ]
+        }
       }
     },
-
-    "drive": {
+    "ram": {
       "type": "object",
-      "required": ["size"],
+      "required": [
+        "size"
+      ],
       "additionalProperties": false,
       "properties": {
-        "type": {
-          "type": "string",
-          "enum": ["nvme", "ssd", "hdd", "sas", "sata", "usb", "sdcard", "micro-sd"]
+        "size": {
+          "type": "number",
+          "minimum": 0
         },
-        "size": { "type": "number", "minimum": 1 }
+        "mts": {
+          "type": "integer",
+          "minimum": 0
+        }
       }
     },
-
-    "gpu": {
+    "cpu": {
       "type": "object",
       "additionalProperties": false,
       "properties": {
-        "model": { "type": "string" },
-        "vram": { "type": "number", "minimum": 0 }
+        "model": {
+          "type": "string"
+        },
+        "cores": {
+          "type": "integer",
+          "minimum": 1
+        },
+        "threads": {
+          "type": "integer",
+          "minimum": 1
+        }
       }
     },
-
-    "nic": {
+    "drive": {
       "type": "object",
+      "required": [
+        "size"
+      ],
       "additionalProperties": false,
       "properties": {
         "type": {
           "type": "string",
           "enum": [
-            "rj45", "sfp", "sfp+", "sfp28", "sfp56",
-            "qsfp+", "qsfp28", "qsfp56", "qsfp-dd",
-            "osfp", "xfp", "cx4", "mgmt"
+            "nvme",
+            "ssd",
+            "hdd",
+            "sas",
+            "sata",
+            "usb",
+            "sdcard",
+            "micro-sd"
           ]
         },
-        "speed": { "type": "number", "minimum": 0 },
-        "ports": { "type": "integer", "minimum": 1 }
+        "size": {
+          "type": "number",
+          "minimum": 1
+        }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": {
+          "type": "string"
+        },
+        "vram": {
+          "type": "number",
+          "minimum": 0
+        }
       }
     },
-
     "port": {
       "type": "object",
-      "required": ["type", "speed", "count"],
+      "required": [
+        "type",
+        "speed",
+        "count"
+      ],
       "additionalProperties": false,
       "properties": {
-        "type": { "type": "string" },
-        "speed": { "type": "number", "minimum": 0 },
-        "count": { "type": "integer", "minimum": 1 }
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45",
+            "sfp",
+            "sfp+",
+            "sfp28",
+            "sfp56",
+            "qsfp+",
+            "qsfp28",
+            "qsfp56",
+            "qsfp-dd",
+            "osfp",
+            "xfp",
+            "cx4",
+            "mgmt"
+          ]
+        },
+        "speed": {
+          "type": "number",
+          "minimum": 0
+        },
+        "count": {
+          "type": "integer",
+          "minimum": 1
+        }
       }
     },
-
     "network": {
       "type": "object",
-      "required": ["ip", "port", "protocol"],
+      "required": [
+        "ip",
+        "port",
+        "protocol"
+      ],
       "additionalProperties": false,
       "properties": {
         "ip": {
           "type": "string",
           "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
         },
-        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
-        "protocol": { "type": "string", "enum": ["TCP", "UDP"] },
-        "url": { "type": "string", "format": "uri" }
+        "port": {
+          "type": "integer",
+          "minimum": 1,
+          "maximum": 65535
+        },
+        "protocol": {
+          "type": "string",
+          "enum": [
+            "TCP",
+            "UDP"
+          ]
+        },
+        "url": {
+          "type": "string",
+          "format": "uri"
+        }
       }
     },
-
     "server": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Server" },
-
-            "ram": { "$ref": "#/$defs/ram" },
-            "ipmi": { "type": "boolean" },
-            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+            "kind": {
+              "const": "Server"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "ipmi": {
+              "type": "boolean"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "desktop": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Desktop" },
-
-            "ram": { "$ref": "#/$defs/ram" },
-            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
-            "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
-            "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+            "kind": {
+              "const": "Desktop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            },
+            "gpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/gpu"
+              }
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "laptop": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Laptop" },
-
-            "ram": { "$ref": "#/$defs/ram" },
-            "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+            "kind": {
+              "const": "Laptop"
+            },
+            "ram": {
+              "$ref": "#/$defs/ram"
+            },
+            "cpus": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/cpu"
+              }
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "firewall": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["ports"],
+          "required": [
+            "ports"
+          ],
           "properties": {
-            "kind": { "const": "Firewall" },
-
-            "model": { "type": "string" },
-            "managed": { "type": "boolean" },
-            "poe": { "type": "boolean" },
-            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+            "kind": {
+              "const": "Firewall"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "router": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["ports"],
+          "required": [
+            "ports"
+          ],
           "properties": {
-            "kind": { "const": "Router" },
-
-            "model": { "type": "string" },
-            "managed": { "type": "boolean" },
-            "poe": { "type": "boolean" },
-            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+            "kind": {
+              "const": "Router"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "switch": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["ports"],
+          "required": [
+            "ports"
+          ],
           "properties": {
-            "kind": { "const": "Switch" },
-
-            "model": { "type": "string" },
-            "managed": { "type": "boolean" },
-            "poe": { "type": "boolean" },
-            "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+            "kind": {
+              "const": "Switch"
+            },
+            "model": {
+              "type": "string"
+            },
+            "managed": {
+              "type": "boolean"
+            },
+            "poe": {
+              "type": "boolean"
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "accessPoint": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "AccessPoint" },
-
-            "model": { "type": "string" },
-            "speed": { "type": "number", "minimum": 0 }
+            "kind": {
+              "const": "AccessPoint"
+            },
+            "model": {
+              "type": "string"
+            },
+            "speed": {
+              "type": "number",
+              "minimum": 0
+            },
+            "ports": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/port"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "ups": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Ups"
+            },
+            "model": {
+              "type": "string"
+            },
+            "va": {
+              "type": "integer",
+              "minimum": 1
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
           "properties": {
-            "kind": { "const": "Ups" },
-
-            "model": { "type": "string" },
-            "va": { "type": "integer", "minimum": 1 }
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "service": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["network"],
+          "required": [
+            "network"
+          ],
           "properties": {
-            "kind": { "const": "Service" },
-            "network": { "$ref": "#/$defs/network" }
+            "kind": {
+              "const": "Service"
+            },
+            "network": {
+              "$ref": "#/$defs/network"
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     },
-
     "system": {
       "allOf": [
-        { "$ref": "#/$defs/resourceBase" },
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
         {
           "type": "object",
-          "required": ["type", "os", "cores", "ram"],
+          "required": [
+            "type",
+            "os",
+            "cores",
+            "ram"
+          ],
           "properties": {
-            "kind": { "const": "System" },
-
+            "kind": {
+              "const": "System"
+            },
             "type": {
               "type": "string",
               "enum": [
-                "baremetal", "Baremetal",
-                "hypervisor", "Hypervisor",
-                "vm", "VM",
-                "container", "embedded", "cloud", "other"
+                "baremetal",
+                "Baremetal",
+                "cluster",
+                "Cluster",
+                "hypervisor",
+                "Hypervisor",
+                "vm",
+                "VM",
+                "container",
+                "embedded",
+                "cloud",
+                "other"
               ]
             },
-            "os": { "type": "string" },
-            "cores": { "type": "integer", "minimum": 1 },
-            "ram": { "type": "number", "minimum": 0 },
-            "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+            "ip": {
+              "type": "string",
+              "pattern": "^(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)(\\.(25[0-5]|2[0-4]\\d|1\\d\\d|[1-9]?\\d)){3}$"
+            },
+            "os": {
+              "type": "string"
+            },
+            "cores": {
+              "type": "integer",
+              "minimum": 1
+            },
+            "ram": {
+              "type": "number",
+              "minimum": 0
+            },
+            "drives": {
+              "type": "array",
+              "items": {
+                "$ref": "#/$defs/drive"
+              }
+            }
           }
         }
       ],
       "unevaluatedProperties": false
     }
   }
-}
+}

Một số tệp đã không được hiển thị bởi vì quá nhiều tập tin thay đổi trong này khác