Browse Source

Merge branch 'feat/153-add-labels' of https://github.com/DavidWalshe93/RackPeek into DavidWalshe93-feat/153-add-labels

# Conflicts:
#	Tests/EndToEnd/ServerYamlE2ETests.cs
Tim Jones 1 month ago
parent
commit
e9da751aec
69 changed files with 1873 additions and 27 deletions
  1. 10 0
      RackPeek.Domain/Helpers/Normalize.cs
  2. 12 0
      RackPeek.Domain/Helpers/ThrowIfInvalid.cs
  3. 4 2
      RackPeek.Domain/Resources/Desktops/DescribeDesktopUseCase.cs
  4. 4 2
      RackPeek.Domain/Resources/Firewalls/DescribeFirewallUseCase.cs
  5. 4 2
      RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs
  6. 1 0
      RackPeek.Domain/Resources/Resource.cs
  7. 4 2
      RackPeek.Domain/Resources/Routers/DescribeRouterUseCase.cs
  8. 4 2
      RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs
  9. 4 2
      RackPeek.Domain/Resources/Switches/DescribeSwitchUseCase.cs
  10. 4 2
      RackPeek.Domain/Resources/SystemResources/UseCases/DescribeSystemUseCase.cs
  11. 4 2
      RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs
  12. 3 0
      RackPeek.Domain/ServiceCollectionExtensions.cs
  13. 49 0
      RackPeek.Domain/UseCases/Labels/AddLabelUseCase.cs
  14. 47 0
      RackPeek.Domain/UseCases/Labels/RemoveLabelUseCase.cs
  15. 4 1
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  16. 83 0
      Shared.Rcl/CliBootstrap.cs
  17. 3 0
      Shared.Rcl/Commands/AccessPoints/AccessPointDescribeCommand.cs
  18. 25 0
      Shared.Rcl/Commands/AccessPoints/Labels/AccessPointLabelAddCommand.cs
  19. 24 0
      Shared.Rcl/Commands/AccessPoints/Labels/AccessPointLabelRemoveCommand.cs
  20. 3 0
      Shared.Rcl/Commands/Desktops/DesktopDescribeCommand.cs
  21. 25 0
      Shared.Rcl/Commands/Desktops/Labels/DesktopLabelAddCommand.cs
  22. 24 0
      Shared.Rcl/Commands/Desktops/Labels/DesktopLabelRemoveCommand.cs
  23. 3 0
      Shared.Rcl/Commands/Firewalls/FirewallDescribeCommand.cs
  24. 25 0
      Shared.Rcl/Commands/Firewalls/Labels/FirewallLabelAddCommand.cs
  25. 24 0
      Shared.Rcl/Commands/Firewalls/Labels/FirewallLabelRemoveCommand.cs
  26. 25 0
      Shared.Rcl/Commands/Laptops/Labels/LaptopLabelAddCommand.cs
  27. 24 0
      Shared.Rcl/Commands/Laptops/Labels/LaptopLabelRemoveCommand.cs
  28. 3 0
      Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs
  29. 25 0
      Shared.Rcl/Commands/Routers/Labels/RouterLabelAddCommand.cs
  30. 24 0
      Shared.Rcl/Commands/Routers/Labels/RouterLabelRemoveCommand.cs
  31. 3 0
      Shared.Rcl/Commands/Routers/RouterDescribeCommand.cs
  32. 34 0
      Shared.Rcl/Commands/Servers/Labels/ServerLabelAddCommand.cs
  33. 31 0
      Shared.Rcl/Commands/Servers/Labels/ServerLabelRemoveCommand.cs
  34. 3 0
      Shared.Rcl/Commands/Servers/ServerDescribeCommand.cs
  35. 25 0
      Shared.Rcl/Commands/Services/Labels/ServiceLabelAddCommand.cs
  36. 24 0
      Shared.Rcl/Commands/Services/Labels/ServiceLabelRemoveCommand.cs
  37. 3 0
      Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs
  38. 25 0
      Shared.Rcl/Commands/Switches/Labels/SwitchLabelAddCommand.cs
  39. 24 0
      Shared.Rcl/Commands/Switches/Labels/SwitchLabelRemoveCommand.cs
  40. 3 0
      Shared.Rcl/Commands/Switches/SwitchDescribeCommand.cs
  41. 25 0
      Shared.Rcl/Commands/Systems/Labels/SystemLabelAddCommand.cs
  42. 24 0
      Shared.Rcl/Commands/Systems/Labels/SystemLabelRemoveCommand.cs
  43. 3 0
      Shared.Rcl/Commands/Systems/SystemDescribeCommand.cs
  44. 25 0
      Shared.Rcl/Commands/Ups/Labels/UpsLabelAddCommand.cs
  45. 24 0
      Shared.Rcl/Commands/Ups/Labels/UpsLabelRemoveCommand.cs
  46. 3 0
      Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs
  47. 118 0
      Shared.Rcl/Components/ResourceLabelEditor.razor
  48. 4 1
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  49. 4 1
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  50. 4 1
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  51. 190 0
      Shared.Rcl/Modals/KeyValueModal.razor
  52. 4 1
      Shared.Rcl/Routers/RouterCardComponent.razor
  53. 4 1
      Shared.Rcl/Servers/ServerCardComponent.razor
  54. 4 1
      Shared.Rcl/Services/ServiceCardComponent.razor
  55. 4 1
      Shared.Rcl/Switches/SwitchCardComponent.razor
  56. 4 1
      Shared.Rcl/Systems/SystemCardComponent.razor
  57. 4 1
      Shared.Rcl/Ups/UpsCardComponent.razor
  58. 70 0
      Tests.E2e/PageObjectModels/LabelsPom.cs
  59. 1 0
      Tests.E2e/PageObjectModels/ServerCardPom.cs
  60. 105 1
      Tests.E2e/ServerCardTests.cs
  61. 26 0
      Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs
  62. 110 0
      Tests/Yaml/LabelsYamlTests.cs
  63. 13 0
      justfile
  64. 2 0
      openspec/changes/archive/2026-02-24-add-key-value-labels/.openspec.yaml
  65. 90 0
      openspec/changes/archive/2026-02-24-add-key-value-labels/design.md
  66. 29 0
      openspec/changes/archive/2026-02-24-add-key-value-labels/proposal.md
  67. 130 0
      openspec/changes/archive/2026-02-24-add-key-value-labels/specs/resource-labels/spec.md
  68. 44 0
      openspec/changes/archive/2026-02-24-add-key-value-labels/tasks.md
  69. 132 0
      openspec/specs/resource-labels/spec.md

+ 10 - 0
RackPeek.Domain/Helpers/Normalize.cs

@@ -41,4 +41,14 @@ public static class Normalize
     {
     {
         return name.Trim();
         return name.Trim();
     }
     }
+
+    public static string LabelKey(string key)
+    {
+        return key.Trim();
+    }
+
+    public static string LabelValue(string value)
+    {
+        return value.Trim();
+    }
 }
 }

+ 12 - 0
RackPeek.Domain/Helpers/ThrowIfInvalid.cs

@@ -13,6 +13,18 @@ public static class ThrowIfInvalid
         if (name.Length > 50) throw new ValidationException("Name is too long.");
         if (name.Length > 50) throw new ValidationException("Name is too long.");
     }
     }
 
 
+    public static void LabelKey(string key)
+    {
+        if (string.IsNullOrWhiteSpace(key)) throw new ValidationException("Label key is required.");
+        if (key.Length > 50) throw new ValidationException("Label key is too long.");
+    }
+
+    public static void LabelValue(string value)
+    {
+        if (string.IsNullOrWhiteSpace(value)) throw new ValidationException("Label value is required.");
+        if (value.Length > 200) throw new ValidationException("Label value is too long.");
+    }
+
     public static void AccessPointModelName(string name)
     public static void AccessPointModelName(string name)
     {
     {
         if (string.IsNullOrWhiteSpace(name))
         if (string.IsNullOrWhiteSpace(name))

+ 4 - 2
RackPeek.Domain/Resources/Desktops/DescribeDesktopUseCase.cs

@@ -10,7 +10,8 @@ public record DesktopDescription(
     string? RamSummary,
     string? RamSummary,
     int DriveCount,
     int DriveCount,
     int NicCount,
     int NicCount,
-    int GpuCount
+    int GpuCount,
+    Dictionary<string, string> Labels
 );
 );
 
 
 public class DescribeDesktopUseCase(IResourceCollection repository) : IUseCase
 public class DescribeDesktopUseCase(IResourceCollection repository) : IUseCase
@@ -35,7 +36,8 @@ public class DescribeDesktopUseCase(IResourceCollection repository) : IUseCase
             ramSummary,
             ramSummary,
             desktop.Drives?.Count ?? 0,
             desktop.Drives?.Count ?? 0,
             desktop.Nics?.Count ?? 0,
             desktop.Nics?.Count ?? 0,
-            desktop.Gpus?.Count ?? 0
+            desktop.Gpus?.Count ?? 0,
+            desktop.Labels
         );
         );
     }
     }
 }
 }

+ 4 - 2
RackPeek.Domain/Resources/Firewalls/DescribeFirewallUseCase.cs

@@ -11,7 +11,8 @@ public record FirewallDescription(
     bool? Poe,
     bool? Poe,
     int TotalPorts,
     int TotalPorts,
     double TotalSpeedGb,
     double TotalSpeedGb,
-    string PortSummary
+    string PortSummary,
+    Dictionary<string, string> Labels
 );
 );
 
 
 public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase
 public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase
@@ -53,7 +54,8 @@ public class DescribeFirewallUseCase(IResourceCollection repository) : IUseCase
             firewallResource.Poe,
             firewallResource.Poe,
             totalPorts,
             totalPorts,
             totalSpeedGb,
             totalSpeedGb,
-            portSummary
+            portSummary,
+            firewallResource.Labels
         );
         );
     }
     }
 }
 }

+ 4 - 2
RackPeek.Domain/Resources/Laptops/DescribeLaptopUseCase.cs

@@ -23,7 +23,8 @@ public class DescribeLaptopUseCase(IResourceCollection repository) : IUseCase
             laptop.Cpus?.Count ?? 0,
             laptop.Cpus?.Count ?? 0,
             ramSummary,
             ramSummary,
             laptop.Drives?.Count ?? 0,
             laptop.Drives?.Count ?? 0,
-            laptop.Gpus?.Count ?? 0
+            laptop.Gpus?.Count ?? 0,
+            laptop.Labels
         );
         );
     }
     }
 }
 }
