Quellcode durchsuchen

Merge branch 'staging' of https://github.com/Timmoth/RackPeek into staging

Tim Jones vor 1 Tag
Ursprung
Commit
7626e6892c
50 geänderte Dateien mit 2308 neuen und 54 gelöschten Zeilen
  1. 5 5
      AGENTS.md
  2. 1 0
      RackPeek.Domain/Graph/Serialisers/MermaidSerialiser.cs
  3. 4 0
      RackPeek.Domain/Persistence/ResourceCollectionMerger.cs
  4. 2 0
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  5. 2 0
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  6. 29 0
      RackPeek.Domain/Resources/OtherHardware/DescribeOtherUseCase.cs
  7. 7 0
      RackPeek.Domain/Resources/OtherHardware/Other.cs
  8. 27 0
      RackPeek.Domain/Resources/OtherHardware/OtherHardwareReport.cs
  9. 29 0
      RackPeek.Domain/Resources/OtherHardware/UpdateOtherUseCase.cs
  10. 4 1
      RackPeek.Domain/Resources/Resource.cs
  11. 14 8
      RackPeek.Web/Program.cs
  12. 44 0
      Shared.Rcl/CliBootstrap.cs
  13. 23 0
      Shared.Rcl/Commands/OtherHardware/Labels/OtherLabelAddCommand.cs
  14. 22 0
      Shared.Rcl/Commands/OtherHardware/Labels/OtherLabelRemoveCommand.cs
  15. 28 0
      Shared.Rcl/Commands/OtherHardware/OtherAddCommand.cs
  16. 28 0
      Shared.Rcl/Commands/OtherHardware/OtherDeleteCommand.cs
  17. 34 0
      Shared.Rcl/Commands/OtherHardware/OtherDescribeCommand.cs
  18. 24 0
      Shared.Rcl/Commands/OtherHardware/OtherGetByNameCommand.cs
  19. 39 0
      Shared.Rcl/Commands/OtherHardware/OtherGetCommand.cs
  20. 38 0
      Shared.Rcl/Commands/OtherHardware/OtherReportCommand.cs
  21. 27 0
      Shared.Rcl/Commands/OtherHardware/OtherSetCommand.cs
  22. 29 0
      Shared.Rcl/Commands/OtherHardware/Rename/OtherRenameCommand.cs
  23. 12 5
      Shared.Rcl/Components/MarkdownEditor.razor
  24. 6 0
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  25. 1 0
      Shared.Rcl/Hardware/HardwareTreePage.razor
  26. 254 0
      Shared.Rcl/OtherHardware/OtherCardComponent.razor
  27. 32 0
      Shared.Rcl/OtherHardware/OtherListPage.razor
  28. 15 0
      Shared.Rcl/wwwroot/raw_docs/cli-commands-index.md
  29. 256 17
      Shared.Rcl/wwwroot/raw_docs/cli-commands.md
  30. 1 1
      Shared.Rcl/wwwroot/raw_docs/inventory-api.md
  31. 1 0
      Shared.Rcl/wwwroot/raw_docs/resource-levels.md
  32. 66 0
      Tests.E2e/AccessPointCardTests.cs
  33. 41 0
      Tests.E2e/Infra/BlazorLatency.cs
  34. 225 0
      Tests.E2e/OtherCardTests.cs
  35. 58 0
      Tests.E2e/OtherTests.cs
  36. 16 0
      Tests.E2e/PageObjectModels/AccessPointCardPom.cs
  37. 6 0
      Tests.E2e/PageObjectModels/HardwareTreePom.cs
  38. 142 0
      Tests.E2e/PageObjectModels/OtherCardPom.cs
  39. 112 0
      Tests.E2e/PageObjectModels/OtherListPom.cs
  40. 2 0
      Tests/Api/ApiTestBase.cs
  41. 45 0
      Tests/Api/InventoryEndpointTests.cs
  42. 69 0
      Tests/EndToEnd/OtherTests/OtherCommandTests.cs
  43. 60 0
      Tests/EndToEnd/OtherTests/OtherErrorTests.cs
  44. 104 0
      Tests/EndToEnd/OtherTests/OtherWorkflowTests.cs
  45. 8 0
      Tests/TestConfigs/v3/12-other.yaml
  46. 3 0
      Tests/Tests.csproj
  47. 25 0
      Tests/schemas/schema.v3.json
  48. 15 0
      docs/CommandIndex.md
  49. 256 17
      docs/Commands.md
  50. 17 0
      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
 ## 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.
 - 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.
 - Same domain code powers the CLI binary (`rpk`) and the Blazor Server Web UI.
