Tim Jones 1 месяц назад
Родитель
Сommit
3228620447
42 измененных файлов с 636 добавлено и 193 удалено
  1. 10 0
      RackPeek.Domain/Helpers/Normalize.cs
  2. 4 0
      RackPeek.Domain/Persistence/IResourceCollection.cs
  3. 30 0
      RackPeek.Domain/Persistence/InMemoryResourceCollection.cs
  4. 23 0
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  5. 10 0
      RackPeek.Domain/Persistence/YamlResourceRepository.cs
  6. 52 0
      RackPeek.Domain/Persistence/YamlServerRepository.cs
  7. 0 24
      RackPeek.Domain/Resources/Hardware/AccessPoints/AddAccessPointUseCase.cs
  8. 5 7
      RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs
  9. 5 0
      RackPeek.Domain/Resources/IResourceRepository.cs
  10. 1 1
      RackPeek.Domain/Resources/Resource.cs
  11. 11 6
      RackPeek.Domain/Resources/Services/IServiceRepository.cs
  12. 3 8
      RackPeek.Domain/Resources/SystemResources/ISystemRepository.cs
  13. 70 0
      RackPeek.Domain/ServiceCollectionExtensions.cs
  14. 31 0
      RackPeek.Domain/UseCases/AddResourceUseCase.cs
  15. 42 0
      RackPeek.Domain/UseCases/Tags/AddResourceTagUseCase.cs
  16. 40 0
      RackPeek.Domain/UseCases/Tags/RemoveResourceTagUseCase.cs
  17. 5 1
      RackPeek.Web.Viewer/Pages/Home.razor
  18. 1 4
      RackPeek.Web.Viewer/Program.cs
  19. 1 0
      RackPeek.Web.Viewer/wwwroot/index.html
  20. 8 2
      RackPeek.Web/Components/Pages/Home.razor
  21. 2 5
      RackPeek.Web/Program.cs
  22. 2 0
      Shared.Rcl/AccessPoints/AccessPointCardComponent.razor
  23. 3 1
      Shared.Rcl/AccessPoints/AccessPointsListComponent.razor
  24. 1 4
      Shared.Rcl/CliBootstrap.cs
  25. 3 1
      Shared.Rcl/Commands/AccessPoints/AccessPointAddCommand.cs
  26. 13 8
      Shared.Rcl/Components/AddResourceComponent.razor
  27. 105 0
      Shared.Rcl/Components/ResourceTagEditor.razor
  28. 50 0
      Shared.Rcl/Components/TagListComponent.razor
  29. 81 0
      Shared.Rcl/Components/TagPage.razor
  30. 2 0
      Shared.Rcl/Desktops/DesktopCardComponent.razor
  31. 2 1
      Shared.Rcl/Firewalls/FirewallCardComponent.razor
  32. 3 0
      Shared.Rcl/Laptops/LaptopCardComponent.razor
  33. 2 1
      Shared.Rcl/Routers/RouterCardComponent.razor
  34. 0 68
      Shared.Rcl/Servers/AddServerComponent.razor
  35. 2 0
      Shared.Rcl/Servers/ServerCardComponent.razor
  36. 4 2
      Shared.Rcl/Servers/ServersListComponent.razor
  37. 3 0
      Shared.Rcl/Services/ServiceCardComponent.razor
  38. 2 1
      Shared.Rcl/Switches/SwitchCardComponent.razor
  39. 1 0
      Shared.Rcl/Systems/SystemCardComponent.razor
  40. 2 1
      Shared.Rcl/Ups/UpsCardComponent.razor
  41. 0 46
      Tests/HardwareResources/AccessPoints/AddAccessPointUseCaseTests.cs
  42. 1 1
      notes.md

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

@@ -31,4 +31,14 @@ public static class Normalize
     {
         return name.Trim();
     }
+    
+    public static string ResourceName(string name)
+    {
+        return name.Trim();
+    }
+    
+    public static string Tag(string name)
+    {
+        return name.Trim();
+    }
 }

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

@@ -16,6 +16,10 @@ public interface IResourceCollection
     Task DeleteAsync(string name);
 
     Resource? GetByName(string name);
+    Task<bool> Exists(string name);
 
     Task LoadAsync();   // required for WASM startup
+    Task<IReadOnlyList<Resource>> GetByTagAsync(string name);
+    public Task<Dictionary<string, int>> GetTagsAsync();
+
 }

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

@@ -37,9 +37,39 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
         }
     }
 