@@ -33,5 +34,6 @@ public record LaptopDescription(
     int CpuCount,
     int CpuCount,
     string? RamSummary,
     string? RamSummary,
     int DriveCount,
     int DriveCount,
-    int GpuCount
+    int GpuCount,
+    Dictionary<string, string> Labels
 );
 );

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

@@ -48,6 +48,7 @@ public abstract class Resource
     public required string Name { get; set; }
     public required string Name { get; set; }
 
 
     public string[] Tags { get; set; } = [];
     public string[] Tags { get; set; } = [];
+    public Dictionary<string, string> Labels { get; set; } = new();
     public string? Notes { get; set; }
     public string? Notes { get; set; }
 
 
     public string? RunsOn { get; set; }
     public string? RunsOn { get; set; }

+ 4 - 2
RackPeek.Domain/Resources/Routers/DescribeRouterUseCase.cs

@@ -11,7 +11,8 @@ public record RouterDescription(
     bool? Poe,
     bool? Poe,
     int TotalPorts,
     int TotalPorts,
     double TotalSpeedGb,
     double TotalSpeedGb,
-    string PortSummary
+    string PortSummary,
+    Dictionary<string, string> Labels
 );
 );
 
 
 public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase
 public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase
@@ -53,7 +54,8 @@ public class DescribeRouterUseCase(IResourceCollection repository) : IUseCase
             routerResource.Poe,
             routerResource.Poe,
             totalPorts,
             totalPorts,
             totalSpeedGb,
             totalSpeedGb,
-            portSummary
+            portSummary,
+            routerResource.Labels
         );
         );
     }
     }
 }
 }

+ 4 - 2
RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs

@@ -11,7 +11,8 @@ public record ServiceDescription(
     string? Protocol,
     string? Protocol,
     string? Url,
     string? Url,
     string? RunsOnSystemHost,
     string? RunsOnSystemHost,
-    string? RunsOnPhysicalHost
+    string? RunsOnPhysicalHost,
+    Dictionary<string, string> Labels
 );
 );
 
 
 public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
 public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
@@ -38,7 +39,8 @@ public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
             service.Network?.Protocol,
             service.Network?.Protocol,
             service.Network?.Url,
             service.Network?.Url,
             service.RunsOn,
             service.RunsOn,
-            runsOnPhysicalHost
+            runsOnPhysicalHost,
+            service.Labels
         );
         );
     }
     }
 }
 }

+ 4 - 2
RackPeek.Domain/Resources/Switches/DescribeSwitchUseCase.cs

@@ -11,7 +11,8 @@ public record SwitchDescription(
     bool? Poe,
     bool? Poe,
     int TotalPorts,
     int TotalPorts,
     double TotalSpeedGb,
     double TotalSpeedGb,
-    string PortSummary
+    string PortSummary,
+    Dictionary<string, string> Labels
 );
 );
 
 
 public class DescribeSwitchUseCase(IResourceCollection repository) : IUseCase
 public class DescribeSwitchUseCase(IResourceCollection repository) : IUseCase
@@ -53,7 +54,8 @@ public class DescribeSwitchUseCase(IResourceCollection repository) : IUseCase
             switchResource.Poe,
             switchResource.Poe,
             totalPorts,
             totalPorts,
             totalSpeedGb,
             totalSpeedGb,
-            portSummary
+            portSummary,
+            switchResource.Labels
         );
         );
     }
     }
 }
 }

+ 4 - 2
RackPeek.Domain/Resources/SystemResources/UseCases/DescribeSystemUseCase.cs

@@ -10,7 +10,8 @@ public record SystemDescription(
     int Cores,
     int Cores,
     double RamGb,
     double RamGb,
     int TotalStorageGb,
     int TotalStorageGb,
-    string? RunsOn
+    string? RunsOn,
+    Dictionary<string, string> Labels
 );
 );
 
 
 public class DescribeSystemUseCase(IResourceCollection repository) : IUseCase
 public class DescribeSystemUseCase(IResourceCollection repository) : IUseCase
@@ -30,7 +31,8 @@ public class DescribeSystemUseCase(IResourceCollection repository) : IUseCase
             system.Cores ?? 0,
             system.Cores ?? 0,
             system.Ram ?? 0,
             system.Ram ?? 0,
             system.Drives?.Sum(d => d.Size) ?? 0,
             system.Drives?.Sum(d => d.Size) ?? 0,
-            system.RunsOn
+            system.RunsOn,
+            system.Labels
         );
         );
     }
     }
 }
 }

+ 4 - 2
RackPeek.Domain/Resources/UpsUnits/DescribeUpsUseCase.cs

@@ -6,7 +6,8 @@ namespace RackPeek.Domain.Resources.UpsUnits;
 public record UpsDescription(
 public record UpsDescription(
     string Name,
     string Name,
     string? Model,
     string? Model,
-    int? Va
+    int? Va,
+    Dictionary<string, string> Labels
 );
 );
 
 
 public class DescribeUpsUseCase(IResourceCollection repository) : IUseCase
 public class DescribeUpsUseCase(IResourceCollection repository) : IUseCase
@@ -23,7 +24,8 @@ public class DescribeUpsUseCase(IResourceCollection repository) : IUseCase
         return new UpsDescription(
         return new UpsDescription(
             ups.Name,
             ups.Name,
             ups.Model,
             ups.Model,
-            ups.Va
+            ups.Va,
+            ups.Labels
         );
         );
     }
     }
 }
 }

+ 3 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -10,6 +10,7 @@ using RackPeek.Domain.UseCases.Cpus;
 using RackPeek.Domain.UseCases.Drives;
 using RackPeek.Domain.UseCases.Drives;
 using RackPeek.Domain.UseCases.Gpus;
 using RackPeek.Domain.UseCases.Gpus;
 using RackPeek.Domain.UseCases.Nics;
 using RackPeek.Domain.UseCases.Nics;
+using RackPeek.Domain.UseCases.Labels;
 using RackPeek.Domain.UseCases.Ports;
 using RackPeek.Domain.UseCases.Ports;
 using RackPeek.Domain.UseCases.Tags;
 using RackPeek.Domain.UseCases.Tags;
 
 
@@ -48,9 +49,11 @@ public static class ServiceCollectionExtensions
         this IServiceCollection services)
         this IServiceCollection services)
     {
     {
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
         services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
+        services.AddScoped(typeof(IAddLabelUseCase<>), typeof(AddLabelUseCase<>));
         services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>));
         services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>));
         services.AddScoped(typeof(ICloneResourceUseCase<>), typeof(CloneResourceUseCase<>));
         services.AddScoped(typeof(ICloneResourceUseCase<>), typeof(CloneResourceUseCase<>));
         services.AddScoped(typeof(IDeleteResourceUseCase<>), typeof(DeleteResourceUseCase<>));
         services.AddScoped(typeof(IDeleteResourceUseCase<>), typeof(DeleteResourceUseCase<>));
+        services.AddScoped(typeof(IRemoveLabelUseCase<>), typeof(RemoveLabelUseCase<>));
         services.AddScoped(typeof(IRemoveTagUseCase<>), typeof(RemoveTagUseCase<>));
         services.AddScoped(typeof(IRemoveTagUseCase<>), typeof(RemoveTagUseCase<>));
         services.AddScoped(typeof(IGetAllResourcesByKindUseCase<>), typeof(GetAllResourcesByKindUseCase<>));
         services.AddScoped(typeof(IGetAllResourcesByKindUseCase<>), typeof(GetAllResourcesByKindUseCase<>));
         services.AddScoped(typeof(IGetResourceByNameUseCase<>), typeof(GetResourceByNameUseCase<>));
         services.AddScoped(typeof(IGetResourceByNameUseCase<>), typeof(GetResourceByNameUseCase<>));

+ 49 - 0
RackPeek.Domain/UseCases/Labels/AddLabelUseCase.cs

@@ -0,0 +1,49 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+
+namespace RackPeek.Domain.UseCases.Labels;
+
+/// <summary>
+/// Adds or updates a key-value label on a resource.
+/// </summary>
+public interface IAddLabelUseCase<T> : IResourceUseCase<T>
+    where T : Resource
+{
+    /// <summary>
+    /// Adds or overwrites a label on the resource. If the key already exists, the value is updated.
+    /// </summary>
+    /// <param name="name">Resource name.</param>
+    /// <param name="key">Label key.</param>
+    /// <param name="value">Label value.</param>
+    /// <exception cref="NotFoundException">Thrown when the resource does not exist.</exception>
+    /// <exception cref="ValidationException">Thrown when key or value fails validation.</exception>
+    Task ExecuteAsync(string name, string key, string value);
+}
+
+/// <summary>
+/// Adds or updates a key-value label on a resource.
+/// </summary>
+public class AddLabelUseCase<T>(IResourceCollection repo) : IAddLabelUseCase<T>
+    where T : Resource
+{
+    /// <inheritdoc />
+    public async Task ExecuteAsync(string name, string key, string value)
+    {
+        key = Normalize.LabelKey(key);
+        value = Normalize.LabelValue(value);
+
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+        ThrowIfInvalid.LabelKey(key);
+        ThrowIfInvalid.LabelValue(value);
+
+        var resource = await repo.GetByNameAsync(name);
+        if (resource is null)
+            throw new NotFoundException($"Resource '{name}' not found.");
+
+        resource.Labels[key] = value;
+
+        await repo.UpdateAsync(resource);
+    }
+}

+ 47 - 0
RackPeek.Domain/UseCases/Labels/RemoveLabelUseCase.cs