@@ -170,7 +170,7 @@ Top-level shape:
 
 
 ```yaml
 ```yaml
 resources:
 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>
     name: <unique name within kind>
     tags: [...]
     tags: [...]
     labels: { key: value }
     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.
 - `name` is the identity within a `kind`. Don't introduce numeric IDs.
 - `runsOn` relationships are validated by `Resource.CanRunOn<T>`:
 - `runsOn` relationships are validated by `Resource.CanRunOn<T>`:
   - `Service` may run on a `System`.
   - `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.
 - Anything that mutates the YAML must go through an `IResourceUseCase<T>` → `IResourceCollection` → repository, never direct file writes.
 
 
 ### YAML migrations
 ### YAML migrations
@@ -213,7 +213,7 @@ The full command tree is documented in `docs/Commands.md` and `docs/CommandIndex
 rpk <kind> <verb> [name] [flags]
 rpk <kind> <verb> [name] [flags]
 
 
 kinds:   summary, servers, switches, routers, firewalls, systems,
 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
 verbs:   summary, add, list, get, describe, set, del, tree
 sub:     cpu, drive, gpu, nic, port, subnets, labels, tags, rename, …
 sub:     cpu, drive, gpu, nic, port, subnets, labels, tags, rename, …
 ```
 ```

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

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

+ 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.Servers;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.UpsUnits;
 using RackPeek.Domain.Resources.UpsUnits;
 
 
@@ -185,6 +186,9 @@ internal static class ResourcePolymorphismResolver {
                 typeInfo.PolymorphismOptions.DerivedTypes.Add(
                 typeInfo.PolymorphismOptions.DerivedTypes.Add(
                     new JsonDerivedType(typeof(Ups), Ups.KindLabel));
                     new JsonDerivedType(typeof(Ups), Ups.KindLabel));
 
 
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Other), Other.KindLabel));
+
                 typeInfo.PolymorphismOptions.DerivedTypes.Add(
                 typeInfo.PolymorphismOptions.DerivedTypes.Add(
                     new JsonDerivedType(typeof(SystemResource), SystemResource.KindLabel));
                     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.Services;
 using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.Switches;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.UpsUnits;
 using RackPeek.Domain.Resources.UpsUnits;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
 using YamlDotNet.Serialization.NamingConventions;
@@ -46,6 +47,7 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
                         { Laptop.KindLabel, typeof(Laptop) },
                         { Laptop.KindLabel, typeof(Laptop) },
                         { AccessPoint.KindLabel, typeof(AccessPoint) },
                         { AccessPoint.KindLabel, typeof(AccessPoint) },
                         { Ups.KindLabel, typeof(Ups) },
                         { Ups.KindLabel, typeof(Ups) },
+                        { Other.KindLabel, typeof(Other) },
                         { SystemResource.KindLabel, typeof(SystemResource) },
                         { SystemResource.KindLabel, typeof(SystemResource) },
                         { Service.KindLabel, typeof(Service) }
                         { Service.KindLabel, typeof(Service) }
                     });
                     });

+ 2 - 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.Servers;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.OtherHardware;
 using RackPeek.Domain.Resources.UpsUnits;
 using RackPeek.Domain.Resources.UpsUnits;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization;
 using YamlDotNet.Serialization.NamingConventions;
 using YamlDotNet.Serialization.NamingConventions;