+    public Task<bool> Exists(string name)
+    {
+        lock (_lock)
+        {
+            return Task.FromResult(_resources.Exists(r =>
+                r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
+        }
+    }
+
     public Task LoadAsync()
         => Task.CompletedTask;
 
+    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
+    {
+        lock (_lock)
+            return Task.FromResult<IReadOnlyList<Resource>>(_resources.Where(r => r.Tags.Contains(name)).ToList());
+    }
+
+    public Task<Dictionary<string, int>> GetTagsAsync()
+    {
+        lock (_lock)
+        {
+            var result = _resources
+                .Where(r => r.Tags != null)
+                .SelectMany(r => r.Tags!)      // flatten all tag arrays
+                .Where(t => !string.IsNullOrWhiteSpace(t))
+                .GroupBy(t => t)
+                .ToDictionary(g => g.Key, g => g.Count());
+            return Task.FromResult(result);
+        }
+    }
+
+
     public Task AddAsync(Resource resource)
     {
         lock (_lock)

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

@@ -22,6 +22,29 @@ public sealed class YamlResourceCollection(
     ResourceCollection resourceCollection)
     : IResourceCollection
 {
+    public Task<bool> Exists(string name)
+    {
+        return Task.FromResult(resourceCollection.Resources.Exists(r =>
+            r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
+    }
+    
+    public Task<Dictionary<string, int>> GetTagsAsync()
+    {
+        var result = resourceCollection.Resources
+            .Where(r => r.Tags != null)
+            .SelectMany(r => r.Tags!)      // flatten all tag arrays
+            .Where(t => !string.IsNullOrWhiteSpace(t))
+            .GroupBy(t => t)
+            .ToDictionary(g => g.Key, g => g.Count());
+        return Task.FromResult(result);
+    }
+
+
+    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
+    {
+        return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources.Where(r => r.Tags.Contains(name)).ToList());
+    }
+    
     public async Task LoadAsync()
     {
         var loaded = await LoadFromFileAsync();

+ 10 - 0
RackPeek.Domain/Persistence/YamlResourceRepository.cs

@@ -26,4 +26,14 @@ public class YamlResourceRepository(IResourceCollection resources) : IResourceRe
     {
         return Task.FromResult(resources.GetByName(name) != null);
     }
+
+    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
+    {
+        return resources.GetByTagAsync(name);
+    }
+
+    public Task<Dictionary<string, int>> GetTagsAsync()
+    {
+        return resources.GetTagsAsync();
+    }
 }

+ 52 - 0
RackPeek.Domain/Persistence/YamlServerRepository.cs

@@ -0,0 +1,52 @@
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Models;
+using RackPeek.Domain.Resources.Services;
+
+namespace RackPeek.Domain.Persistence;
+
+public class YamlHardwareRepo<T>(IResourceCollection resources) : IResourceRepo<T> where T : Hardware
+{
+    public Task<IReadOnlyList<T>> GetAllAsync()
+    {
+        var servers = resources.HardwareResources.OfType<T>().ToList();
+        return Task.FromResult<IReadOnlyList<T>>(servers.AsReadOnly());
+    }
+
+    public async Task AddAsync(T service)
+    {
+        if (await resources.Exists(service.Name))
+            throw new InvalidOperationException(
+                $"Resource with name '{service.Name}' already exists.");
+
+        await resources.AddAsync(service);
+    }
+    
+    public async Task UpdateAsync(T service)
+    {
+        var existing = resources.HardwareResources
+            .FirstOrDefault(r => r.Name.Equals(service.Name, StringComparison.OrdinalIgnoreCase));
+        
+        if (existing is not T)
+            throw new InvalidOperationException($"'{service.Name}' not found.");
+
+        await resources.UpdateAsync(service);
+        
+    }
+
+    public async Task DeleteAsync(string name)
+    {
+        var existing = resources.HardwareResources
+            .FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
+
+        if (existing is not Server)
+            throw new InvalidOperationException($"'{name}' not found.");
+
+        await resources.DeleteAsync(name);
+        
+    }
+
+    public Task<T?> GetByNameAsync(string name)
+    {
+        return Task.FromResult(resources.GetByName(name) as T);
+    }
+}

+ 0 - 24
RackPeek.Domain/Resources/Hardware/AccessPoints/AddAccessPointUseCase.cs

@@ -1,24 +0,0 @@
-using RackPeek.Domain.Helpers;
-using RackPeek.Domain.Resources.Models;
-
-namespace RackPeek.Domain.Resources.Hardware.AccessPoints;
-
-public class AddAccessPointUseCase(IHardwareRepository repository, IResourceRepository resourceRepo) : IUseCase
-{
-    public async Task ExecuteAsync(string name)
-    {
-        name = Normalize.HardwareName(name);
-        ThrowIfInvalid.ResourceName(name);
-
-        var existingResourceKind = await resourceRepo.GetResourceKindAsync(name);
-        if (!string.IsNullOrEmpty(existingResourceKind))
-            throw new ConflictException($"{existingResourceKind} resource '{name}' already exists.");
-
-        var ap = new AccessPoint
-        {
-            Name = name
-        };
-
-        await repository.AddAsync(ap);
-    }
-}

+ 5 - 7
RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs

@@ -1,15 +1,13 @@
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+
 namespace RackPeek.Domain.Resources.Hardware;
 
-public interface IHardwareRepository
+public interface IHardwareRepository : IResourceRepo<Models.Hardware> 
 {
     Task<int> GetCountAsync();
     Task<Dictionary<string, int>> GetKindCountAsync();
-
-    Task<IReadOnlyList<Models.Hardware>> GetAllAsync();
-    Task AddAsync(Models.Hardware hardware);
-    Task UpdateAsync(Models.Hardware hardware);
-    Task DeleteAsync(string name);
-    Task<Models.Hardware?> GetByNameAsync(string name);
+    
     public Task<List<HardwareTree>> GetTreeAsync();
 }
 

+ 5 - 0
RackPeek.Domain/Resources/IResourceRepository.cs

@@ -4,4 +4,9 @@ public interface IResourceRepository
 {
     public Task<string?> GetResourceKindAsync(string name);
     public Task<bool> ResourceExistsAsync(string name);
+    
+    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name);
+    public Task<Dictionary<string, int>> GetTagsAsync();
+
+
 }

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

@@ -21,7 +21,7 @@ public abstract class Resource
 
     public required string Name { get; set; }
 
-    public Dictionary<string, string>? Tags { get; set; }
+    public string[]? Tags { get; set; } = [];
     public string? Notes { get; set; }
 
     public static string KindToPlural(string kind)

+ 11 - 6
RackPeek.Domain/Resources/Services/IServiceRepository.cs

@@ -1,14 +1,19 @@
 namespace RackPeek.Domain.Resources.Services;
 
-public interface IServiceRepository
+public interface IServiceRepository : IResourceRepo<Service> 
 {
     Task<int> GetCountAsync();
     Task<int> GetIpAddressCountAsync();
 
-    Task<IReadOnlyList<Service>> GetAllAsync();
-    Task AddAsync(Service service);
-    Task UpdateAsync(Service service);
-    Task DeleteAsync(string name);
-    Task<Service?> GetByNameAsync(string name);
     Task<IReadOnlyList<Service>> GetBySystemHostAsync(string name);
+}
+
+
+public interface IResourceRepo<T> where T : Resource
+{
+    Task<IReadOnlyList<T>> GetAllAsync();
+    Task AddAsync(T service);
+    Task UpdateAsync(T service);
+    Task DeleteAsync(string name);
+    Task<T?> GetByNameAsync(string name);
 }

+ 3 - 8
RackPeek.Domain/Resources/SystemResources/ISystemRepository.cs

@@ -1,17 +1,12 @@
+using RackPeek.Domain.Resources.Services;
+
 namespace RackPeek.Domain.Resources.SystemResources;
 
-public interface ISystemRepository
+public interface ISystemRepository : IResourceRepo<SystemResource> 
 {
     Task<int> GetSystemCountAsync();
     Task<Dictionary<string, int>> GetSystemTypeCountAsync();
     Task<Dictionary<string, int>> GetSystemOsCountAsync();
-
-    Task<IReadOnlyList<SystemResource>> GetAllAsync();
     Task<IReadOnlyList<SystemResource>> GetFilteredAsync(string? typeFilter, string? osFilter);
-
-    Task AddAsync(SystemResource systemResource);
-    Task UpdateAsync(SystemResource systemResource);
-    Task DeleteAsync(string name);
-    Task<SystemResource?> GetByNameAsync(string name);
     Task<IReadOnlyList<SystemResource>> GetByPhysicalHostAsync(string name);
 }

+ 70 - 0
RackPeek.Domain/ServiceCollectionExtensions.cs

@@ -1,13 +1,56 @@
 using System.Reflection;
 using Microsoft.Extensions.DependencyInjection;
+using RackPeek.Domain.Persistence;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Hardware;
+using RackPeek.Domain.Resources.Models;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.UseCases;
+using RackPeek.Domain.UseCases.Tags;
 
 namespace RackPeek.Domain;
 
+public interface IResourceUseCase<T> where T : Resource
+{
+    
+}
+
 public static class ServiceCollectionExtensions
 {
+    public static IServiceCollection AddResourceUseCases(
+        this IServiceCollection services,
+        Assembly assembly)
+    {
+        var types = assembly.GetTypes()
+            .Where(t => !t.IsAbstract && !t.IsInterface);
+
+        foreach (var type in types)
+        {
+            var resourceUseCaseInterfaces = type.GetInterfaces()
+                .Where(i =>
+                    i.IsGenericType &&
+                    i.GetInterfaces().Any(parent =>
+                        parent.IsGenericType &&
+                        parent.GetGenericTypeDefinition() == typeof(IResourceUseCase<>)));
+
+            foreach (var serviceType in resourceUseCaseInterfaces)
+            {
+                services.AddScoped(serviceType, type);
+            }
+        }
+
+        return services;
+    }
+
+    
     public static IServiceCollection AddUseCases(
         this IServiceCollection services)
     {
+        services.AddScoped(typeof(IAddTagUseCase<>), typeof(AddTagUseCase<>));
+        services.AddScoped(typeof(IRemoveTagUseCase<>), typeof(RemoveTagUseCase<>));
+        services.AddScoped(typeof(IAddResourceUseCase<>), typeof(AddResourceUseCase<>));
+        
         var usecases = Assembly.GetAssembly(typeof(IUseCase))
             ?.GetTypes()
             .Where(t =>
@@ -19,4 +62,31 @@ public static class ServiceCollectionExtensions
 
         return services;
     }
+    
+    public static IServiceCollection AddYamlRepos(
+        this IServiceCollection services)
+    {
+        services.AddScoped<IHardwareRepository, YamlHardwareRepository>();
+        services.AddScoped<ISystemRepository, YamlSystemRepository>();
+        services.AddScoped<IServiceRepository, YamlServiceRepository>();
+        services.AddScoped<IResourceRepository, YamlResourceRepository>();
+        
+        services.AddScoped<IResourceRepo<AccessPoint>, YamlHardwareRepo<AccessPoint>>();
+        services.AddScoped<IResourceRepo<Desktop>, YamlHardwareRepo<Desktop>>();
+        services.AddScoped<IResourceRepo<Firewall>, YamlHardwareRepo<Firewall>>();
+        services.AddScoped<IResourceRepo<Laptop>, YamlHardwareRepo<Laptop>>();
+        services.AddScoped<IResourceRepo<Router>, YamlHardwareRepo<Router>>();
+        services.AddScoped<IResourceRepo<Server>, YamlHardwareRepo<Server>>();
+        services.AddScoped<IResourceRepo<Switch>, YamlHardwareRepo<Switch>>();
+        services.AddScoped<IResourceRepo<Ups>, YamlHardwareRepo<Ups>>();
+        
+        services.AddScoped<IResourceRepo<SystemResource>, YamlSystemRepository>();
+        services.AddScoped<IResourceRepo<Service>, YamlServiceRepository>();
+        
+
+
+        return services;
+    }
+    
+    
 }

+ 31 - 0
RackPeek.Domain/UseCases/AddResourceUseCase.cs

@@ -0,0 +1,31 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+
+namespace RackPeek.Domain.UseCases;
+
+public interface IAddResourceUseCase<T> : IResourceUseCase<T>
+    where T : Resource
+{
+    Task ExecuteAsync(string name);
+}
+
+
+public class AddResourceUseCase<T>(IResourceRepo<T> repo, IResourceRepository resourceRepository) : IAddResourceUseCase<T> where T : Resource, new()
+{
+    public async Task ExecuteAsync(string name)
+    {
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var existingResourceKind = await resourceRepository.GetResourceKindAsync(name);
+        if (!string.IsNullOrEmpty(existingResourceKind))
+            throw new ConflictException($"{existingResourceKind} resource '{name}' already exists.");
+        
+        var resource = new T
+        {
+            Name = name
+        };
+        await repo.AddAsync(resource);
+    }
+}

+ 42 - 0
RackPeek.Domain/UseCases/Tags/AddResourceTagUseCase.cs

@@ -0,0 +1,42 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+
+namespace RackPeek.Domain.UseCases.Tags;
+
+public interface IAddTagUseCase<T> : IResourceUseCase<T>
+    where T : Resource
+{
+    Task ExecuteAsync(string name, string tag);
+}
+
+public class AddTagUseCase<T>(IResourceRepo<T> repo) : IAddTagUseCase<T> where T : Resource
+{
+    public async Task ExecuteAsync(string name, string tag)
+    {
+        tag = Normalize.Tag(tag);
+
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+        
+        var resource = await repo.GetByNameAsync(name);
+        if (resource == null)
+            throw new NotFoundException($"Resource '{name}' not found.");
+
+        if (resource.Tags == null)
+        {
+            resource.Tags = [tag];
+        }
+        else if (!resource.Tags.Contains(tag))
+        {
+            resource.Tags = [..resource.Tags, tag];
+        }
+        else
+        {
+            // Tag already exists
+            return;
+        }
+        
+        await repo.UpdateAsync(resource);
+    }
+}

+ 40 - 0
RackPeek.Domain/UseCases/Tags/RemoveResourceTagUseCase.cs

@@ -0,0 +1,40 @@
+using RackPeek.Domain.Helpers;
+using RackPeek.Domain.Resources;
+using RackPeek.Domain.Resources.Services;
+
+namespace RackPeek.Domain.UseCases.Tags;
+public interface IRemoveTagUseCase<T> : IResourceUseCase<T>
+    where T : Resource
+{
+    Task ExecuteAsync(string name, string tag);
+}
+
+public class RemoveTagUseCase<T>(IResourceRepo<T> repo)
+    : IRemoveTagUseCase<T>
+    where T : Resource
+{
+    public async Task ExecuteAsync(string name, string tag)
+    {
+        tag = Normalize.Tag(tag);
+
+        name = Normalize.HardwareName(name);
+        ThrowIfInvalid.ResourceName(name);
+
+        var resource = await repo.GetByNameAsync(name)
+                       ?? throw new NotFoundException($"Resource '{name}' not found.");
+
+        if (resource.Tags is null || resource.Tags.Length == 0)
+            return;
+
+        var updated = resource.Tags
+            .Where(t => t != tag)
+            .ToArray();
+
+        if (updated.Length == resource.Tags.Length)
+            return; // tag didn't exist
+
+        resource.Tags = updated.Length == 0 ? null : updated;
+
+        await repo.UpdateAsync(resource);
+    }
+}

+ 5 - 1
RackPeek.Web.Viewer/Pages/Home.razor

@@ -4,6 +4,7 @@
 @using RackPeek.Domain.Resources.Services.UseCases
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @using Shared.Rcl
+@using Shared.Rcl.Components
 @inject GetSystemSummaryUseCase SystemSummaryUseCase
 @inject GetServiceSummaryUseCase ServiceSummaryUseCase
 @inject GetHardwareUseCaseSummary HardwareSummaryUseCase
@@ -44,7 +45,10 @@
             </div>
         </div>
 
-        <!-- Tree -->
+        <div class="space-y-10">
+            <TagListComponent/>
+            </div>
+            <!-- Tree -->
         <div class="space-y-10">
 
             <!-- Hardware -->

+ 1 - 4
RackPeek.Web.Viewer/Program.cs

@@ -33,10 +33,7 @@ public class Program
                 sp.GetRequiredService<ITextFileStore>(),
                 sp.GetRequiredService<ResourceCollection>()));
         
-        services.AddScoped<IHardwareRepository, YamlHardwareRepository>();
-        services.AddScoped<ISystemRepository, YamlSystemRepository>();
-        services.AddScoped<IServiceRepository, YamlServiceRepository>();
-        services.AddScoped<IResourceRepository, YamlResourceRepository>();
+        builder.Services.AddYamlRepos();
 
         builder.Services.AddCommands();
         builder.Services.AddScoped<IConsoleEmulator, ConsoleEmulator>();

+ 1 - 0
RackPeek.Web.Viewer/wwwroot/index.html

@@ -12,6 +12,7 @@
     <script src="./storage.js"></script>
     <script src="console.js"></script>
     <script type="importmap"></script>
+    <link href="app.css" rel="stylesheet" />
 </head>
 
 <body>

+ 8 - 2
RackPeek.Web/Components/Pages/Home.razor

@@ -3,6 +3,7 @@
 @using RackPeek.Domain.Resources.Hardware
 @using RackPeek.Domain.Resources.Services.UseCases
 @using RackPeek.Domain.Resources.SystemResources.UseCases
+@using Shared.Rcl.Components
 @inject GetSystemSummaryUseCase SystemSummaryUseCase
 @inject GetServiceSummaryUseCase ServiceSummaryUseCase
 @inject GetHardwareUseCaseSummary HardwareSummaryUseCase
@@ -43,7 +44,12 @@
                 </div>
             </div>
         </div>
-
+        
+        <div class="space-y-10 mb-3">
+            <TagListComponent/>
+        </div>
+        
+        
         <!-- Tree -->
         <div class="space-y-10">
 
@@ -55,7 +61,7 @@
 
                 <ul class="space-y-2">
                     <li class="text-zinc-100">
-                        ├─ Total (@_hardware!.TotalHardware)
+                        ├─ Hardware (@_hardware!.TotalHardware)
                     </li>
 
                     @if (_hardware.HardwareByKind.Any())

+ 2 - 5
RackPeek.Web/Program.cs

@@ -61,11 +61,8 @@ public class Program
                 sp.GetRequiredService<ResourceCollection>()));
         
         // Infrastructure
-        builder.Services.AddScoped<IHardwareRepository, YamlHardwareRepository>();
-        builder.Services.AddScoped<ISystemRepository, YamlSystemRepository>();
-        builder.Services.AddScoped<IServiceRepository, YamlServiceRepository>();
-        builder.Services.AddScoped<IResourceRepository, YamlResourceRepository>();
-        
+        builder.Services.AddYamlRepos();
+
         builder.Services.AddUseCases();
         builder.Services.AddCommands();
         builder.Services.AddScoped<IConsoleEmulator, ConsoleEmulator>();

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

@@ -95,6 +95,8 @@
             }
         </div>
 