@@ -0,0 +1,47 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+
+namespace RackPeek.Domain.UseCases.Labels;
+
+/// <summary>
+/// Removes a label by key from a resource.
+/// </summary>
+public interface IRemoveLabelUseCase<T> : IResourceUseCase<T>
+    where T : Resource
+{
+    /// <summary>
+    /// Removes the label with the given key. No-op if the key does not exist.
+    /// </summary>
+    /// <param name="name">Resource name.</param>
+    /// <param name="key">Label key to remove.</param>
+    /// <exception cref="NotFoundException">Thrown when the resource does not exist.</exception>
+    /// <exception cref="ValidationException">Thrown when key fails validation.</exception>
+    Task ExecuteAsync(string name, string key);
+}
+
+/// <summary>
+/// Removes a label by key from a resource.
+/// </summary>
+public class RemoveLabelUseCase<T>(IResourceCollection repo) : IRemoveLabelUseCase<T>
+    where T : Resource
+{
+    /// <inheritdoc />
+    public async Task ExecuteAsync(string name, string key)
+    {
+        key = Normalize.LabelKey(key);
+
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+        ThrowIfInvalid.LabelKey(key);
+
+        var resource = await repo.GetByNameAsync(name);
+        if (resource is null)
+            throw new NotFoundException($"Resource '{name}' not found.");
+
+        if (!resource.Labels.Remove(key))
+            return;
+
+        await repo.UpdateAsync(resource);
+    }
+}

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

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.AccessPoints
+@using RackPeek.Domain.Resources.AccessPoints
 @inject UpdateAccessPointUseCase UpdateUseCase
 @inject UpdateAccessPointUseCase UpdateUseCase
 @inject IDeleteResourceUseCase<AccessPoint> DeleteUseCase
 @inject IDeleteResourceUseCase<AccessPoint> DeleteUseCase
 @inject IRenameResourceUseCase<AccessPoint> RenameUseCase
 @inject IRenameResourceUseCase<AccessPoint> RenameUseCase
@@ -107,6 +107,9 @@
         <ResourceTagEditor Resource="AccessPoint"
         <ResourceTagEditor Resource="AccessPoint"
                            TestIdPrefix="accesspoint" />
                            TestIdPrefix="accesspoint" />
 
 
+        <ResourceLabelEditor Resource="AccessPoint"
+                             TestIdPrefix="accesspoint" />
+
         <div class="md:col-span-2"
         <div class="md:col-span-2"
              data-testid="accesspoint-notes-section">
              data-testid="accesspoint-notes-section">
 
 

+ 83 - 0
Shared.Rcl/CliBootstrap.cs

@@ -7,29 +7,39 @@ using RackPeek.Domain.Persistence;
 using RackPeek.Domain.Persistence.Yaml;
 using RackPeek.Domain.Persistence.Yaml;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands;
 using Shared.Rcl.Commands.AccessPoints;
 using Shared.Rcl.Commands.AccessPoints;
+using Shared.Rcl.Commands.AccessPoints.Labels;
 using Shared.Rcl.Commands.Desktops;
 using Shared.Rcl.Commands.Desktops;
+using Shared.Rcl.Commands.Desktops.Labels;
 using Shared.Rcl.Commands.Desktops.Cpus;
 using Shared.Rcl.Commands.Desktops.Cpus;
 using Shared.Rcl.Commands.Desktops.Drive;
 using Shared.Rcl.Commands.Desktops.Drive;
 using Shared.Rcl.Commands.Desktops.Gpus;
 using Shared.Rcl.Commands.Desktops.Gpus;
 using Shared.Rcl.Commands.Desktops.Nics;
 using Shared.Rcl.Commands.Desktops.Nics;
 using Shared.Rcl.Commands.Firewalls;
 using Shared.Rcl.Commands.Firewalls;
+using Shared.Rcl.Commands.Firewalls.Labels;
 using Shared.Rcl.Commands.Firewalls.Ports;
 using Shared.Rcl.Commands.Firewalls.Ports;
 using Shared.Rcl.Commands.Laptops;
 using Shared.Rcl.Commands.Laptops;
+using Shared.Rcl.Commands.Laptops.Labels;
 using Shared.Rcl.Commands.Laptops.Cpus;
 using Shared.Rcl.Commands.Laptops.Cpus;
 using Shared.Rcl.Commands.Laptops.Drive;
 using Shared.Rcl.Commands.Laptops.Drive;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Laptops.Gpus;
 using Shared.Rcl.Commands.Routers;
 using Shared.Rcl.Commands.Routers;
+using Shared.Rcl.Commands.Routers.Labels;
 using Shared.Rcl.Commands.Routers.Ports;
 using Shared.Rcl.Commands.Routers.Ports;
 using Shared.Rcl.Commands.Servers;
 using Shared.Rcl.Commands.Servers;
 using Shared.Rcl.Commands.Servers.Cpus;
 using Shared.Rcl.Commands.Servers.Cpus;
 using Shared.Rcl.Commands.Servers.Drives;
 using Shared.Rcl.Commands.Servers.Drives;
 using Shared.Rcl.Commands.Servers.Gpus;
 using Shared.Rcl.Commands.Servers.Gpus;
+using Shared.Rcl.Commands.Servers.Labels;
 using Shared.Rcl.Commands.Servers.Nics;
 using Shared.Rcl.Commands.Servers.Nics;
 using Shared.Rcl.Commands.Services;
 using Shared.Rcl.Commands.Services;
+using Shared.Rcl.Commands.Services.Labels;
 using Shared.Rcl.Commands.Switches;
 using Shared.Rcl.Commands.Switches;
+using Shared.Rcl.Commands.Switches.Labels;
 using Shared.Rcl.Commands.Switches.Ports;
 using Shared.Rcl.Commands.Switches.Ports;
 using Shared.Rcl.Commands.Systems;
 using Shared.Rcl.Commands.Systems;
+using Shared.Rcl.Commands.Systems.Labels;
 using Shared.Rcl.Commands.Ups;
 using Shared.Rcl.Commands.Ups;
+using Shared.Rcl.Commands.Ups.Labels;
 using Spectre.Console;
 using Spectre.Console;
 using Spectre.Console.Cli;
 using Spectre.Console.Cli;
 
 
@@ -172,6 +182,16 @@ public static class CliBootstrap
 
 
                     nic.AddCommand<ServerNicRemoveCommand>("del").WithDescription("Remove a NIC from a server.");
                     nic.AddCommand<ServerNicRemoveCommand>("del").WithDescription("Remove a NIC from a server.");
                 });
                 });
+
+                // Server Labels
+                server.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a server.");
+
+                    label.AddCommand<ServerLabelAddCommand>("add").WithDescription("Add a label to a server.");
+
+                    label.AddCommand<ServerLabelRemoveCommand>("remove").WithDescription("Remove a label from a server.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -208,6 +228,13 @@ public static class CliBootstrap
 
 
                     port.AddCommand<SwitchPortRemoveCommand>("del").WithDescription("Remove a port from a switch.");
                     port.AddCommand<SwitchPortRemoveCommand>("del").WithDescription("Remove a port from a switch.");
                 });
                 });
+
+                switches.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a switch.");
+                    label.AddCommand<SwitchLabelAddCommand>("add").WithDescription("Add a label to a switch.");
+                    label.AddCommand<SwitchLabelRemoveCommand>("remove").WithDescription("Remove a label from a switch.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -244,6 +271,13 @@ public static class CliBootstrap
 
 
                     port.AddCommand<RouterPortRemoveCommand>("del").WithDescription("Remove a port from a router.");
                     port.AddCommand<RouterPortRemoveCommand>("del").WithDescription("Remove a port from a router.");
                 });
                 });
+
+                routers.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a router.");
+                    label.AddCommand<RouterLabelAddCommand>("add").WithDescription("Add a label to a router.");
+                    label.AddCommand<RouterLabelRemoveCommand>("remove").WithDescription("Remove a label from a router.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -280,6 +314,13 @@ public static class CliBootstrap
 
 
                     port.AddCommand<FirewallPortRemoveCommand>("del").WithDescription("Remove a port from a firewall.");
                     port.AddCommand<FirewallPortRemoveCommand>("del").WithDescription("Remove a port from a firewall.");
                 });
                 });
+
+                firewalls.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a firewall.");
+                    label.AddCommand<FirewallLabelAddCommand>("add").WithDescription("Add a label to a firewall.");
+                    label.AddCommand<FirewallLabelRemoveCommand>("remove").WithDescription("Remove a label from a firewall.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -307,6 +348,13 @@ public static class CliBootstrap
 
 
                 system.AddCommand<SystemTreeCommand>("tree")
                 system.AddCommand<SystemTreeCommand>("tree")
                     .WithDescription("Display the dependency tree for a system.");
                     .WithDescription("Display the dependency tree for a system.");
+
+                system.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a system.");
+                    label.AddCommand<SystemLabelAddCommand>("add").WithDescription("Add a label to a system.");
+                    label.AddCommand<SystemLabelRemoveCommand>("remove").WithDescription("Remove a label from a system.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -331,6 +379,13 @@ public static class CliBootstrap
                 ap.AddCommand<AccessPointSetCommand>("set").WithDescription("Update properties of an access point.");
                 ap.AddCommand<AccessPointSetCommand>("set").WithDescription("Update properties of an access point.");
 
 
                 ap.AddCommand<AccessPointDeleteCommand>("del").WithDescription("Delete an access point.");
                 ap.AddCommand<AccessPointDeleteCommand>("del").WithDescription("Delete an access point.");
+
+                ap.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on an access point.");
+                    label.AddCommand<AccessPointLabelAddCommand>("add").WithDescription("Add a label to an access point.");
+                    label.AddCommand<AccessPointLabelRemoveCommand>("remove").WithDescription("Remove a label from an access point.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -355,6 +410,13 @@ public static class CliBootstrap
                 ups.AddCommand<UpsSetCommand>("set").WithDescription("Update properties of a UPS unit.");
                 ups.AddCommand<UpsSetCommand>("set").WithDescription("Update properties of a UPS unit.");
 
 
                 ups.AddCommand<UpsDeleteCommand>("del").WithDescription("Delete a UPS unit.");
                 ups.AddCommand<UpsDeleteCommand>("del").WithDescription("Delete a UPS unit.");
+
+                ups.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a UPS unit.");
+                    label.AddCommand<UpsLabelAddCommand>("add").WithDescription("Add a label to a UPS unit.");
+                    label.AddCommand<UpsLabelRemoveCommand>("remove").WithDescription("Remove a label from a UPS unit.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -414,6 +476,13 @@ public static class CliBootstrap
                     nic.AddCommand<DesktopNicSetCommand>("set").WithDescription("Update a desktop NIC.");
                     nic.AddCommand<DesktopNicSetCommand>("set").WithDescription("Update a desktop NIC.");
                     nic.AddCommand<DesktopNicRemoveCommand>("del").WithDescription("Remove a NIC from a desktop.");
                     nic.AddCommand<DesktopNicRemoveCommand>("del").WithDescription("Remove a NIC from a desktop.");
                 });
                 });