@@ -407,6 +408,7 @@ public sealed class YamlResourceCollection(
             Laptop => "Laptop",
             Laptop => "Laptop",
             AccessPoint => "AccessPoint",
             AccessPoint => "AccessPoint",
             Ups => "Ups",
             Ups => "Ups",
+            Other => "Other",
             SystemResource => "System",
             SystemResource => "System",
             Service => "Service",
             Service => "Service",
             _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")
             _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}")

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

+ 14 - 8
RackPeek.Web/Program.cs

@@ -31,14 +31,20 @@ public class Program {
         var yamlFilePath = Path.Combine(yamlPath, yamlFileName);
         var yamlFilePath = Path.Combine(yamlPath, yamlFileName);
 
 
         if (!File.Exists(yamlFilePath)) {
         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.
+            }
         }
         }
 
 
         builder.Services.ConfigureHttpJsonOptions(options => {
         builder.Services.ConfigureHttpJsonOptions(options => {

+ 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;
 using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Systems.Rename;
 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.Tags;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups.Labels;
 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
             // 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;
+    }
+}

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

@@ -23,8 +23,9 @@
     <textarea
     <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"
         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")"
         data-testid="@($"{BaseTestId}-textarea")"
-        value="@Value"
-        @oninput="HandleInput">
+        @bind="_text"
+        @bind:event="oninput"
+        @bind:after="NotifyChanged">
     </textarea>
     </textarea>
 
 
 </div>
 </div>
@@ -45,10 +46,16 @@
             ? "markdown-editor"
             ? "markdown-editor"
             : $"{TestIdPrefix}-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()
     async Task HandleSave()

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

@@ -9,6 +9,7 @@
 @using RackPeek.Domain.Resources.Servers
 @using RackPeek.Domain.Resources.Servers
 @using RackPeek.Domain.Resources.Switches
 @using RackPeek.Domain.Resources.Switches
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.SystemResources
+@using RackPeek.Domain.Resources.OtherHardware
 @using RackPeek.Domain.Resources.UpsUnits
 @using RackPeek.Domain.Resources.UpsUnits
 @using Shared.Rcl.AccessPoints
 @using Shared.Rcl.AccessPoints
 @using Shared.Rcl.Connections
 @using Shared.Rcl.Connections
@@ -19,6 +20,7 @@
 @using Shared.Rcl.Servers
 @using Shared.Rcl.Servers
 @using Shared.Rcl.Switches
 @using Shared.Rcl.Switches
 @using Shared.Rcl.Ups
 @using Shared.Rcl.Ups
+@using Shared.Rcl.OtherHardware
 @using Router = RackPeek.Domain.Resources.Routers.Router
 @using Router = RackPeek.Domain.Resources.Routers.Router
 
 
 @inject IResourceCollection Repo
 @inject IResourceCollection Repo
@@ -81,6 +83,10 @@
             {
             {
                 <UpsCardComponent Ups="ups" OnDeleted="DeleteCallback"/>
                 <UpsCardComponent Ups="ups" OnDeleted="DeleteCallback"/>
             }
             }
+            else if (_hardware is Other other)
+            {
+                <OtherCardComponent Other="other" OnDeleted="DeleteCallback"/>
+            }
             else
             else
             {
             {
                 <div class="text-zinc-400">
                 <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="ups/list" data-testid="nav-ups">Ups</NavLink>
         <NavLink href="desktops/list" data-testid="nav-desktops">Desktops</NavLink>
         <NavLink href="desktops/list" data-testid="nav-desktops">Desktops</NavLink>
         <NavLink href="laptops/list" data-testid="nav-laptops">Laptops</NavLink>
         <NavLink href="laptops/list" data-testid="nav-laptops">Laptops</NavLink>
+        <NavLink href="other/list" data-testid="nav-other">Other</NavLink>
     </nav>
     </nav>
 
 
     @if (_tree is null)
     @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();
+    }
+
+}

+ 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
     - [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
       - [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
       - [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
   - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
     - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
     - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
     - [list](docs/Commands.md#rpk-desktops-list) - List all desktops
     - [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
     -v, --version    Prints version information
 
 
 COMMANDS:
 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`
 ## `rpk summary`
@@ -2231,6 +2232,244 @@ OPTIONS:
     -h, --help    Prints help information
     -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`
 ## `rpk desktops`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:

+ 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
 The `kind` discriminator is case-sensitive. Use the exact value for each
 resource type: `Server`, `Switch`, `Router`, `Firewall`, `AccessPoint`,
 resource type: `Server`, `Switch`, `Router`, `Firewall`, `AccessPoint`,
-`Ups`, `Desktop`, `Laptop`, `Service`, `System`.
+`Ups`, `Desktop`, `Laptop`, `Other`, `Service`, `System`.
 
 
 #### Response
 #### 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                   |
 | **Desktop**      | Desktop workstation          | Custom build, Mac Mini                   |
 | **Laptop**       | Laptop                       | ThinkPad T14s                            |
 | **Laptop**       | Laptop                       | ThinkPad T14s                            |
 | **UPS**          | Uninterruptible power supply | APC SMT1500                              |
 | **UPS**          | Uninterruptible power supply | APC SMT1500                              |
+| **Other**        | Anything that doesn't fit an existing category | Microwave radio bridge, KVM switch |
 
 
 ### Sub-Resources
 ### Sub-Resources
 
 

+ 66 - 0
Tests.E2e/AccessPointCardTests.cs

@@ -457,4 +457,70 @@ public class AccessPointCardTests(
             await context.CloseAsync();
             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;
+    }
 }
 }

+ 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();
+        }
+    }
+}

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

@@ -119,6 +119,22 @@ public class AccessPointCardPom(IPage page) {
     public async Task SetSpeedAsync(string accessPointName, double speed) =>
     public async Task SetSpeedAsync(string accessPointName, double speed) =>
         await SpeedInput(accessPointName).FillAsync(speed.ToString(CultureInfo.InvariantCulture));
         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) {
+        await NotesEditorTextarea(accessPointName).FocusAsync();
+        await page.Keyboard.PressAsync("Control+Home");
+        await page.Keyboard.PressAsync("End");
+        await page.Keyboard.PressAsync("Enter");
+        await TypeNotesAsync(accessPointName, line);
+    }
+
     public async Task SaveAsync(string accessPointName) {
     public async Task SaveAsync(string accessPointName) {
         await SaveButton(accessPointName).ClickAsync();
         await SaveButton(accessPointName).ClickAsync();
         await Assertions.Expect(ModelSection(accessPointName)).ToBeVisibleAsync();
         await Assertions.Expect(ModelSection(accessPointName)).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 NavUps => page.GetByTestId("nav-ups");
     public ILocator NavDesktops => page.GetByTestId("nav-desktops");
     public ILocator NavDesktops => page.GetByTestId("nav-desktops");
     public ILocator NavLaptops => page.GetByTestId("nav-laptops");
     public ILocator NavLaptops => page.GetByTestId("nav-laptops");
+    public ILocator NavOther => page.GetByTestId("nav-other");
 
 
     public async Task GotoAsync(string baseUrl) {
     public async Task GotoAsync(string baseUrl) {
         await page.GotoAsync($"{baseUrl}/hardware/tree");
         await page.GotoAsync($"{baseUrl}/hardware/tree");
@@ -145,4 +146,9 @@ public class HardwareTreePom(IPage page) {
         await NavLaptops.ClickAsync();
         await NavLaptops.ClickAsync();
         await page.WaitForURLAsync("**/laptops/list");
         await page.WaitForURLAsync("**/laptops/list");
     }
     }
+
+    public async Task GotoOtherListAsync() {
+        await NavOther.ClickAsync();
+        await page.WaitForURLAsync("**/other/list");
+    }
 }
 }

+ 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(" ", "-");
+}

+ 2 - 0
Tests/Api/ApiTestBase.cs

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

+ 45 - 0
Tests/Api/InventoryEndpointTests.cs

@@ -848,4 +848,49 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
         Assert.Contains("team: backend", newYaml);
         Assert.Contains("team: backend", newYaml);
         Assert.DoesNotContain("env: production", newYaml);
         Assert.DoesNotContain("env: production", newYaml);
     }
     }