+        <ResourceTagEditor Resource="AccessPoint" />
+        
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
 

+ 3 - 1
Shared.Rcl/AccessPoints/AccessPointsListComponent.razor

@@ -6,7 +6,9 @@
 <PageTitle>Access Points</PageTitle>
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
-    <AddAccessPointComponent OnCreated="NavigateToNewResource"/>
+    <AddResourceComponent TResource="AccessPoint"
+                        Placeholder="AP name"
+                        OnCreated="NavigateToNewResource" />
 
 
     @if (_AccessPoints is null)

+ 1 - 4
Shared.Rcl/CliBootstrap.cs

@@ -59,10 +59,7 @@ public static class CliBootstrap
         services.AddSingleton<IResourceCollection>(collection);
 
         // Infrastructure
-        services.AddScoped<IHardwareRepository, YamlHardwareRepository>();
-        services.AddScoped<ISystemRepository, YamlSystemRepository>();
-        services.AddScoped<IServiceRepository, YamlServiceRepository>();
-        services.AddScoped<IResourceRepository, YamlResourceRepository>();
+        services.AddYamlRepos();
 
         // Application
         services.AddUseCases();

+ 3 - 1
Shared.Rcl/Commands/AccessPoints/AccessPointAddCommand.cs

@@ -1,6 +1,8 @@
 using System.ComponentModel;
 using Microsoft.Extensions.DependencyInjection;
 using RackPeek.Domain.Resources.Hardware.AccessPoints;