+
+                desktops.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a desktop.");
+                    label.AddCommand<DesktopLabelAddCommand>("add").WithDescription("Add a label to a desktop.");
+                    label.AddCommand<DesktopLabelRemoveCommand>("remove").WithDescription("Remove a label from a desktop.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -462,6 +531,13 @@ public static class CliBootstrap
                     gpu.AddCommand<LaptopGpuSetCommand>("set").WithDescription("Update a Laptop GPU.");
                     gpu.AddCommand<LaptopGpuSetCommand>("set").WithDescription("Update a Laptop GPU.");
                     gpu.AddCommand<LaptopGpuRemoveCommand>("del").WithDescription("Remove a GPU from a Laptop.");
                     gpu.AddCommand<LaptopGpuRemoveCommand>("del").WithDescription("Remove a GPU from a Laptop.");
                 });
                 });
+
+                laptops.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a laptop.");
+                    label.AddCommand<LaptopLabelAddCommand>("add").WithDescription("Add a label to a laptop.");
+                    label.AddCommand<LaptopLabelRemoveCommand>("remove").WithDescription("Remove a label from a laptop.");
+                });
             });
             });
 
 
             // ----------------------------
             // ----------------------------
@@ -489,6 +565,13 @@ public static class CliBootstrap
 
 
                 service.AddCommand<ServiceSubnetsCommand>("subnets")
                 service.AddCommand<ServiceSubnetsCommand>("subnets")
                     .WithDescription("List subnets associated with a service, optionally filtered by CIDR.");
                     .WithDescription("List subnets associated with a service, optionally filtered by CIDR.");
+
+                service.AddBranch("label", label =>
+                {
+                    label.SetDescription("Manage labels on a service.");
+                    label.AddCommand<ServiceLabelAddCommand>("add").WithDescription("Add a label to a service.");
+                    label.AddCommand<ServiceLabelRemoveCommand>("remove").WithDescription("Remove a label from a service.");
+                });
             });
             });
         });
         });
     }
     }

+ 3 - 0
Shared.Rcl/Commands/AccessPoints/AccessPointDescribeCommand.cs