+
+    [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);
+    }
 }
 }

+ 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

+ 3 - 0
Tests/Tests.csproj

@@ -74,5 +74,8 @@
         <None Update="TestConfigs\v3\11-demo-config.yaml">
         <None Update="TestConfigs\v3\11-demo-config.yaml">
             <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
             <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
         </None>
         </None>
+        <None Update="TestConfigs\v3\12-other.yaml">
+            <CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
+        </None>
     </ItemGroup>
     </ItemGroup>
 </Project>
 </Project>

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

@@ -102,6 +102,9 @@
         {
         {
           "$ref": "#/$defs/ups"
           "$ref": "#/$defs/ups"
         },
         },
+        {
+          "$ref": "#/$defs/other"
+        },
         {
         {
           "$ref": "#/$defs/desktop"
           "$ref": "#/$defs/desktop"
         },
         },
@@ -582,6 +585,28 @@
       ],
       ],
       "unevaluatedProperties": false
       "unevaluatedProperties": false
     },
     },
+    "other": {
+      "allOf": [
+        {
+          "$ref": "#/$defs/resourceBase"
+        },
+        {
+          "type": "object",
+          "properties": {
+            "kind": {
+              "const": "Other"
+            },
+            "model": {
+              "type": "string"
+            },
+            "description": {
+              "type": "string"
+            }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
     "service": {
     "service": {
       "allOf": [
       "allOf": [
         {
         {

+ 15 - 0
docs/CommandIndex.md

@@ -135,6 +135,21 @@
     - [tag](docs/Commands.md#rpk-ups-tag) - Manage tags on a UPS unit
     - [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
       - [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
       - [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
   - [desktops](docs/Commands.md#rpk-desktops) - Manage desktop computers and their components
     - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
     - [add](docs/Commands.md#rpk-desktops-add) - Add a new desktop
     - [list](docs/Commands.md#rpk-desktops-list) - List all desktops
     - [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
     -v, --version    Prints version information
 
 
 COMMANDS:
 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`
 ## `rpk summary`
@@ -2231,6 +2232,244 @@ OPTIONS:
     -h, --help    Prints help information
     -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`
 ## `rpk desktops`
 ```
 ```
 DESCRIPTION:
 DESCRIPTION:

+ 17 - 0
schemas/v3/schema.v3.json

@@ -53,6 +53,7 @@
         { "$ref": "#/$defs/switch" },
         { "$ref": "#/$defs/switch" },
         { "$ref": "#/$defs/accessPoint" },
         { "$ref": "#/$defs/accessPoint" },
         { "$ref": "#/$defs/ups" },
         { "$ref": "#/$defs/ups" },
+        { "$ref": "#/$defs/other" },
         { "$ref": "#/$defs/desktop" },
         { "$ref": "#/$defs/desktop" },
         { "$ref": "#/$defs/laptop" },
         { "$ref": "#/$defs/laptop" },
         { "$ref": "#/$defs/service" },
         { "$ref": "#/$defs/service" },
@@ -290,6 +291,22 @@
       "unevaluatedProperties": false
       "unevaluatedProperties": false
     },
     },
 
 
+    "other": {
+      "allOf": [
+        { "$ref": "#/$defs/resourceBase" },
+        {
+          "type": "object",
+          "properties": {
+            "kind": { "const": "Other" },
+
+            "model": { "type": "string" },
+            "description": { "type": "string" }
+          }
+        }
+      ],
+      "unevaluatedProperties": false
+    },
+
     "service": {
     "service": {
       "allOf": [
       "allOf": [
         { "$ref": "#/$defs/resourceBase" },
         { "$ref": "#/$defs/resourceBase" },