+using RackPeek.Domain.Resources.Models;
+using RackPeek.Domain.UseCases;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
@@ -23,7 +25,7 @@ public class AccessPointAddCommand(
         CancellationToken cancellationToken)
     {
         using var scope = serviceProvider.CreateScope();
-        var useCase = scope.ServiceProvider.GetRequiredService<AddAccessPointUseCase>();
+        var useCase = scope.ServiceProvider.GetRequiredService<IAddResourceUseCase<AccessPoint>>();
 
         await useCase.ExecuteAsync(settings.Name);
 

+ 13 - 8
Shared.Rcl/AccessPoints/AddAccessPointComponent.razor → Shared.Rcl/Components/AddResourceComponent.razor

@@ -1,17 +1,21 @@
-@using RackPeek.Domain.Resources.Hardware.AccessPoints
-@inject AddAccessPointUseCase AddAccessPoint
+@typeparam TResource where TResource : Resource
+
+@using RackPeek.Domain.Resources
+@using RackPeek.Domain.UseCases
+
+@inject IAddResourceUseCase<TResource> AddResource
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="text-zinc-100 mb-3">
-        Add AP
+        Add @typeof(TResource).Name
     </div>
 
     <div class="flex gap-2">
         <input
             class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
-            placeholder="AP name"
+            placeholder="@Placeholder"
             @bind="_name"
-            @bind:event="oninput"/>
+            @bind:event="oninput" />
 
         <button
             class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
@@ -31,6 +35,7 @@
 
 @code {
     [Parameter] public EventCallback<string> OnCreated { get; set; }
+    [Parameter] public string? Placeholder { get; set; }
 
     private string _name = string.Empty;
     private string? _error;
@@ -50,9 +55,10 @@
         {
             _isSubmitting = true;
             var name = _name.Trim();
-            await AddAccessPoint.ExecuteAsync(name);
-            _name = string.Empty;
 
+            await AddResource.ExecuteAsync(name);
+
+            _name = string.Empty;
             await OnCreated.InvokeAsync(name);
         }
         catch (Exception ex)
@@ -64,5 +70,4 @@
             _isSubmitting = false;
         }
     }