@@ -28,6 +28,9 @@ public class AccessPointDescribeCommand(
         grid.AddRow("Model:", ap.Model ?? "Unknown");
         grid.AddRow("Model:", ap.Model ?? "Unknown");
         grid.AddRow("Speed (Gbps):", ap.Speed?.ToString() ?? "Unknown");
         grid.AddRow("Speed (Gbps):", ap.Speed?.ToString() ?? "Unknown");
 
 
+        if (ap.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", ap.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)
                 .Header("Access Point")
                 .Header("Access Point")

+ 25 - 0
Shared.Rcl/Commands/AccessPoints/Labels/AccessPointLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/AccessPoints/Labels/AccessPointLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Desktops/DesktopDescribeCommand.cs

@@ -28,6 +28,9 @@ public class DesktopDescribeCommand(IServiceProvider provider)
         grid.AddRow("NICs:", result.NicCount.ToString());
         grid.AddRow("NICs:", result.NicCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
 
 
+        if (result.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(new Panel(grid).Header("Desktop").Border(BoxBorder.Rounded));
         AnsiConsole.Write(new Panel(grid).Header("Desktop").Border(BoxBorder.Rounded));
 
 
         return 0;
         return 0;

+ 25 - 0
Shared.Rcl/Commands/Desktops/Labels/DesktopLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Desktops/Labels/DesktopLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Firewalls/FirewallDescribeCommand.cs

@@ -31,6 +31,9 @@ public class FirewallDescribeCommand(
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Ports:", sw.PortSummary);
         grid.AddRow("Ports:", sw.PortSummary);
 
 
+        if (sw.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)
                 .Header("Firewall")
                 .Header("Firewall")

+ 25 - 0
Shared.Rcl/Commands/Firewalls/Labels/FirewallLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Firewalls/Labels/FirewallLabelRemoveCommand.cs

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

+ 25 - 0
Shared.Rcl/Commands/Laptops/Labels/LaptopLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Laptops/Labels/LaptopLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Laptops/LaptopDescribeCommand.cs

@@ -26,6 +26,9 @@ public class LaptopDescribeCommand(IServiceProvider provider)
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("Drives:", result.DriveCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
         grid.AddRow("GPUs:", result.GpuCount.ToString());
 
 
+        if (result.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", result.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(new Panel(grid).Header("Laptop").Border(BoxBorder.Rounded));
         AnsiConsole.Write(new Panel(grid).Header("Laptop").Border(BoxBorder.Rounded));
 
 
         return 0;
         return 0;

+ 25 - 0
Shared.Rcl/Commands/Routers/Labels/RouterLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Routers/Labels/RouterLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Routers/RouterDescribeCommand.cs

@@ -31,6 +31,9 @@ public class RouterDescribeCommand(
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Ports:", sw.PortSummary);
         grid.AddRow("Ports:", sw.PortSummary);
 
 
+        if (sw.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)
                 .Header("Router")
                 .Header("Router")

+ 34 - 0
Shared.Rcl/Commands/Servers/Labels/ServerLabelAddCommand.cs

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

+ 31 - 0
Shared.Rcl/Commands/Servers/Labels/ServerLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Servers/ServerDescribeCommand.cs

@@ -32,6 +32,9 @@ public class ServerDescribeCommand(
             foreach (var cpu in server.Cpus)
             foreach (var cpu in server.Cpus)
                 grid.AddRow("CPU", $"{cpu.Model} ({cpu.Cores}/{cpu.Threads})");
                 grid.AddRow("CPU", $"{cpu.Model} ({cpu.Cores}/{cpu.Threads})");
 
 
+        if (server.Labels.Count > 0)
+            grid.AddRow("Labels", string.Join(", ", server.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)
                 .Header("Server")
                 .Header("Server")

+ 25 - 0
Shared.Rcl/Commands/Services/Labels/ServiceLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Services/Labels/ServiceLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs

@@ -35,6 +35,9 @@ public class ServiceDescribeCommand(
         grid.AddRow("Runs On:",
         grid.AddRow("Runs On:",
             ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost));
             ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost));
 
 
+        if (service.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", service.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)
                 .Header("Service")
                 .Header("Service")

+ 25 - 0
Shared.Rcl/Commands/Switches/Labels/SwitchLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Switches/Labels/SwitchLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Switches/SwitchDescribeCommand.cs

@@ -31,6 +31,9 @@ public class SwitchDescribeCommand(
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Total Speed (Gb):", sw.TotalSpeedGb.ToString());
         grid.AddRow("Ports:", sw.PortSummary);
         grid.AddRow("Ports:", sw.PortSummary);
 
 
+        if (sw.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", sw.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)
                 .Header("Switch")
                 .Header("Switch")

+ 25 - 0
Shared.Rcl/Commands/Systems/Labels/SystemLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Systems/Labels/SystemLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Systems/SystemDescribeCommand.cs

@@ -31,6 +31,9 @@ public class SystemDescribeCommand(
         grid.AddRow("Total Storage (GB):", system.TotalStorageGb.ToString());
         grid.AddRow("Total Storage (GB):", system.TotalStorageGb.ToString());
         grid.AddRow("Runs On:", system.RunsOn ?? "Unknown");
         grid.AddRow("Runs On:", system.RunsOn ?? "Unknown");
 
 
+        if (system.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", system.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(
         AnsiConsole.Write(
             new Panel(grid)
             new Panel(grid)
                 .Header("System")
                 .Header("System")

+ 25 - 0
Shared.Rcl/Commands/Ups/Labels/UpsLabelAddCommand.cs

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

+ 24 - 0
Shared.Rcl/Commands/Ups/Labels/UpsLabelRemoveCommand.cs

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

+ 3 - 0
Shared.Rcl/Commands/Ups/UpsDescribeCommand.cs

@@ -26,6 +26,9 @@ public class UpsDescribeCommand(IServiceProvider provider)
         grid.AddRow("Model:", ups.Model ?? "Unknown");
         grid.AddRow("Model:", ups.Model ?? "Unknown");
         grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown");
         grid.AddRow("VA:", ups.Va?.ToString() ?? "Unknown");
 
 
+        if (ups.Labels.Count > 0)
+            grid.AddRow("Labels:", string.Join(", ", ups.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
+
         AnsiConsole.Write(new Panel(grid).Header("UPS").Border(BoxBorder.Rounded));
         AnsiConsole.Write(new Panel(grid).Header("UPS").Border(BoxBorder.Rounded));
 
 
         return 0;
         return 0;

+ 118 - 0
Shared.Rcl/Components/ResourceLabelEditor.razor

@@ -0,0 +1,118 @@
+@using RackPeek.Domain.UseCases.Labels
+@typeparam TResource where TResource : RackPeek.Domain.Resources.Resource
+@inject IAddLabelUseCase<TResource> AddLabelUseCase
+@inject IRemoveLabelUseCase<TResource> RemoveLabelUseCase
+
+<div class="md:col-span-2"
+     data-testid="@BaseTestId">
+
+    <div class="flex items-center justify-between mb-1 group"
+         data-testid="@($"{BaseTestId}-header")">
+
+        <div class="text-zinc-400">
+            Labels
+            <button class="hover:text-emerald-400 ml-1"
+                    title="Add Label"
+                    data-testid="@($"{BaseTestId}-add")"
+                    @onclick="OpenAddLabel">
+                +
+            </button>
+        </div>
+    </div>
+
+    @if (Resource.Labels.Any())
+    {
+        <div class="flex flex-wrap gap-2"
+             data-testid="@($"{BaseTestId}-list")">
+
+            @foreach (var kvp in Resource.Labels.OrderBy(k => k.Key))
+            {
+                <div class="flex text-xs rounded overflow-hidden border border-zinc-700"
+                     data-testid="@($"{BaseTestId}-label-{kvp.Key}")">
+
+                    <button type="button"
+                            class="px-2 py-0.5 bg-zinc-800 text-zinc-300 hover:bg-emerald-800 hover:text-emerald-200 transition text-left"
+                            title="Edit label"
+                            data-testid="@($"{BaseTestId}-label-{kvp.Key}-view")"
+                            @onclick="() => OpenEditLabel(kvp.Key, kvp.Value)">
+                        @kvp.Key: @kvp.Value
+                    </button>
+
+                    <button type="button"
+                            class="px-1 py-0.5 bg-zinc-800 text-zinc-400 hover:bg-red-800 hover:text-red-200 transition border-l border-zinc-700"
+                            title="Remove label"
+                            data-testid="@($"{BaseTestId}-label-{kvp.Key}-remove")"
+                            @onclick="() => RemoveLabel(kvp.Key)">
+                        ✕
+                    </button>
+
+                </div>
+            }
+        </div>
+    }
+
+</div>
+
+<KeyValueModal
+    IsOpen="_labelModalOpen"
+    IsOpenChanged="v => { _labelModalOpen = v; if (!v) { _editingKey = null; _editingValue = null; } }"
+    Title="@(_editingKey is null ? "Add Label" : "Edit Label")"
+    Description="@(_editingKey is null ? "Enter a key and value for the label" : "Update the key and value for the label")"
+    KeyLabel="Key"
+    ValueLabel="Value"
+    Key="@_editingKey"
+    Value="@_editingValue"
+    TestIdPrefix="@BaseTestId"
+    OnSubmit="HandleLabelSubmit" />
+
+@code {
+    [Parameter][EditorRequired] public TResource Resource { get; set; } = default!;
+    [Parameter] public EventCallback OnLabelsChanged { get; set; }
+    [Parameter] public string? TestIdPrefix { get; set; }
+
+    private bool _labelModalOpen;
+    private string? _editingKey;
+    private string? _editingValue;
+
+    private string BaseTestId =>
+        string.IsNullOrWhiteSpace(TestIdPrefix)
+            ? "resource-label-editor"
+            : $"{TestIdPrefix}-resource-label-editor";
+
+    void OpenAddLabel()
+    {
+        _editingKey = null;
+        _editingValue = null;
+        _labelModalOpen = true;
+    }
+
+    void OpenEditLabel(string key, string value)
+    {
+        _editingKey = key;
+        _editingValue = value;
+        _labelModalOpen = true;
+    }
+
+    public async Task HandleLabelSubmit((string Key, string Value) label)
+    {
+        var originalKey = _editingKey;
+        _editingKey = null;
+        _editingValue = null;
+
+        if (originalKey is not null && originalKey != label.Key)
+            await RemoveLabelUseCase.ExecuteAsync(Resource.Name, originalKey);
+
+        await AddLabelUseCase.ExecuteAsync(Resource.Name, label.Key, label.Value);
+
+        if (OnLabelsChanged.HasDelegate)
+            await OnLabelsChanged.InvokeAsync();
+    }
+
+    async Task RemoveLabel(string key)
+    {
+        await RemoveLabelUseCase.ExecuteAsync(Resource.Name, key);
+
+        if (OnLabelsChanged.HasDelegate)
+            await OnLabelsChanged.InvokeAsync();
+    }
+}

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

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.Desktops
+@using RackPeek.Domain.Resources.Desktops
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Drives
@@ -207,6 +207,9 @@
         <ResourceTagEditor Resource="Desktop"
         <ResourceTagEditor Resource="Desktop"
                            TestIdPrefix="desktop" />
                            TestIdPrefix="desktop" />
 
 
+        <ResourceLabelEditor Resource="Desktop"
+                             TestIdPrefix="desktop" />
+
 
 
     </div>
     </div>
 
 

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

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.Firewalls
+@using RackPeek.Domain.Resources.Firewalls
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.UseCases.Ports
 @using RackPeek.Domain.UseCases.Ports
 @inject UpdateFirewallUseCase UpdateUseCase
 @inject UpdateFirewallUseCase UpdateUseCase
@@ -165,6 +165,9 @@
         <ResourceTagEditor Resource="Firewall" 
         <ResourceTagEditor Resource="Firewall" 
                            TestIdPrefix="firewall" />
                            TestIdPrefix="firewall" />
 
 
+        <ResourceLabelEditor Resource="Firewall"
+                             TestIdPrefix="firewall" />
+
 
 
         <div class="md:col-span-2"
         <div class="md:col-span-2"
              data-testid="firewall-notes-section">
              data-testid="firewall-notes-section">

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

@@ -1,4 +1,4 @@
-
+
 @using RackPeek.Domain.Resources.Laptops
 @using RackPeek.Domain.Resources.Laptops
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Cpus
@@ -178,6 +178,9 @@
     <ResourceTagEditor Resource="Laptop" 
     <ResourceTagEditor Resource="Laptop" 
                        TestIdPrefix="laptop" />
                        TestIdPrefix="laptop" />
 
 
+    <ResourceLabelEditor Resource="Laptop"
+                         TestIdPrefix="laptop" />
+
 
 
     <div class="md:col-span-2"
     <div class="md:col-span-2"
          data-testid="laptop-notes-section">
          data-testid="laptop-notes-section">

+ 190 - 0
Shared.Rcl/Modals/KeyValueModal.razor

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

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

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.Routers
+@using RackPeek.Domain.Resources.Routers
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.UseCases.Ports
 @using RackPeek.Domain.UseCases.Ports
 @using Router = RackPeek.Domain.Resources.Routers.Router
 @using Router = RackPeek.Domain.Resources.Routers.Router
@@ -167,6 +167,9 @@
         <ResourceTagEditor Resource="Router" 
         <ResourceTagEditor Resource="Router" 
                            TestIdPrefix="router" />
                            TestIdPrefix="router" />
 
 
+        <ResourceLabelEditor Resource="Router"
+                             TestIdPrefix="router" />
+
 
 
         <div class="md:col-span-2"
         <div class="md:col-span-2"
              data-testid="router-notes-section">
              data-testid="router-notes-section">

+ 4 - 1
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.Servers
+@using RackPeek.Domain.Resources.Servers
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Cpus
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Drives
@@ -234,6 +234,9 @@
     <ResourceTagEditor Resource="Server"
     <ResourceTagEditor Resource="Server"
                        TestIdPrefix="server" />
                        TestIdPrefix="server" />
 
 
+    <ResourceLabelEditor Resource="Server"
+                         TestIdPrefix="server" />
+
 
 
     <div class="md:col-span-2">
     <div class="md:col-span-2">
         <div class="text-zinc-400 mb-1">Notes</div>
         <div class="text-zinc-400 mb-1">Notes</div>

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

@@ -1,4 +1,4 @@
-@inject UpdateServiceUseCase UpdateUseCase
+@inject UpdateServiceUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Service> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Service> GetByNameUseCase
 @inject IDeleteResourceUseCase<Service> DeleteUseCase
 @inject IDeleteResourceUseCase<Service> DeleteUseCase
 @inject ICloneResourceUseCase<Service> CloneUseCase
 @inject ICloneResourceUseCase<Service> CloneUseCase
@@ -191,6 +191,9 @@
         <ResourceTagEditor Resource="Service"
         <ResourceTagEditor Resource="Service"
                            TestIdPrefix="service" />
                            TestIdPrefix="service" />
 
 
+        <ResourceLabelEditor Resource="Service"
+                             TestIdPrefix="service" />
+
 
 
         <div class="md:col-span-2">
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
             <div class="text-zinc-400 mb-1">Notes</div>

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

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.SubResources
+@using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.Switches
 @using RackPeek.Domain.Resources.Switches
 @using RackPeek.Domain.UseCases.Ports
 @using RackPeek.Domain.UseCases.Ports
 @inject UpdateSwitchUseCase UpdateUseCase
 @inject UpdateSwitchUseCase UpdateUseCase
@@ -165,6 +165,9 @@
         <ResourceTagEditor Resource="Switch" 
         <ResourceTagEditor Resource="Switch" 
                            TestIdPrefix="switch" />
                            TestIdPrefix="switch" />
 
 
+        <ResourceLabelEditor Resource="Switch"
+                             TestIdPrefix="switch" />
+
 
 
         <div class="md:col-span-2"
         <div class="md:col-span-2"
              data-testid="switch-notes-section">
              data-testid="switch-notes-section">

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

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.SubResources
+@using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @using RackPeek.Domain.UseCases.Drives
 @using RackPeek.Domain.UseCases.Drives
@@ -230,6 +230,9 @@
         <ResourceTagEditor Resource="System"
         <ResourceTagEditor Resource="System"
                            TestIdPrefix="system" />
                            TestIdPrefix="system" />
 
 
+        <ResourceLabelEditor Resource="System"
+                             TestIdPrefix="system" />
+
 
 
         <div class="md:col-span-2">
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
             <div class="text-zinc-400 mb-1">Notes</div>

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

@@ -1,4 +1,4 @@
-@using RackPeek.Domain.Resources.UpsUnits
+@using RackPeek.Domain.Resources.UpsUnits
 @inject UpdateUpsUseCase UpdateUseCase
 @inject UpdateUpsUseCase UpdateUseCase
 @inject IGetResourceByNameUseCase<Ups> GetByNameUseCase
 @inject IGetResourceByNameUseCase<Ups> GetByNameUseCase
 @inject IDeleteResourceUseCase<Ups> DeleteUseCase
 @inject IDeleteResourceUseCase<Ups> DeleteUseCase
@@ -107,6 +107,9 @@
         <ResourceTagEditor Resource="Ups" 
         <ResourceTagEditor Resource="Ups" 
                            TestIdPrefix="ups" />
                            TestIdPrefix="ups" />
 
 
+        <ResourceLabelEditor Resource="Ups"
+                             TestIdPrefix="ups" />
+
 
 
         <div class="md:col-span-2"
         <div class="md:col-span-2"
              data-testid="ups-notes-section">
              data-testid="ups-notes-section">

+ 70 - 0
Tests.E2e/PageObjectModels/LabelsPom.cs

@@ -0,0 +1,70 @@
+using Microsoft.Playwright;
+
+namespace Tests.E2e.PageObjectModels;
+
+public class LabelsPom(IPage page)
+{
+    public ILocator Root(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-label-editor");
+
+    public ILocator AddButton(string testIdPrefix)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-label-editor-add");
+
+    public ILocator Label(string testIdPrefix, string key)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-label-editor-label-{key}");
+
+    public ILocator LabelViewButton(string testIdPrefix, string key)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-label-editor-label-{key}-view");
+
+    public ILocator RemoveLabelButton(string testIdPrefix, string key)
+        => Root(testIdPrefix).GetByTestId($"{testIdPrefix}-resource-label-editor-label-{key}-remove");
+
+    public ILocator ModalKeyInput(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-label-editor-key-value-modal-key-input");
+
+    public ILocator ModalValueInput(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-label-editor-key-value-modal-value-input");
+
+    public ILocator ModalSubmit(string testIdPrefix)
+        => page.GetByTestId($"{testIdPrefix}-resource-label-editor-key-value-modal-submit");
+
+    public async Task AddLabelAsync(string testIdPrefix, string key, string value)
+    {
+        await AddButton(testIdPrefix).ClickAsync();
+        await Assertions.Expect(ModalKeyInput(testIdPrefix)).ToBeVisibleAsync();
+        await ModalKeyInput(testIdPrefix).FillAsync(key);
+        await ModalValueInput(testIdPrefix).FillAsync(value);
+        await ModalSubmit(testIdPrefix).ClickAsync();
+    }
+
+    public async Task EditLabelAsync(string testIdPrefix, string existingKey, string newKey, string newValue)
+    {
+        await LabelViewButton(testIdPrefix, existingKey).ClickAsync();
+        await Assertions.Expect(ModalKeyInput(testIdPrefix)).ToBeVisibleAsync();
+        await ModalKeyInput(testIdPrefix).FillAsync(newKey);
+        await ModalValueInput(testIdPrefix).FillAsync(newValue);
+        await ModalSubmit(testIdPrefix).ClickAsync();
+    }
+
+    public async Task RemoveLabelAsync(string testIdPrefix, string key)
+    {
+        await RemoveLabelButton(testIdPrefix, key).ClickAsync();
+    }
+
+    public async Task AssertLabelVisibleAsync(string testIdPrefix, string key)
+    {
+        await Assertions.Expect(Label(testIdPrefix, key)).ToBeVisibleAsync();
+    }
+
+    public async Task AssertLabelNotVisibleAsync(string testIdPrefix, string key)
+    {
+        await Assertions.Expect(Label(testIdPrefix, key)).Not.ToBeVisibleAsync();
+    }
+
+    public async Task AssertLabelDisplaysAsync(string testIdPrefix, string key, string expectedValue)
+    {
+        var locator = Label(testIdPrefix, key);
+        await Assertions.Expect(locator).ToBeVisibleAsync();
+        await Assertions.Expect(locator).ToContainTextAsync($"{key}: {expectedValue}");
+    }
+}

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

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

+ 105 - 1
Tests.E2e/ServerCardTests.cs

@@ -1,4 +1,4 @@
-using Tests.E2e.Infra;
+using Tests.E2e.Infra;
 using Tests.E2e.PageObjectModels;
 using Tests.E2e.PageObjectModels;
 using Xunit.Abstractions;
 using Xunit.Abstractions;
 
 
@@ -169,4 +169,108 @@ public class ServerCardTests(
             await context.CloseAsync();
             await context.CloseAsync();
         }
         }
     }
     }
+
+    [Fact]
+    public async Task User_Can_Add_And_Remove_Labels_From_Server_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-srv-lbl-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoServersListAsync();
+
+            var list = new ServersListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServerAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new ServerCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var labels = card.Labels;
+
+            await labels.AddLabelAsync("server", "env", "production");
+            await labels.AssertLabelVisibleAsync("server", "env");
+
+            await labels.AddLabelAsync("server", "owner", "team-a");
+            await labels.AssertLabelVisibleAsync("server", "owner");
+
+            await labels.RemoveLabelAsync("server", "owner");
+            await labels.AssertLabelNotVisibleAsync("server", "owner");
+            await labels.AssertLabelVisibleAsync("server", "env");
+
+            await page.ReloadAsync();
+            await labels.AssertLabelVisibleAsync("server", "env");
+            await labels.AssertLabelNotVisibleAsync("server", "owner");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
+
+    [Fact]
+    public async Task User_Can_Edit_Label_From_Server_Card()
+    {
+        var (context, page) = await CreatePageAsync();
+        var name = $"e2e-srv-edit-{Guid.NewGuid():N}"[..16];
+
+        try
+        {
+            await page.GotoAsync(fixture.BaseUrl);
+
+            var layout = new MainLayoutPom(page);
+            await layout.AssertLoadedAsync();
+            await layout.GotoHardwareAsync();
+
+            var hardwareTree = new HardwareTreePom(page);
+            await hardwareTree.AssertLoadedAsync();
+            await hardwareTree.GotoServersListAsync();
+
+            var list = new ServersListPom(page);
+            await list.AssertLoadedAsync();
+
+            await list.AddServerAsync(name);
+            await page.WaitForURLAsync($"**/resources/hardware/{name}");
+
+            var card = new ServerCardPom(page);
+            await card.AssertVisibleAsync(name);
+
+            var labels = card.Labels;
+
+            await labels.AddLabelAsync("server", "env", "production");
+            await labels.AssertLabelDisplaysAsync("server", "env", "production");
+
+            await labels.EditLabelAsync("server", "env", "env", "staging");
+            await labels.AssertLabelDisplaysAsync("server", "env", "staging");
+
+            await page.ReloadAsync();
+            await labels.AssertLabelDisplaysAsync("server", "env", "staging");
+
+            await labels.EditLabelAsync("server", "env", "environment", "staging");
+            await labels.AssertLabelNotVisibleAsync("server", "env");
+            await labels.AssertLabelDisplaysAsync("server", "environment", "staging");
+
+            await page.ReloadAsync();
+            await labels.AssertLabelDisplaysAsync("server", "environment", "staging");
+
+            await context.CloseAsync();
+        }
+        finally
+        {
+            await context.CloseAsync();
+        }
+    }
 }
 }

+ 26 - 0
Tests/EndToEnd/ServerTests/ServerWorkflowTests.cs

@@ -138,4 +138,30 @@ public class ServerWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
 
                      """, output);
                      """, output);
     }
     }
+    
+    [Fact]
+    public async Task servers_labels_cli_workflow_test()
+    {
+        await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+        // Create server
+        var (output, yaml) = await ExecuteAsync("servers", "add", "web-01");
+        Assert.Contains("web-01", yaml);
+
+        // Add label
+        (output, yaml) = await ExecuteAsync("servers", "label", "add", "web-01", "--key", "env", "--value", "production");
+        Assert.Contains("Label 'env' added", output);
+        Assert.Contains("env:", yaml);
+        Assert.Contains("production", yaml);
+
+        // Describe should show label
+        (output, _) = await ExecuteAsync("servers", "describe", "web-01");
+        Assert.Contains("env", output);
+        Assert.Contains("production", output);
+
+        // Remove label
+        (output, yaml) = await ExecuteAsync("servers", "label", "remove", "web-01", "--key", "env");
+        Assert.Contains("Label 'env' removed", output);
+        Assert.DoesNotContain("env:", yaml);
+    }
 }
 }

+ 110 - 0
Tests/Yaml/LabelsYamlTests.cs

@@ -0,0 +1,110 @@
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Persistence.Yaml;
+using RackPeek.Domain.Resources.Servers;
+
+namespace Tests.Yaml;
+
+/// <summary>
+/// Tests YAML serialization and deserialization of resource labels.
+/// </summary>
+public class LabelsYamlTests
+{
+    private static async Task<IResourceCollection> CreateSut(string yaml)
+    {
+        var tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "RackPeekTests",
+            Guid.NewGuid().ToString("N"));
+
+        Directory.CreateDirectory(tempDir);
+
+        var filePath = Path.Combine(tempDir, "config.yaml");
+        await File.WriteAllTextAsync(filePath, yaml);
+
+        var yamlResourceCollection =
+            new YamlResourceCollection(filePath, new PhysicalTextFileStore(), new ResourceCollection());
+        await yamlResourceCollection.LoadAsync();
+        return yamlResourceCollection;
+    }
+
+    [Fact]
+    public async Task deserialize_yaml_with_labels__resource_has_labels()
+    {
+        // Given
+        var yaml = @"
+resources:
+  - kind: Server
+    name: web-01
+    labels:
+      env: production
+      owner: team-a
+";
+
+        var sut = await CreateSut(yaml);
+
+        // When
+        var server = await sut.GetByNameAsync<Server>("web-01");
+
+        // Then
+        Assert.NotNull(server);
+        Assert.Equal(2, server.Labels.Count);
+        Assert.Equal("production", server.Labels["env"]);
+        Assert.Equal("team-a", server.Labels["owner"]);
+    }
+
+    [Fact]
+    public async Task deserialize_yaml_without_labels__resource_has_empty_labels()
+    {
+        // Given - legacy YAML without labels section
+        var yaml = @"
+resources:
+  - kind: Server
+    name: web-01
+";
+
+        var sut = await CreateSut(yaml);
+
+        // When
+        var server = await sut.GetByNameAsync<Server>("web-01");
+
+        // Then
+        Assert.NotNull(server);
+        Assert.NotNull(server.Labels);
+        Assert.Empty(server.Labels);
+    }
+
+    [Fact]
+    public async Task round_trip_labels__persisted_and_loaded()
+    {
+        // Given - add server with labels via collection, save, reload
+        var tempDir = Path.Combine(
+            Path.GetTempPath(),
+            "RackPeekTests",
+            Guid.NewGuid().ToString("N"));
+        Directory.CreateDirectory(tempDir);
+        var filePath = Path.Combine(tempDir, "config.yaml");
+        await File.WriteAllTextAsync(filePath, "");
+
+        var collection = new ResourceCollection();
+        var yamlCollection = new YamlResourceCollection(filePath, new PhysicalTextFileStore(), collection);
+        await yamlCollection.LoadAsync();
+
+        var server = new Server
+        {
+            Name = "web-01",
+            Labels = new Dictionary<string, string> { ["env"] = "production", ["owner"] = "team-a" }
+        };
+        await yamlCollection.AddAsync(server);
+
+        // When - reload from file
+        var reloaded = new YamlResourceCollection(filePath, new PhysicalTextFileStore(), new ResourceCollection());
+        await reloaded.LoadAsync();
+        var loaded = await reloaded.GetByNameAsync<Server>("web-01");
+
+        // Then
+        Assert.NotNull(loaded);
+        Assert.Equal(2, loaded.Labels.Count);
+        Assert.Equal("production", loaded.Labels["env"]);
+        Assert.Equal("team-a", loaded.Labels["owner"]);
+    }
+}

+ 13 - 0
justfile

@@ -105,6 +105,19 @@ docker-push version:
         -t aptacode/rackpeek:latest \
         -t aptacode/rackpeek:latest \
         --push .
         --push .
 
 
+# ─── Run ────────────────────────────────────────────────────────────────────
+
+[doc("Run the docker container")]
+[group("run")]
+run-docker: _check-dotnet
+    docker build -t {{ _image }} -f {{ _dockerfile }} .
+    docker run -d -p 8080:8080 {{ _image }}
+
+[doc("Use the locally built CLI")]
+[group("run")]
+rpk *args: _check-dotnet
+    ./RackPeek/bin/Debug/net10.0/RackPeek {{ args }}
+
 # ─── Utility ────────────────────────────────────────────────────────────────
 # ─── Utility ────────────────────────────────────────────────────────────────
 
 
 [doc("Clean build artifacts (bin, obj)")]
 [doc("Clean build artifacts (bin, obj)")]

+ 2 - 0
openspec/changes/archive/2026-02-24-add-key-value-labels/.openspec.yaml

@@ -0,0 +1,2 @@
+schema: spec-driven
+created: 2026-02-23

+ 90 - 0
openspec/changes/archive/2026-02-24-add-key-value-labels/design.md

@@ -0,0 +1,90 @@
+# Design: Add Key-Value Labels to Resources
+
+## Context
+
+RackPeek models IT infrastructure as a hierarchy of Resources (Hardware → System → Service). Resources currently have `Name`, `Tags` (string array), `Notes`, and `RunsOn`. Tags are single-value strings used for categorization (e.g., "production", "staging"); they are managed via `AddTagUseCase` / `RemoveTagUseCase` and displayed in `ResourceTagEditor`. Tags have no CLI commands today—they are Web UI only.
+
+Labels differ from Tags: they are key-value pairs (e.g., `env: production`, `owner: team-a`). Users need arbitrary metadata for organization, filtering, and reporting. The project overview and code-style rules already anticipate Labels (`Dictionary<string, string>` on `Resource`), but the implementation does not exist yet. `KeyValueModal` exists in Shared.Rcl and can be reused for label add/edit in the Web UI.
+
+## Goals / Non-Goals
+
+**Goals:**
+
+- Add `Labels` property (`Dictionary<string, string>`) to `Resource` base class.
+- Implement `AddLabelUseCase` and `RemoveLabelUseCase` following the Tags pattern.
+- Persist labels in YAML; existing resources without labels deserialize as empty dict.
+- Expose add/remove label commands in CLI for each resource branch (servers, switches, etc.).
+- Display and edit labels in the Web UI via a `ResourceLabelEditor` component (reusing `KeyValueModal`).
+
+**Non-Goals:**
+
+- Label-based filtering or querying in CLI/Web (future enhancement).
+- Label validation beyond key/value presence and length limits.
+- Bulk label operations or import/export.
+
+## Decisions
+
+### 1. Data model: `Dictionary<string, string>` on Resource
+
+**Decision:** Add `Labels` as `Dictionary<string, string>` on `Resource`, initialized to `new()` (never null).
+
+**Rationale:** Matches Kubernetes-style labels and the project's code-style rules. Simple, serializable, and supports arbitrary key-value pairs.
+
+**Alternatives considered:** `List<(string, string)>` — rejected because lookup by key is common and Dictionary is more ergonomic. `Dictionary<string, string?>` — rejected; empty string is acceptable for value.
+
+### 2. Use case pattern: Mirror Tags
+
+**Decision:** Create `AddLabelUseCase<T>` and `RemoveLabelUseCase<T>` in `UseCases/Labels/`, following the same structure as `AddTagUseCase` / `RemoveTagUseCase`.
+
+**Rationale:** Consistency with existing patterns; DI registration via open generics; single responsibility per use case.
+
+**Alternatives considered:** Single `LabelUseCase` with Add/Remove methods — rejected per SRP. Reusing Tag use cases with a "mode" — rejected; labels have different semantics (key-value vs single value).
+
+### 3. Normalization and validation
+
+**Decision:** Normalize key and value with `Trim()`. Validate: key and value non-empty, key length ≤ 50, value length ≤ 200. Add `ThrowIfInvalid.LabelKey` and `ThrowIfInvalid.LabelValue` (or similar) helpers.
+
+**Rationale:** Prevents whitespace-only labels; keeps YAML readable; avoids unbounded storage.
+
+**Alternatives considered:** No length limits — rejected for YAML size and UX. Lowercase keys — rejected; user may want case-sensitive keys (e.g., `Env` vs `env`).
+
+### 4. CLI structure: `label add` / `label remove` per resource branch
+
+**Decision:** Add a `label` sub-branch under each resource branch (servers, switches, systems, etc.) with `add` and `remove` commands. Example: `rpk servers label add web-01 --key env --value prod`, `rpk servers label remove web-01 --key env`.
+
+**Rationale:** Matches the E2E example workflow; consistent with `cpu add`, `drive add` sub-branch pattern.
+
+**Alternatives considered:** Global `label add <resource-type> <name> ...` — rejected; less discoverable. Positional args only — rejected; `--key`/`--value` improve clarity for key-value semantics.
+
+### 5. YAML serialization
+
+**Decision:** Rely on YamlDotNet default serialization for `Dictionary<string, string>`. No custom converter unless ordering or format requires it.
+
+**Rationale:** YamlDotNet serializes `Dictionary<string, string>` as YAML mapping. Existing resources without `Labels` will deserialize with default `new()`; migration not required if property is initialized.
+
+**Alternatives considered:** Custom converter for key ordering — deferred; can add later if needed. Schema version bump — only if migration logic is required; adding a new optional property typically does not require it.
+
+### 6. Web UI: ResourceLabelEditor component
+
+**Decision:** Create `ResourceLabelEditor` in Shared.Rcl, mirroring `ResourceTagEditor`. Use `KeyValueModal` for add/edit. Display labels as key-value chips with remove button; add button opens modal.
+
+**Rationale:** Reuses existing `KeyValueModal`; consistent UX with Tags; shared across all resource types via `@typeparam TResource`.
+
+## Risks / Trade-offs
+
+| Risk | Mitigation |
+|------|-------------|
+| YAML files from older app versions lack `Labels` | Initialize to empty dict in Resource; YamlDotNet will populate or leave default. Verify deserialization of legacy YAML. |
+| Duplicate label commands across 10+ resource branches | Use shared command base or factory; CliBootstrap will have repetitive but explicit registration (consistent with existing pattern). |
+| Key/value injection in YAML (e.g., special chars) | YamlDotNet handles escaping; validate key/value to reject control characters if needed. |
+| Labels bloat YAML file size | Length limits (50/200) mitigate; bulk operations out of scope. |
+
+## Migration Plan
+
+- **Deploy:** No schema version bump required if `Labels` is an additive, optional property. Existing YAML without `labels:` will deserialize with empty dictionary.
+- **Rollback:** Revert code; YAML with `labels:` will be ignored by older app (YamlDotNet typically ignores unknown properties). If strict compatibility is required, add migration step to strip labels when downgrading.
+
+## Open Questions
+
+- Should `describe` output format labels differently from tags (e.g., `Labels: env=prod, owner=team-a` vs `Tags: production, staging`)? **Recommendation:** Yes; use `key: value` format for labels.
+- Should we support label filtering in `list`/`get` in this change? **Recommendation:** No; keep scope to add/remove and display.

+ 29 - 0
openspec/changes/archive/2026-02-24-add-key-value-labels/proposal.md

@@ -0,0 +1,29 @@
+# Proposal: Add Key-Value Labels to Resources
+
+## Why
+
+The user would like the ability to add arbitrary labels to each resource. This helps the user associate key-value information on their resources (e.g., environment, owner, cost-center) for organization, filtering, and reporting.
+
+## What Changes
+
+- Addition of Labels (key-value section) to each resource
+- Add/Remove label use cases for managing labels via CLI and Web UI
+- YAML persistence of labels on resources
+- Display and editing of labels in the Web UI
+
+## Capabilities
+
+### New Capabilities
+
+- `resource-labels`: User can associate key-value attributes to their various resources in RackPeek. Labels are arbitrary string key-value pairs stored on each resource, with add/remove operations exposed via CLI and Web UI.
+
+### Modified Capabilities
+
+- None (no existing specs in `openspec/specs/`)
+
+## Impact
+
+- **Domain**: `Resource` base class gains `Labels` property (`Dictionary<string, string>`); new `AddLabelUseCase` and `RemoveLabelUseCase` in `UseCases/Labels/`
+- **Persistence**: YAML schema must serialize/deserialize labels; existing resources without labels remain valid (empty dict)
+- **CLI**: New commands for adding/removing labels per resource type (e.g., `rpk servers add-label <name> --key <key> --value <value>`, `rpk servers remove-label <name> --key <key>`)
+- **Web UI**: Resource card and edit flows must display and allow editing of labels (e.g., `ResourceLabelEditor` component)

+ 130 - 0
openspec/changes/archive/2026-02-24-add-key-value-labels/specs/resource-labels/spec.md

@@ -0,0 +1,130 @@
+# Spec: Resource Labels
+
+## ADDED Requirements
+
+### Requirement: Resource stores labels as key-value pairs
+
+Each resource SHALL have a Labels property that stores arbitrary string key-value pairs. Labels SHALL be initialized to an empty collection and MUST NOT be null.
+
+#### Scenario: New resource has empty labels
+
+- **WHEN** a resource is created
+- **THEN** the resource has an empty Labels collection
+
+#### Scenario: Labels are stored per resource
+
+- **WHEN** a label with key "env" and value "production" is added to resource "web-01"
+- **THEN** resource "web-01" has Labels containing "env" -> "production"
+- **AND** other resources are unaffected
+
+### Requirement: User can add a label to a resource
+
+The system SHALL allow users to add a label (key-value pair) to any resource via CLI and Web UI.
+
+#### Scenario: Add label via CLI
+
+- **WHEN** user runs `rpk servers label add web-01 --key env --value production`
+- **THEN** resource "web-01" has label "env" with value "production"
+- **AND** the change is persisted to YAML
+
+#### Scenario: Add label via Web UI
+
+- **WHEN** user opens the resource card, clicks add label, enters key "owner" and value "team-a", and submits
+- **THEN** the resource has label "owner" with value "team-a"
+- **AND** the change is persisted
+
+#### Scenario: Add label to existing key overwrites value
+
+- **WHEN** resource "web-01" has label "env" with value "staging"
+- **AND** user adds label "env" with value "production"
+- **THEN** resource "web-01" has label "env" with value "production"
+
+### Requirement: User can remove a label from a resource
+
+The system SHALL allow users to remove a label by key from any resource via CLI and Web UI.
+
+#### Scenario: Remove label via CLI
+
+- **WHEN** resource "web-01" has label "env" with value "production"
+- **AND** user runs `rpk servers label remove web-01 --key env`
+- **THEN** resource "web-01" no longer has label "env"
+- **AND** the change is persisted to YAML
+
+#### Scenario: Remove label via Web UI
+
+- **WHEN** resource has label "owner" with value "team-a"
+- **AND** user clicks remove on that label in the resource card
+- **THEN** the resource no longer has label "owner"
+- **AND** the change is persisted
+
+#### Scenario: Remove nonexistent key is no-op
+
+- **WHEN** resource "web-01" does not have label "env"
+- **AND** user runs `rpk servers label remove web-01 --key env`
+- **THEN** no error occurs
+- **AND** the resource is unchanged
+
+### Requirement: Labels persist in YAML
+
+Labels SHALL be serialized and deserialized from the YAML resource file. Existing resources without a labels section SHALL deserialize with an empty Labels collection.
+
+#### Scenario: Labels are written to YAML
+
+- **WHEN** resource "web-01" has labels "env: production" and "owner: team-a"
+- **AND** the resource is saved
+- **THEN** the YAML file contains a labels section for that resource with the key-value pairs
+
+#### Scenario: Legacy YAML without labels deserializes correctly
+
+- **WHEN** resource in YAML has no labels section
+- **THEN** the resource deserializes with an empty Labels collection
+
+### Requirement: Labels are displayed in resource views
+
+The system SHALL display labels in resource describe output (CLI) and resource cards (Web UI).
+
+#### Scenario: Describe shows labels
+
+- **WHEN** resource "web-01" has labels "env: production" and "owner: team-a"
+- **AND** user runs `rpk servers describe web-01`
+- **THEN** the output includes the labels in key-value format
+
+#### Scenario: Resource card shows labels
+
+- **WHEN** resource has label "env" with value "production"
+- **AND** user views the resource card in Web UI
+- **THEN** the label is displayed
+
+### Requirement: Label key and value are validated
+
+The system SHALL validate label key and value before adding. Key and value MUST be non-empty after trimming. Key length MUST NOT exceed 50 characters. Value length MUST NOT exceed 200 characters.
+
+#### Scenario: Empty key is rejected
+
+- **WHEN** user attempts to add a label with empty or whitespace-only key
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Empty value is rejected
+
+- **WHEN** user attempts to add a label with empty or whitespace-only value
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Key exceeds length limit is rejected
+
+- **WHEN** user attempts to add a label with key longer than 50 characters
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Value exceeds length limit is rejected
+
+- **WHEN** user attempts to add a label with value longer than 200 characters
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Add label for nonexistent resource fails
+
+- **WHEN** user attempts to add a label to a resource that does not exist
+- **THEN** a not-found error is returned
+- **AND** no change is persisted

+ 44 - 0
openspec/changes/archive/2026-02-24-add-key-value-labels/tasks.md

@@ -0,0 +1,44 @@
+# Tasks: Add Key-Value Labels to Resources
+
+## 1. Domain Model and Validation
+
+- [x] 1.1 Add `Labels` property (`Dictionary<string, string>`) to `Resource` base class, initialized to `new()`
+- [x] 1.2 Add `Normalize.LabelKey` and `Normalize.LabelValue` helpers (trim)
+- [x] 1.3 Add `ThrowIfInvalid.LabelKey` and `ThrowIfInvalid.LabelValue` (non-empty, key ≤ 50 chars, value ≤ 200 chars)
+- [x] 1.4 Create `AddLabelUseCase<T>` and `IAddLabelUseCase<T>` in `UseCases/Labels/`
+- [x] 1.5 Create `RemoveLabelUseCase<T>` and `IRemoveLabelUseCase<T>` in `UseCases/Labels/`
+- [x] 1.6 Register `IAddLabelUseCase<>` and `IRemoveLabelUseCase<>` in `ServiceCollectionExtensions`
+
+## 2. Persistence
+
+- [x] 2.1 Verify YamlDotNet serializes/deserializes `Dictionary<string, string>` for labels (no custom converter)
+- [x] 2.2 Add unit test for YAML round-trip with labels and legacy YAML without labels
+
+## 3. CLI Commands
+
+- [x] 3.1 Create `ServerLabelAddCommand` and `ServerLabelRemoveCommand` with `--key` and `--value` options
+- [x] 3.2 Add `label` branch with `add` and `remove` commands to servers in CliBootstrap
+- [x] 3.3 Add label add/remove commands for switches, routers, firewalls
+- [x] 3.4 Add label add/remove commands for systems, accesspoints, ups
+- [x] 3.5 Add label add/remove commands for desktops, laptops, services
+- [x] 3.6 Update `ServerDescribeCommand` to display labels in key-value format
+- [x] 3.7 Update describe commands for switches, routers, firewalls, systems, accesspoints, ups, desktops, laptops, services to display labels
+
+## 4. Web UI
+
+- [x] 4.1 Create `ResourceLabelEditor` component in Shared.Rcl (mirror `ResourceTagEditor`, use `KeyValueModal`)
+- [x] 4.2 Add `ResourceLabelEditor` to ServerCardComponent
+- [x] 4.3 Add `ResourceLabelEditor` to SwitchCardComponent, RouterCardComponent, FirewallCardComponent
+- [x] 4.4 Add `ResourceLabelEditor` to SystemCardComponent, AccessPointCardComponent, UpsCardComponent
+- [x] 4.5 Add `ResourceLabelEditor` to DesktopCardComponent, LaptopCardComponent, ServiceCardComponent
+
+## 5. Unit Tests
+
+- [x] 5.1 Add `AddLabelUseCaseTests` (new label, overwrite existing key, nonexistent resource)
+- [x] 5.2 Add `RemoveLabelUseCaseTests` (remove existing, remove nonexistent key no-op)
+- [x] 5.3 Add validation tests for `ThrowIfInvalid.LabelKey` and `ThrowIfInvalid.LabelValue`
+
+## 6. E2E Tests
+
+- [x] 6.1 Add CLI workflow test: add label, verify YAML, describe shows labels, remove label
+- [x] 6.2 Add browser E2E test for label add/remove in resource card

+ 132 - 0
openspec/specs/resource-labels/spec.md

@@ -0,0 +1,132 @@
+# resource-labels Specification
+
+## Purpose
+TBD - created by archiving change add-key-value-labels. Update Purpose after archive.
+## Requirements
+### Requirement: Resource stores labels as key-value pairs
+
+Each resource SHALL have a Labels property that stores arbitrary string key-value pairs. Labels SHALL be initialized to an empty collection and MUST NOT be null.
+
+#### Scenario: New resource has empty labels
+
+- **WHEN** a resource is created
+- **THEN** the resource has an empty Labels collection
+
+#### Scenario: Labels are stored per resource
+
+- **WHEN** a label with key "env" and value "production" is added to resource "web-01"
+- **THEN** resource "web-01" has Labels containing "env" -> "production"
+- **AND** other resources are unaffected
+
+### Requirement: User can add a label to a resource
+
+The system SHALL allow users to add a label (key-value pair) to any resource via CLI and Web UI.
+
+#### Scenario: Add label via CLI
+
+- **WHEN** user runs `rpk servers label add web-01 --key env --value production`
+- **THEN** resource "web-01" has label "env" with value "production"
+- **AND** the change is persisted to YAML
+
+#### Scenario: Add label via Web UI
+
+- **WHEN** user opens the resource card, clicks add label, enters key "owner" and value "team-a", and submits
+- **THEN** the resource has label "owner" with value "team-a"
+- **AND** the change is persisted
+
+#### Scenario: Add label to existing key overwrites value
+
+- **WHEN** resource "web-01" has label "env" with value "staging"
+- **AND** user adds label "env" with value "production"
+- **THEN** resource "web-01" has label "env" with value "production"
+
+### Requirement: User can remove a label from a resource
+
+The system SHALL allow users to remove a label by key from any resource via CLI and Web UI.
+
+#### Scenario: Remove label via CLI
+
+- **WHEN** resource "web-01" has label "env" with value "production"
+- **AND** user runs `rpk servers label remove web-01 --key env`
+- **THEN** resource "web-01" no longer has label "env"
+- **AND** the change is persisted to YAML
+
+#### Scenario: Remove label via Web UI
+
+- **WHEN** resource has label "owner" with value "team-a"
+- **AND** user clicks remove on that label in the resource card
+- **THEN** the resource no longer has label "owner"
+- **AND** the change is persisted
+
+#### Scenario: Remove nonexistent key is no-op
+
+- **WHEN** resource "web-01" does not have label "env"
+- **AND** user runs `rpk servers label remove web-01 --key env`
+- **THEN** no error occurs
+- **AND** the resource is unchanged
+
+### Requirement: Labels persist in YAML
+
+Labels SHALL be serialized and deserialized from the YAML resource file. Existing resources without a labels section SHALL deserialize with an empty Labels collection.
+
+#### Scenario: Labels are written to YAML
+
+- **WHEN** resource "web-01" has labels "env: production" and "owner: team-a"
+- **AND** the resource is saved
+- **THEN** the YAML file contains a labels section for that resource with the key-value pairs
+
+#### Scenario: Legacy YAML without labels deserializes correctly
+
+- **WHEN** resource in YAML has no labels section
+- **THEN** the resource deserializes with an empty Labels collection
+
+### Requirement: Labels are displayed in resource views
+
+The system SHALL display labels in resource describe output (CLI) and resource cards (Web UI).
+
+#### Scenario: Describe shows labels
+
+- **WHEN** resource "web-01" has labels "env: production" and "owner: team-a"
+- **AND** user runs `rpk servers describe web-01`
+- **THEN** the output includes the labels in key-value format
+
+#### Scenario: Resource card shows labels
+
+- **WHEN** resource has label "env" with value "production"
+- **AND** user views the resource card in Web UI
+- **THEN** the label is displayed
+
+### Requirement: Label key and value are validated
+
+The system SHALL validate label key and value before adding. Key and value MUST be non-empty after trimming. Key length MUST NOT exceed 50 characters. Value length MUST NOT exceed 200 characters.
+
+#### Scenario: Empty key is rejected
+
+- **WHEN** user attempts to add a label with empty or whitespace-only key
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Empty value is rejected
+
+- **WHEN** user attempts to add a label with empty or whitespace-only value
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Key exceeds length limit is rejected
+
+- **WHEN** user attempts to add a label with key longer than 50 characters
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Value exceeds length limit is rejected
+
+- **WHEN** user attempts to add a label with value longer than 200 characters
+- **THEN** a validation error is returned
+- **AND** the resource is not updated
+
+#### Scenario: Add label for nonexistent resource fails
+
+- **WHEN** user attempts to add a label to a resource that does not exist
+- **THEN** a not-found error is returned
+- **AND** no change is persisted
+