-
 }

+ 105 - 0
Shared.Rcl/Components/ResourceTagEditor.razor

@@ -0,0 +1,105 @@
+@using RackPeek.Domain.Resources.Models
+@typeparam TResource where TResource : RackPeek.Domain.Resources.Resource
+
+@using RackPeek.Domain.UseCases
+@using RackPeek.Domain.UseCases.Tags
+@inject NavigationManager Nav
+
+@inject IAddTagUseCase<TResource> AddTagUseCase
+@inject IRemoveTagUseCase<TResource> RemoveTagUseCase
+
+<div class="md:col-span-2">
+    <div class="flex items-center justify-between mb-1 group">
+        <div class="text-zinc-400">
+            Tags
+            <button class="hover:text-emerald-400 ml-1"
+                    title="Add Tag"
+                    @onclick="OpenAddTag">
+                +
+            </button>
+        </div>
+    </div>
+
+    @if (Resource.Tags?.Any() == true)
+    {
+        <div class="flex flex-wrap gap-2">
+            @foreach (var tag in Resource.Tags.OrderBy(t => t))
+            {
+                <div class="flex text-xs rounded overflow-hidden border border-zinc-700">
+
+                    <!-- LEFT SIDE (Navigate) -->
+                    <button type="button"
+                            class="px-2 py-0.5 bg-zinc-800 text-zinc-300 hover:bg-emerald-800 hover:text-emerald-200 transition"
+                            title="View tag"
+                            @onclick="() => NavigateToTag(tag)">
+                        @tag
+                    </button>
+
+                    <!-- RIGHT SIDE (Delete) -->
+                    <button type="button"
+                            class="px-1 py-0.5 bg-zinc-800 text-zinc-400 hover:bg-red-800 hover:text-red-200 transition border-l border-zinc-700"
+                            title="Remove tag"
+                            @onclick="() => RemoveTag(tag)">
+                        ✕
+                    </button>
+
+                </div>
+            }
+        </div>
+    }
+
+</div>
+
+<StringValueModal
+    IsOpen="_tagModalOpen"
+    IsOpenChanged="v => _tagModalOpen = v"
+    Title="Add Tag"
+    Description="Enter tag value"
+    Label="Tag"
+    Value=""
+    OnSubmit="HandleTagSubmit" />
+    
+@code {
+    [Parameter, EditorRequired]
+    public TResource Resource { get; set; } = default!;
+
+    [Parameter]
+    public EventCallback OnTagsChanged { get; set; }
+
+    bool _tagModalOpen;
+
+    void OpenAddTag()
+    {
+        _tagModalOpen = true;
+    }
+
+    async Task HandleTagSubmit(string value)
+    {
+        if (string.IsNullOrWhiteSpace(value))
+            return;
+
+        await AddTagUseCase.ExecuteAsync(
+            Resource.Name,
+            value);
+
+        _tagModalOpen = false;
+
+        if (OnTagsChanged.HasDelegate)
+            await OnTagsChanged.InvokeAsync();
+    }
+
+    async Task RemoveTag(string tag)
+    {
+        await RemoveTagUseCase.ExecuteAsync(
+            Resource.Name,
+            tag);
+
+        if (OnTagsChanged.HasDelegate)
+            await OnTagsChanged.InvokeAsync();
+    }
+    
+    void NavigateToTag(string tag)
+    {
+        Nav.NavigateTo($"tags/{Uri.EscapeDataString(tag)}");
+    }
+}

+ 50 - 0
Shared.Rcl/Components/TagListComponent.razor

@@ -0,0 +1,50 @@
+@using RackPeek.Domain.Resources
+@using RackPeek.Domain.UseCases.Tags
+@inject IResourceRepository TagRepository
+@inject NavigationManager Nav
+
+<div>
+    <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
+        Tags
+    </div>
+
+    @if (_tags is null)
+    {
+        <div class="text-zinc-500">loading tags…</div>
+    }
+    else if (_tags.Count == 0)
+    {
+        <div class="text-zinc-500">no tags found</div>
+    }
+    else
+    {
+        <ul class="space-y-3">
+            <li>
+                <div class="text-zinc-100">
+                    ├─ Tags (@_tags.Count)
+                </div>
+
+                <ul class="ml-4 mt-2 border-l border-zinc-800 pl-4 space-y-1">
+                    @foreach (var (tag, count) in _tags.OrderByDescending(x => x.Value).ThenBy(x => x.Key))
+                    {
+                        <li class="text-zinc-500 hover:text-emerald-300 transition">
+                            <NavLink href="@($"tags/{Uri.EscapeDataString(tag)}")"
+                                     class="block">
+                                └─ @tag (@count)
+                            </NavLink>
+                        </li>
+                    }
+                </ul>
+            </li>
+        </ul>
+    }
+</div>
+
+@code {
+    private Dictionary<string, int>? _tags;
+
+    protected override async Task OnInitializedAsync()
+    {
+        _tags = await TagRepository.GetTagsAsync();
+    }
+}

+ 81 - 0
Shared.Rcl/Components/TagPage.razor

@@ -0,0 +1,81 @@
+@page "/tags/{TagName}"
+
+@using RackPeek.Domain.Resources
+@using RackPeek.Domain.Resources.SystemResources
+
+@inject IResourceRepository ResourceRepository
+@inject NavigationManager Nav
+
+<PageTitle>Tag: @TagName</PageTitle>
+
+<div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
+
+    <!-- Header -->
+    <div class="space-y-2">
+        <h1 class="text-lg text-zinc-100">
+            Tag: <span class="text-emerald-400">@TagName</span>
+        </h1>
+    </div>
+
+    @if (_resources is null)
+    {
+        <div class="text-zinc-500">loading resources…</div>
+    }
+    else if (_resources.Count == 0)
+    {
+        <div class="text-zinc-500">no resources found for this tag</div>
+    }
+    else
+    {
+        <div class="space-y-3">
+            @foreach (var resource in _resources.OrderBy(r => r.Name))
+            {
+                <div class="border border-zinc-800 rounded p-3 bg-zinc-900 hover:border-emerald-700 transition">
+                    <NavLink href="@GetResourceUrl(resource)"
+                             class="block hover:text-emerald-300">
+
+                        <div class="flex justify-between items-center">
+                            <div class="text-zinc-100">
+                                @resource.Name
+                            </div>
+
+                            <div class="text-xs text-zinc-500 uppercase tracking-wide">
+                                @resource.Kind
+                            </div>
+                        </div>
+
+                    </NavLink>
+                </div>
+            }
+        </div>
+    }
+
+</div>
+
+@code {
+    [Parameter]
+    public string TagName { get; set; } = string.Empty;
+
+    private IReadOnlyList<Resource>? _resources;
+
+    protected override async Task OnParametersSetAsync()
+    {
+        var decoded = Uri.UnescapeDataString(TagName);
+        _resources = await ResourceRepository.GetByTagAsync(decoded);
+    }
+
+    private string GetResourceUrl(Resource resource)
+    {
+        if (resource.Kind == SystemResource.KindLabel)
+        {
+            return $"resources/systems/{resource.Name}";
+        }else if (resource.Kind == Service.KindLabel)
+        {
+            return $"resources/services/{resource.Name}";
+        }
+        else
+        {
+            return $"resources/hardware/{resource.Name}";
+        }
+    }
+}

+ 2 - 0
Shared.Rcl/Desktops/DesktopCardComponent.razor

@@ -216,6 +216,8 @@
             }
         </div>
 
+        <ResourceTagEditor Resource="Desktop" />
+
     </div>
     
     <div class="md:col-span-2">

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

@@ -151,7 +151,8 @@
                 }
             }
         </div>
-        
+        <ResourceTagEditor Resource="Firewall" />
+
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
 

+ 3 - 0
Shared.Rcl/Laptops/LaptopCardComponent.razor

@@ -181,7 +181,10 @@
             }
         </div>
 
+        
     </div>
+    <ResourceTagEditor Resource="Laptop" />
+
     <div class="md:col-span-2">
         <div class="text-zinc-400 mb-1">Notes</div>
 

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

@@ -148,7 +148,8 @@
                 }
             }
         </div>
-        
+        <ResourceTagEditor Resource="Router" />
+
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
 

+ 0 - 68
Shared.Rcl/Servers/AddServerComponent.razor

@@ -1,68 +0,0 @@
-@using RackPeek.Domain.Resources.Hardware.Servers
-@inject AddServerUseCase AddServer
-
-<div class="border border-zinc-800 rounded p-4 bg-zinc-900">
-    <div class="text-zinc-100 mb-3">
-        Add server
-    </div>
-
-    <div class="flex gap-2">
-        <input
-            class="flex-1 bg-zinc-950 border border-zinc-800 rounded px-3 py-2 text-sm text-zinc-200 placeholder-zinc-600 focus:outline-none focus:border-zinc-600"
-            placeholder="server name"
-            @bind="_name"
-            @bind:event="oninput"/>
-
-        <button
-            class="px-4 py-2 text-sm rounded bg-zinc-800 hover:bg-zinc-700 text-zinc-100 disabled:opacity-50"
-            disabled="@_isSubmitting"
-            @onclick="CreateAsync">
-            add
-        </button>
-    </div>
-
-    @if (_error is not null)
-    {
-        <div class="mt-2 text-sm text-red-400">
-            @_error
-        </div>
-    }
-</div>
-
-@code {
-    [Parameter] public EventCallback<string> OnCreated { get; set; }
-
-    private string _name = string.Empty;
-    private string? _error;
-    private bool _isSubmitting;
-
-    private async Task CreateAsync()
-    {
-        _error = null;
-
-        if (string.IsNullOrWhiteSpace(_name))
-        {
-            _error = "name is required";
-            return;
-        }
-
-        try
-        {
-            _isSubmitting = true;
-            var name = _name.Trim();
-            await AddServer.ExecuteAsync(name);
-            _name = string.Empty;
-
-            await OnCreated.InvokeAsync(name);
-        }
-        catch (Exception ex)
-        {
-            _error = ex.Message;
-        }
-        finally
-        {
-            _isSubmitting = false;
-        }
-    }
-
-}

+ 2 - 0
Shared.Rcl/Servers/ServerCardComponent.razor

@@ -225,6 +225,8 @@
 
 
     </div>
+    <ResourceTagEditor Resource="Server" />
+
     <div class="md:col-span-2">
         <div class="text-zinc-400 mb-1">Notes</div>
 

+ 4 - 2
Shared.Rcl/Servers/ServersListComponent.razor

@@ -7,8 +7,10 @@
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6 space-y-6">
 
-    <AddServerComponent OnCreated="NavigateToNewResource"/>
-
+    <AddResourceComponent TResource="Server"
+                          Placeholder="name"
+                          OnCreated="NavigateToNewResource" />
+    
     @if (_servers is null)
     {
         <div class="text-zinc-500">loading servers…</div>

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

@@ -174,6 +174,9 @@
                 </NavLink>
             }
         </div>
+        
+        <ResourceTagEditor Resource="Service" />
+
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
 

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

@@ -149,7 +149,8 @@
                 }
             }
         </div>
-        
+        <ResourceTagEditor Resource="Switch" />
+
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
 

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

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

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

@@ -101,7 +101,8 @@
                 <div class="text-zinc-300">@Ups.Va VA</div>
             }
         </div>
-        
+        <ResourceTagEditor Resource="Ups" />
+
         <div class="md:col-span-2">
             <div class="text-zinc-400 mb-1">Notes</div>
 

+ 0 - 46
Tests/HardwareResources/AccessPoints/AddAccessPointUseCaseTests.cs

@@ -1,46 +0,0 @@
-using NSubstitute;
-using RackPeek.Domain.Helpers;
-using RackPeek.Domain.Resources.Hardware.AccessPoints;
-using RackPeek.Domain.Resources.Models;
-
-namespace Tests.HardwareResources.AccessPoints;
-
-public class AddAccessPointUseCaseTests
-{
-    [Fact]
-    public async Task ExecuteAsync_Adds_new_ap_when_not_exists()
-    {
-        // Arrange
-        var host = new UsecaseTestHost();
-        var repo = host.HardwareRepo;
-        repo.GetByNameAsync("ap01").Returns((Hardware?)null);
-
-        var sut = host.Get<AddAccessPointUseCase>();
-
-        // Act
-        await sut.ExecuteAsync("ap01");
-
-        // Assert
-        await repo.Received(1).AddAsync(Arg.Is<AccessPoint>(ap =>
-            ap.Name == "ap01"
-        ));
-    }
-
-    [Fact]
-    public async Task ExecuteAsync_Throws_if_ap_already_exists()
-    {
-        // Arrange
-        var host = new UsecaseTestHost();
-        host.ResourceRepo.GetResourceKindAsync("ap01").Returns("Server");
-
-        var sut = host.Get<AddAccessPointUseCase>();
-
-        // Act
-        var ex = await Assert.ThrowsAsync<ConflictException>(async () =>
-            await sut.ExecuteAsync("ap01")
-        );
-
-        // Assert
-        await host.HardwareRepo.DidNotReceive().AddAsync(Arg.Any<AccessPoint>());
-    }
-}

+ 1 - 1
notes.md

@@ -28,7 +28,7 @@ chmod +x webui_capture.sh
 docker buildx build \
   --platform linux/amd64,linux/arm64 \
   -f ./Dockerfile \
-  -t aptacode/rackpeek:v0.0.6 \
+  -t aptacode/rackpeek:v0.0.9 \
   -t aptacode/rackpeek:latest \
   --push ..