Bläddra i källkod

Added system / service tree views to webui

Tim Jones 2 månader sedan
förälder
incheckning
3ced908d15
46 ändrade filer med 1025 tillägg och 659 borttagningar
  1. 0 52
      RackPeek.Domain/Resources/Hardware/Desktops/GetDesktopSystemTreeUseCase.cs
  2. 6 7
      RackPeek.Domain/Resources/Hardware/GetHardwareSystemTreeUseCase.cs
  3. 3 3
      RackPeek.Domain/Resources/Hardware/GetHardwareUseCaseSummary.cs
  4. 2 2
      RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs
  5. 2 2
      RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs
  6. 4 3
      RackPeek.Domain/Resources/Services/UseCases/GetServiceSummaryUseCase.cs
  7. 1 1
      RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs
  8. 1 1
      RackPeek.Domain/Resources/SystemResources/UseCases/GetSystemServiceTreeUseCase.cs
  9. 4 4
      RackPeek.Domain/Resources/SystemResources/UseCases/GetSystemSummaryUseCase.cs
  10. 64 0
      RackPeek.Web/Components/Components/HardwareDependencyTreeComponent.razor
  11. 2 5
      RackPeek.Web/Components/Components/ServerCardComponent.razor
  12. 3 2
      RackPeek.Web/Components/Components/ServersListComponent.razor
  13. 113 39
      RackPeek.Web/Components/Components/ServiceCardComponent.razor
  14. 26 0
      RackPeek.Web/Components/Components/ServiceEditModel.cs
  15. 17 3
      RackPeek.Web/Components/Components/ServicesListComponent.razor
  16. 115 38
      RackPeek.Web/Components/Components/SystemCardComponent.razor
  17. 40 0
      RackPeek.Web/Components/Components/SystemDependencyTreeComponent.razor
  18. 26 0
      RackPeek.Web/Components/Components/SystemEditModel.cs
  19. 18 4
      RackPeek.Web/Components/Components/SystemsListComponent.razor
  20. 6 6
      RackPeek.Web/Components/Layout/MainLayout.razor.css
  21. 9 4
      RackPeek.Web/Components/Layout/ReconnectModal.razor
  22. 20 21
      RackPeek.Web/Components/Layout/ReconnectModal.razor.css
  23. 23 4
      RackPeek.Web/Components/Pages/HardwareDetailsPage.razor
  24. 6 4
      RackPeek.Web/Components/Pages/HardwareTreePage.razor
  25. 18 12
      RackPeek.Web/Components/Pages/Home.razor
  26. 17 8
      RackPeek.Web/Components/Pages/ServiceDetailsPage.razor
  27. 35 5
      RackPeek.Web/Components/Pages/SystemsDetailsPage.razor
  28. 3 2
      RackPeek.Web/Components/Routes.razor
  29. 4 4
      RackPeek.Web/Program.cs
  30. 18 18
      RackPeek.Web/Properties/launchSettings.json
  31. 1 1
      RackPeek.Web/RackPeek.Web.csproj
  32. 144 143
      RackPeek.Web/config/Services.yaml
  33. 107 83
      RackPeek.Web/config/Systems.yaml
  34. 1 0
      RackPeek.Web/config/desktops.yaml
  35. 3 3
      RackPeek.Web/wwwroot/app.css
  36. 139 139
      RackPeek/CliBootstrap.cs
  37. 2 2
      RackPeek/Commands/Desktops/DesktopTreeCommand.cs
  38. 6 5
      RackPeek/Commands/GetTotalSummaryCommand.cs
  39. 2 2
      RackPeek/Commands/Servers/ServerTreeCommand.cs
  40. 2 1
      RackPeek/Commands/Services/ServiceDescribeCommand.cs
  41. 5 15
      RackPeek/Commands/Services/ServicesFormatExtensions.cs
  42. 1 6
      RackPeek/Yaml/YamlHardwareRepository.cs
  43. 1 1
      RackPeek/Yaml/YamlServiceRepository.cs
  44. 3 2
      RackPeek/Yaml/YamlSystemRepository.cs
  45. 1 1
      Tests/EndToEnd/DesktopYamlE2ETest.cs
  46. 1 1
      Tests/HardwareResources/Services/ServiceSubnetsCommandTests.cs

+ 0 - 52
RackPeek.Domain/Resources/Hardware/Desktops/GetDesktopSystemTreeUseCase.cs

@@ -1,52 +0,0 @@
-using RackPeek.Domain.Resources.Hardware.Models;
-using RackPeek.Domain.Resources.Services;
-using RackPeek.Domain.Resources.SystemResources;
-
-namespace RackPeek.Domain.Resources.Hardware.Desktops;
-
-public class GetDesktopSystemTreeUseCase(
-    IHardwareRepository hardwareRepository,
-    ISystemRepository systemRepository,
-    IServiceRepository serviceRepository) : IUseCase
-{
-    public async Task<HardwareDependencyTree?> ExecuteAsync(string hardwareName)
-    {
-        if (string.IsNullOrWhiteSpace(hardwareName))
-            return null;
-
-        var desktop = await hardwareRepository.GetByNameAsync(hardwareName) as Desktop;
-        if (desktop is null)
-            return null;
-
-        return await BuildDependencyTreeAsync(desktop);
-    }
-
-    private async Task<HardwareDependencyTree> BuildDependencyTreeAsync(Desktop desktop)
-    {
-        var systems = await systemRepository.GetByPhysicalHostAsync(desktop.Name);
-
-        var systemTrees = new List<SystemDependencyTree>();
-        foreach (var system in systems)
-            systemTrees.Add(await BuildSystemDependencyTreeAsync(system));
-
-        return new HardwareDependencyTree(desktop, systemTrees);
-    }
-
-    private async Task<SystemDependencyTree> BuildSystemDependencyTreeAsync(SystemResource system)
-    {
-        var services = await serviceRepository.GetBySystemHostAsync(system.Name);
-        return new SystemDependencyTree(system, services);
-    }
-}
-
-public sealed class HardwareDependencyTree(Desktop hardware, IEnumerable<SystemDependencyTree> systems)
-{
-    public Desktop Hardware { get; } = hardware;
-    public IEnumerable<SystemDependencyTree> Systems { get; } = systems;
-}
-
-public sealed class SystemDependencyTree(SystemResource system, IEnumerable<Service> services)
-{
-    public SystemResource System { get; } = system;
-    public IEnumerable<Service> Services { get; } = services;
-}

+ 6 - 7
RackPeek.Domain/Resources/Hardware/Servers/GetServerSystemTreeUseCase.cs → RackPeek.Domain/Resources/Hardware/GetHardwareSystemTreeUseCase.cs

@@ -1,23 +1,22 @@
-using RackPeek.Domain.Resources.Hardware.Models;
 using RackPeek.Domain.Resources.Services;
 using RackPeek.Domain.Resources.SystemResources;
 
-namespace RackPeek.Domain.Resources.Hardware.Servers;
+namespace RackPeek.Domain.Resources.Hardware;
 
-public class GetServerSystemTreeUseCase(
+public class GetHardwareSystemTreeUseCase(
     IHardwareRepository hardwareRepository,
     ISystemRepository systemRepository,
     IServiceRepository serviceRepository) : IUseCase
 {
     public async Task<HardwareDependencyTree?> ExecuteAsync(string hardwareName)
     {
-        var server = await hardwareRepository.GetByNameAsync(hardwareName) as Server;
+        var server = await hardwareRepository.GetByNameAsync(hardwareName);
         if (server is null) return null;
 
         return await BuildDependencyTreeAsync(server);
     }
 
-    private async Task<HardwareDependencyTree> BuildDependencyTreeAsync(Server server)
+    private async Task<HardwareDependencyTree> BuildDependencyTreeAsync(Models.Hardware server)
     {
         var systems = await systemRepository.GetByPhysicalHostAsync(server.Name);
 
@@ -35,9 +34,9 @@ public class GetServerSystemTreeUseCase(
     }
 }
 
-public sealed class HardwareDependencyTree(Server hardware, IEnumerable<SystemDependencyTree> systems)
+public sealed class HardwareDependencyTree(Models.Hardware hardware, IEnumerable<SystemDependencyTree> systems)
 {
-    public Server Hardware { get; } = hardware;
+    public Models.Hardware Hardware { get; } = hardware;
     public IEnumerable<SystemDependencyTree> Systems { get; } = systems;
 }
 

+ 3 - 3
RackPeek.Domain/Resources/Hardware/GetHardwareUseCaseSummary.cs

@@ -2,9 +2,6 @@ namespace RackPeek.Domain.Resources.Hardware;
 
 public sealed class HardwareSummary
 {
-    public int TotalHardware { get; }
-    public IReadOnlyDictionary<string, int> HardwareByKind { get; }
-
     public HardwareSummary(
         int totalHardware,
         IReadOnlyDictionary<string, int> hardwareByKind)
@@ -12,6 +9,9 @@ public sealed class HardwareSummary
         TotalHardware = totalHardware;
         HardwareByKind = hardwareByKind;
     }
+
+    public int TotalHardware { get; }
+    public IReadOnlyDictionary<string, int> HardwareByKind { get; }
 }
 
 public class GetHardwareUseCaseSummary(IHardwareRepository repository) : IUseCase

+ 2 - 2
RackPeek.Domain/Resources/Hardware/IHardwareRepository.cs

@@ -17,11 +17,11 @@ public class HardwareTree
 {
     public required string HardwareName { get; set; }
     public required string Kind { get; set; }
-    public required List<SystemTree> Systems {get; set;}
+    public required List<SystemTree> Systems { get; set; }
 }
 
 public class SystemTree
 {
     public required string SystemName { get; set; }
-    public required List<string> Services {get; set;}
+    public required List<string> Services { get; set; }
 }

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

@@ -24,9 +24,9 @@ public class DescribeServiceUseCase(IServiceRepository repository, ISystemReposi
         if (!string.IsNullOrEmpty(service.RunsOn))
         {
             var systemResource = await systemRepo.GetByNameAsync(service.RunsOn);
-            runsOnPhysicalHost = systemResource?.RunsOn;   
+            runsOnPhysicalHost = systemResource?.RunsOn;
         }
-        
+
         return new ServiceDescription(
             service.Name,
             service.Network?.Ip,

+ 4 - 3
RackPeek.Domain/Resources/Services/UseCases/GetServiceSummaryUseCase.cs

@@ -1,14 +1,15 @@
 namespace RackPeek.Domain.Resources.Services.UseCases;
+
 public sealed class AllServicesSummary
 {
-    public int TotalServices { get; }
-    public int TotalIpAddresses { get; }
-
     public AllServicesSummary(int totalServices, int totalIpAddresses)
     {
         TotalServices = totalServices;
         TotalIpAddresses = totalIpAddresses;
     }
+
+    public int TotalServices { get; }
+    public int TotalIpAddresses { get; }
 }
 
 public class GetServiceSummaryUseCase(IServiceRepository repository) : IUseCase

+ 1 - 1
RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs

@@ -1,4 +1,3 @@
-using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.SystemResources;
 
 namespace RackPeek.Domain.Resources.Services.UseCases;
@@ -31,6 +30,7 @@ public class ServiceReportUseCase(IServiceRepository repository, ISystemReposito
                 var systemResource = await systemRepo.GetByNameAsync(s.RunsOn);
                 runsOnPhysicalHost = systemResource?.RunsOn;
             }
+
             return new ServiceReportRow(
                 s.Name,
                 s.Network?.Ip,

+ 1 - 1
RackPeek.Domain/Resources/SystemResources/UseCases/GetSystemServiceTreeUseCase.cs

@@ -1,4 +1,4 @@
-using RackPeek.Domain.Resources.Hardware.Servers;
+using RackPeek.Domain.Resources.Hardware;
 using RackPeek.Domain.Resources.Services;
 
 namespace RackPeek.Domain.Resources.SystemResources.UseCases;

+ 4 - 4
RackPeek.Domain/Resources/SystemResources/UseCases/GetSystemSummaryUseCase.cs

@@ -2,10 +2,6 @@ namespace RackPeek.Domain.Resources.SystemResources.UseCases;
 
 public sealed class SystemSummary
 {
-    public int TotalSystems { get; }
-    public IReadOnlyDictionary<string, int> SystemsByType { get; }
-    public IReadOnlyDictionary<string, int> SystemsByOs { get; }
-
     public SystemSummary(
         int totalSystems,
         IReadOnlyDictionary<string, int> systemsByType,
@@ -15,6 +11,10 @@ public sealed class SystemSummary
         SystemsByType = systemsByType;
         SystemsByOs = systemsByOs;
     }
+
+    public int TotalSystems { get; }
+    public IReadOnlyDictionary<string, int> SystemsByType { get; }
+    public IReadOnlyDictionary<string, int> SystemsByOs { get; }
 }
 
 public class GetSystemSummaryUseCase(ISystemRepository repository) : IUseCase

+ 64 - 0
RackPeek.Web/Components/Components/HardwareDependencyTreeComponent.razor

@@ -0,0 +1,64 @@
+@using RackPeek.Domain.Resources.Hardware
+@if (Tree is null)
+{
+    <div class="text-zinc-500 text-sm">
+        No data.
+    </div>
+}
+else
+{
+    <div class="space-y-4">
+
+        <!-- Systems -->
+        <div class="ml-4 space-y-4 border-l border-zinc-800 pl-4">
+            @foreach (var systemTree in Tree.Systems)
+            {
+                <div class="space-y-2">
+
+                    <!-- System -->
+                    <NavLink href="@($"/resources/systems/{systemTree.System.Name}")" class="block">
+                        <div class="border border-zinc-800 rounded bg-zinc-900 p-3">
+                            <div class="text-zinc-100">
+                                @systemTree.System.Name
+                            </div>
+                            <div class="text-xs text-zinc-500 mt-1">
+                                System
+                            </div>
+                        </div>
+                    </NavLink>
+
+                    <!-- Services -->
+                    @if (systemTree.Services.Any())
+                    {
+                        <div class="ml-4 space-y-2 border-l border-zinc-800 pl-4">
+                            @foreach (var service in systemTree.Services)
+                            {
+                                <NavLink href="@($"/resources/services/{service.Name}")" class="block">
+                                    <div class="border border-zinc-800 rounded bg-zinc-900 p-2">
+                                        <div class="text-zinc-200 text-sm">
+                                            @service.Name
+                                        </div>
+                                        <div class="text-xs text-zinc-500 mt-1">
+                                            Service
+                                        </div>
+                                    </div>
+                                </NavLink>
+                            }
+                        </div>
+                    }
+                    else
+                    {
+                        <div class="ml-4 text-xs text-zinc-600 italic">
+                            No services
+                        </div>
+                    }
+                </div>
+            }
+        </div>
+
+    </div>
+}
+
+@code {
+    [Parameter][EditorRequired] public HardwareDependencyTree? Tree { get; set; }
+}

+ 2 - 5
RackPeek.Web/Components/Components/ServerCardComponent.razor

@@ -1,6 +1,4 @@
-@using RackPeek.Domain.Resources.Hardware.Models
-@using RackPeek.Domain.Resources.Hardware.Servers
-@typeparam TServer where TServer : Server
+@typeparam TServer where TServer : RackPeek.Domain.Resources.Hardware.Models.Server
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
@@ -82,6 +80,5 @@
 </div>
 
 @code {
-    [Parameter, EditorRequired]
-    public TServer Server { get; set; } = default!;
+    [Parameter][EditorRequired] public TServer Server { get; set; } = default!;
 }

+ 3 - 2
RackPeek.Web/Components/Components/ServersListComponent.razor

@@ -16,10 +16,10 @@
     else
     {
         <div class="space-y-4">
-            @foreach (var server in _servers)
+            @foreach (var server in _servers.OrderBy(s => s.Name))
             {
                 <NavLink href="@($"/resources/hardware/{server.Name}")" class="block">
-                    <ServerCardComponent Server="server" />
+                    <ServerCardComponent Server="server"/>
                 </NavLink>
             }
         </div>
@@ -33,4 +33,5 @@
     {
         _servers = await GetServers.ExecuteAsync();
     }
+
 }

+ 113 - 39
RackPeek.Web/Components/Components/ServiceCardComponent.razor

@@ -1,5 +1,4 @@
-@using RackPeek.Domain.Resources.Services
-@typeparam TService where TService : Service
+@typeparam TService where TService : RackPeek.Domain.Resources.Services.Service
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
@@ -7,60 +6,135 @@
             @Service.Name
         </div>
 
-        @if (!string.IsNullOrWhiteSpace(Service.RunsOn))
-        {
-            <NavLink href="@($"/resources/systems/{Service.RunsOn}")" class="block">
-                <span class="text-xs text-emerald-400">
-                    Runs on: @Service.RunsOn
-                </span>
-            </NavLink>
-        }
+        <div class="flex gap-3 text-xs">
+            @if (!_isEditing)
+            {
+                <button class="text-zinc-400 hover:text-zinc-200"
+                        @onclick="BeginEdit">
+                    Edit
+                </button>
+            }
+            else
+            {
+                <button class="text-emerald-400 hover:text-emerald-300"
+                        @onclick="Save">
+                    Save
+                </button>
+                <button class="text-zinc-500 hover:text-zinc-300"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+            }
+        </div>
     </div>
 
     <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
 
-        @if (Service.Network is not null)
-        {
-            @if (!string.IsNullOrWhiteSpace(Service.Network.Ip))
+        <!-- IP -->
+        <div>
+            <div class="text-zinc-400 mb-1">IP</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.Ip"/>
+            }
+            else if (!string.IsNullOrWhiteSpace(Service.Network?.Ip))
             {
-                <div>
-                    <div class="text-zinc-400 mb-1">IP</div>
-                    <div class="text-zinc-300">@Service.Network.Ip</div>
-                </div>
+                <div class="text-zinc-300">@Service.Network!.Ip</div>
             }
+        </div>
 
-            @if (Service.Network.Port.HasValue)
+        <!-- Port -->
+        <div>
+            <div class="text-zinc-400 mb-1">Port</div>
+            @if (_isEditing)
             {
-                <div>
-                    <div class="text-zinc-400 mb-1">Port</div>
-                    <div class="text-zinc-300">@Service.Network.Port</div>
-                </div>
+                <input type="number"
+                       class="input"
+                       @bind="_edit.Port"/>
             }
+            else if (Service.Network?.Port.HasValue == true)
+            {
+                <div class="text-zinc-300">@Service.Network.Port</div>
+            }
+        </div>
 
-            @if (!string.IsNullOrWhiteSpace(Service.Network.Protocol))
+        <!-- Protocol -->
+        <div>
+            <div class="text-zinc-400 mb-1">Protocol</div>
+            @if (_isEditing)
             {
-                <div>
-                    <div class="text-zinc-400 mb-1">Protocol</div>
-                    <div class="text-zinc-300">@Service.Network.Protocol</div>
-                </div>
+                <input class="input"
+                       @bind="_edit.Protocol"/>
             }
+            else if (!string.IsNullOrWhiteSpace(Service.Network?.Protocol))
+            {
+                <div class="text-zinc-300">@Service.Network!.Protocol</div>
+            }
+        </div>
 
-            @if (!string.IsNullOrWhiteSpace(Service.Network.Url))
+        <!-- URL -->
+        <div>
+            <div class="text-zinc-400 mb-1">URL</div>
+            @if (_isEditing)
             {
-                <div>
-                    <div class="text-zinc-400 mb-1">URL</div>
-                    <a href="@Service.Network.Url" target="_blank" rel="noopener noreferrer"
-                       class="text-emerald-400 hover:underline break-all">
-                        @Service.Network.Url
-                    </a>
-                </div>
+                <input class="input"
+                       @bind="_edit.Url"/>
             }
-        }
+            else if (!string.IsNullOrWhiteSpace(Service.Network?.Url))
+            {
+                <a href="@Service.Network!.Url"
+                   target="_blank"
+                   rel="noopener noreferrer"
+                   class="text-emerald-400 hover:underline break-all">
+                    @Service.Network.Url
+                </a>
+            }
+        </div>
+
+        <!-- Runs On -->
+        <div>
+            <div class="text-zinc-400 mb-1">Runs On</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.RunsOn"/>
+            }
+            else if (!string.IsNullOrWhiteSpace(Service.RunsOn))
+            {
+                <NavLink href="@($"/resources/systems/{Service.RunsOn}")"
+                         class="text-emerald-400">
+                    @Service.RunsOn
+                </NavLink>
+            }
+        </div>
 
     </div>
 </div>
 
 @code {
-    [Parameter, EditorRequired]
-    public TService Service { get; set; } = default!;
-}
+    [Parameter][EditorRequired] public TService Service { get; set; } = default!;
+
+    [Parameter] public EventCallback<ServiceEditModel> OnSave { get; set; }
+
+    private bool _isEditing;
+    private ServiceEditModel _edit = new();
+
+    void BeginEdit()
+    {
+        _edit = ServiceEditModel.From(Service);
+        _isEditing = true;
+    }
+
+    async Task Save()
+    {
+        _isEditing = false;
+        await OnSave.InvokeAsync(_edit);
+    }
+
+    void Cancel()
+    {
+        _isEditing = false;
+    }
+
+}

+ 26 - 0
RackPeek.Web/Components/Components/ServiceEditModel.cs

@@ -0,0 +1,26 @@
+using RackPeek.Domain.Resources.Services;
+
+namespace RackPeek.Web.Components.Components;
+
+public sealed class ServiceEditModel
+{
+    public string Name { get; init; } = default!;
+    public string? Ip { get; set; }
+    public int? Port { get; set; }
+    public string? Protocol { get; set; }
+    public string? Url { get; set; }
+    public string? RunsOn { get; set; }
+
+    public static ServiceEditModel From(Service s)
+    {
+        return new ServiceEditModel
+        {
+            Name = s.Name,
+            Ip = s.Network?.Ip,
+            Port = s.Network?.Port,
+            Protocol = s.Network?.Protocol,
+            Url = s.Network?.Url,
+            RunsOn = s.RunsOn
+        };
+    }
+}

+ 17 - 3
RackPeek.Web/Components/Components/ServicesListComponent.razor

@@ -1,6 +1,7 @@
 @using RackPeek.Domain.Resources.Services
+@using RackPeek.Domain.Resources.Services.UseCases
 @inject IServiceRepository ServiceRepository
-
+@inject UpdateServiceUseCase UpdateServiceUseCase
 <PageTitle>Services</PageTitle>
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
@@ -15,10 +16,10 @@
     else
     {
         <div class="space-y-4">
-            @foreach (var svc in _services)
+            @foreach (var svc in _services.OrderBy(s => s.Name))
             {
                 <NavLink href="@($"/resources/services/{svc.Name}")" class="block">
-                    <ServiceCardComponent TService="Service" Service="svc" />
+                    <ServiceCardComponent TService="Service" Service="svc" OnSave="UpdateService"/>
                 </NavLink>
             }
         </div>
@@ -32,4 +33,17 @@
     {
         _services = await ServiceRepository.GetAllAsync();
     }
+
+    async Task UpdateService(ServiceEditModel edit)
+    {
+        await UpdateServiceUseCase.ExecuteAsync(
+            edit.Name,
+            edit.Ip,
+            edit.Port,
+            edit.Protocol,
+            edit.Url,
+            edit.RunsOn
+        );
+    }
+
 }

+ 115 - 38
RackPeek.Web/Components/Components/SystemCardComponent.razor

@@ -1,5 +1,4 @@
-@using RackPeek.Domain.Resources.SystemResources
-@typeparam TSystem where TSystem : SystemResource
+@typeparam TSystem where TSystem : RackPeek.Domain.Resources.SystemResources.SystemResource
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900">
     <div class="flex justify-between items-center mb-3">
@@ -7,51 +6,107 @@
             @System.Name
         </div>
 
-        @if (!string.IsNullOrWhiteSpace(System.RunsOn))
-        {
-            <NavLink href="@($"/resources/hardware/{System.RunsOn}")" class="block">
-                <span class="text-xs text-emerald-400">
-                    Runs on: @System.RunsOn
-                </span>
-            </NavLink>
-        }
+        <div class="flex gap-3 text-xs">
+            @if (!_isEditing)
+            {
+                <button class="text-zinc-400 hover:text-zinc-200"
+                        @onclick="BeginEdit">
+                    Edit
+                </button>
+            }
+            else
+            {
+                <button class="text-emerald-400 hover:text-emerald-300"
+                        @onclick="Save">
+                    Save
+                </button>
+                <button class="text-zinc-500 hover:text-zinc-300"
+                        @onclick="Cancel">
+                    Cancel
+                </button>
+            }
+        </div>
     </div>
 
     <div class="grid grid-cols-1 md:grid-cols-2 gap-3 text-sm">
 
-        @if (!string.IsNullOrWhiteSpace(System.Type))
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">Type</div>
+        <!-- Type -->
+        <div>
+            <div class="text-zinc-400 mb-1">Type</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.Type"/>
+            }
+            else if (!string.IsNullOrWhiteSpace(System.Type))
+            {
                 <div class="text-zinc-300">@System.Type</div>
-            </div>
-        }
+            }
+        </div>
 
-        @if (!string.IsNullOrWhiteSpace(System.Os))
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">OS</div>
+        <!-- OS -->
+        <div>
+            <div class="text-zinc-400 mb-1">OS</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.Os"/>
+            }
+            else if (!string.IsNullOrWhiteSpace(System.Os))
+            {
                 <div class="text-zinc-300">@System.Os</div>
-            </div>
-        }
+            }
+        </div>
 
-        @if (System.Cores.HasValue)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">Cores</div>
+        <!-- Cores -->
+        <div>
+            <div class="text-zinc-400 mb-1">Cores</div>
+            @if (_isEditing)
+            {
+                <input type="number"
+                       class="input"
+                       @bind="_edit.Cores"/>
+            }
+            else if (System.Cores.HasValue)
+            {
                 <div class="text-zinc-300">@System.Cores</div>
-            </div>
-        }
+            }
+        </div>
 
-        @if (System.Ram.HasValue)
-        {
-            <div>
-                <div class="text-zinc-400 mb-1">RAM</div>
+        <!-- RAM -->
+        <div>
+            <div class="text-zinc-400 mb-1">RAM (GB)</div>
+            @if (_isEditing)
+            {
+                <input type="number"
+                       class="input"
+                       @bind="_edit.Ram"/>
+            }
+            else if (System.Ram.HasValue)
+            {
                 <div class="text-zinc-300">@System.Ram GB</div>
-            </div>
-        }
+            }
+        </div>
 
-        @if (System.Drives?.Any() == true)
+        <!-- Runs On -->
+        <div>
+            <div class="text-zinc-400 mb-1">Runs On</div>
+            @if (_isEditing)
+            {
+                <input class="input"
+                       @bind="_edit.RunsOn"/>
+            }
+            else if (!string.IsNullOrWhiteSpace(System.RunsOn))
+            {
+                <NavLink href="@($"/resources/hardware/{System.RunsOn}")"
+                         class="text-emerald-400 text-sm">
+                    @System.RunsOn
+                </NavLink>
+            }
+        </div>
+
+        <!-- Drives (read-only) -->
+        @if (!_isEditing && System.Drives?.Any() == true)
         {
             <div>
                 <div class="text-zinc-400 mb-1">Drives</div>
@@ -68,6 +123,28 @@
 </div>
 
 @code {
-    [Parameter, EditorRequired]
-    public TSystem System { get; set; } = default!;
-}
+    [Parameter][EditorRequired] public TSystem System { get; set; } = default!;
+
+    [Parameter] public EventCallback<SystemEditModel> OnSave { get; set; }
+
+    private bool _isEditing;
+    private SystemEditModel _edit = new();
+
+    void BeginEdit()
+    {
+        _edit = SystemEditModel.From(System);
+        _isEditing = true;
+    }
+
+    async Task Save()
+    {
+        _isEditing = false;
+        await OnSave.InvokeAsync(_edit);
+    }
+
+    void Cancel()
+    {
+        _isEditing = false;
+    }
+
+}

+ 40 - 0
RackPeek.Web/Components/Components/SystemDependencyTreeComponent.razor

@@ -0,0 +1,40 @@
+@using RackPeek.Domain.Resources.Hardware
+@if (Tree is null)
+{
+    <div class="text-zinc-500 text-sm">
+        No data.
+    </div>
+}
+else
+{
+    <div class="ml-4 border-l border-zinc-800 pl-4 space-y-2">
+
+        @if (Tree.Services.Any())
+        {
+            @foreach (var service in Tree.Services)
+            {
+                <NavLink href="@($"/resources/services/{service.Name}")" class="block">
+                    <div class="border border-zinc-800 rounded bg-zinc-900 p-2 hover:border-zinc-700">
+                        <div class="text-zinc-200 text-sm">
+                            @service.Name
+                        </div>
+                        <div class="text-xs text-zinc-500 mt-1">
+                            Service
+                        </div>
+                    </div>
+                </NavLink>
+            }
+        }
+        else
+        {
+            <div class="text-xs text-zinc-600 italic">
+                No services
+            </div>
+        }
+
+    </div>
+}
+
+@code {
+    [Parameter][EditorRequired] public SystemDependencyTree? Tree { get; set; }
+}

+ 26 - 0
RackPeek.Web/Components/Components/SystemEditModel.cs

@@ -0,0 +1,26 @@
+using RackPeek.Domain.Resources.SystemResources;
+
+namespace RackPeek.Web.Components.Components;
+
+public sealed class SystemEditModel
+{
+    public string Name { get; init; } = default!;
+    public string? Type { get; set; }
+    public string? Os { get; set; }
+    public int? Cores { get; set; }
+    public int? Ram { get; set; }
+    public string? RunsOn { get; set; }
+
+    public static SystemEditModel From(SystemResource system)
+    {
+        return new SystemEditModel
+        {
+            Name = system.Name,
+            Type = system.Type,
+            Os = system.Os,
+            Cores = system.Cores,
+            Ram = system.Ram,
+            RunsOn = system.RunsOn
+        };
+    }
+}

+ 18 - 4
RackPeek.Web/Components/Components/SystemsListComponent.razor

@@ -1,6 +1,7 @@
-@using RackPeek.Domain.Resources.Hardware.Models
-@using RackPeek.Domain.Resources.SystemResources
+@using RackPeek.Domain.Resources.SystemResources
+@using RackPeek.Domain.Resources.SystemResources.UseCases
 @inject ISystemRepository SystemRepository
+@inject UpdateSystemUseCase UpdateSystemUseCase
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
     @if (_systems is null)
@@ -14,10 +15,10 @@
     else
     {
         <div class="space-y-4">
-            @foreach (var system in _systems)
+            @foreach (var system in _systems.OrderBy(s => s.Name))
             {
                 <NavLink href="@($"/resources/systems/{system.Name}")" class="block">
-                    <SystemCardComponent System="system" />
+                    <SystemCardComponent System="system" OnSave="UpdateSystem"/>
                 </NavLink>
             }
         </div>
@@ -31,4 +32,17 @@
     {
         _systems = await SystemRepository.GetAllAsync();
     }
+
+    async Task UpdateSystem(SystemEditModel edit)
+    {
+        await UpdateSystemUseCase.ExecuteAsync(
+            edit.Name,
+            edit.Type,
+            edit.Os,
+            edit.Cores,
+            edit.Ram,
+            edit.RunsOn
+        );
+    }
+
 }

+ 6 - 6
RackPeek.Web/Components/Layout/MainLayout.razor.css

@@ -12,9 +12,9 @@
     z-index: 1000;
 }
 
-    #blazor-error-ui .dismiss {
-        cursor: pointer;
-        position: absolute;
-        right: 0.75rem;
-        top: 0.5rem;
-    }
+#blazor-error-ui .dismiss {
+    cursor: pointer;
+    position: absolute;
+    right: 0.75rem;
+    top: 0.5rem;
+}

+ 9 - 4
RackPeek.Web/Components/Layout/ReconnectModal.razor

@@ -1,4 +1,6 @@
-<dialog id="components-reconnect-modal" class="w-full max-w-md p-6 bg-zinc-900 text-zinc-200 font-mono rounded-lg border border-zinc-800 shadow-lg" data-nosnippet>
+<dialog id="components-reconnect-modal"
+        class="w-full max-w-md p-6 bg-zinc-900 text-zinc-200 font-mono rounded-lg border border-zinc-800 shadow-lg"
+        data-nosnippet>
     <div class="flex flex-col items-center space-y-4">
 
         <!-- Animated rejoin indicator -->
@@ -12,7 +14,8 @@
             Rejoining the server...
         </p>
         <p class="components-reconnect-repeated-attempt-visible text-sm text-amber-400">
-            Rejoin failed... trying again in <span id="components-seconds-to-next-attempt" class="font-bold"></span> seconds.
+            Rejoin failed... trying again in <span id="components-seconds-to-next-attempt" class="font-bold"></span>
+            seconds.
         </p>
         <p class="components-reconnect-failed-visible text-sm text-red-500 text-center">
             Failed to rejoin.<br/>Please retry or reload the page.
@@ -20,10 +23,12 @@
 
         <!-- Buttons -->
         <div class="flex space-x-3">
-            <button id="components-reconnect-button" class="components-reconnect-failed-visible px-4 py-1 bg-emerald-400 text-zinc-900 rounded hover:bg-emerald-500 transition">
+            <button id="components-reconnect-button"
+                    class="components-reconnect-failed-visible px-4 py-1 bg-emerald-400 text-zinc-900 rounded hover:bg-emerald-500 transition">
                 Retry
             </button>
-            <button id="components-resume-button" class="components-pause-visible px-4 py-1 bg-emerald-400 text-zinc-900 rounded hover:bg-emerald-500 transition">
+            <button id="components-resume-button"
+                    class="components-pause-visible px-4 py-1 bg-emerald-400 text-zinc-900 rounded hover:bg-emerald-500 transition">
                 Resume
             </button>
         </div>

+ 20 - 21
RackPeek.Web/Components/Layout/ReconnectModal.razor.css

@@ -31,12 +31,11 @@
     opacity: 0;
     transition: display 0.5s allow-discrete, overlay 0.5s allow-discrete;
     animation: components-reconnect-modal-fadeOutOpacity 0.5s both;
-    &[open]
 
-{
-    animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
-    animation-fill-mode: both;
-}
+    &[open] {
+        animation: components-reconnect-modal-slideUp 1.5s cubic-bezier(.05, .89, .25, 1.02) 0.3s, components-reconnect-modal-fadeInOpacity 0.5s ease-in-out 0.3s;
+        animation-fill-mode: both;
+    }
 
 }
 
@@ -96,13 +95,13 @@
     border-radius: 4px;
 }
 
-    #components-reconnect-modal button:hover {
-        background-color: #3b6ea2;
-    }
+#components-reconnect-modal button:hover {
+    background-color: #3b6ea2;
+}
 
-    #components-reconnect-modal button:active {
-        background-color: #6b9ed2;
-    }
+#components-reconnect-modal button:active {
+    background-color: #6b9ed2;
+}
 
 .components-rejoining-animation {
     position: relative;
@@ -110,17 +109,17 @@
     height: 80px;
 }
 
-    .components-rejoining-animation div {
-        position: absolute;
-        border: 3px solid #0087ff;
-        opacity: 1;
-        border-radius: 50%;
-        animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
-    }
+.components-rejoining-animation div {
+    position: absolute;
+    border: 3px solid #0087ff;
+    opacity: 1;
+    border-radius: 50%;
+    animation: components-rejoining-animation 1.5s cubic-bezier(0, 0.2, 0.8, 1) infinite;
+}
 
-        .components-rejoining-animation div:nth-child(2) {
-            animation-delay: -0.5s;
-        }
+.components-rejoining-animation div:nth-child(2) {
+    animation-delay: -0.5s;
+}
 
 @keyframes components-rejoining-animation {
     0% {

+ 23 - 4
RackPeek.Web/Components/Pages/HardwareDetailsPage.razor

@@ -3,7 +3,7 @@
 @using RackPeek.Domain.Resources.Hardware.Models
 @using RackPeek.Web.Components.Components
 @inject IHardwareRepository HardwareRepository
-
+@inject GetHardwareSystemTreeUseCase GetHardwareSystemTreeUseCase
 <PageTitle>Hardware Details</PageTitle>
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
@@ -23,7 +23,7 @@
 
         @if (_hardware is Server server)
         {
-            <ServerCardComponent Server="server" />
+            <ServerCardComponent Server="server"/>
         }
         else
         {
@@ -31,19 +31,38 @@
                 No detailed view for hardware type: @_hardware.Kind
             </div>
         }
+
+        @if (_tree is not null && _tree.Systems.Any())
+        {
+            <HardwareDependencyTreeComponent Tree="_tree"/>
+        }
+        else
+        {
+            <div class="text-zinc-400 m-2">
+                No child systems / services
+            </div>
+        }
     }
 </div>
 
 @code {
-    [Parameter]
-    public string HardwareName { get; set; } = string.Empty;
+    [Parameter] public string HardwareName { get; set; } = string.Empty;
 
     private Hardware? _hardware;
     private bool _loading = true;
+    HardwareDependencyTree? _tree;
 
     protected override async Task OnInitializedAsync()
     {
         _hardware = await HardwareRepository.GetByNameAsync(HardwareName);
+        _tree = null;
+        if (!string.IsNullOrEmpty(_hardware?.Name))
+        {
+            _tree = await GetHardwareSystemTreeUseCase.ExecuteAsync(_hardware?.Name!);
+        }
+
         _loading = false;
     }
+
+
 }

+ 6 - 4
RackPeek.Web/Components/Pages/HardwareTreePage.razor

@@ -17,9 +17,9 @@
     else
     {
         @foreach (var group in _tree
-            .OrderBy(h => h.Kind)
-            .ThenBy(h => h.HardwareName)
-            .GroupBy(h => h.Kind))
+                          .OrderBy(h => h.Kind)
+                          .ThenBy(h => h.HardwareName)
+                          .GroupBy(h => h.Kind))
         {
             <!-- Hardware Kind Header -->
             <div class="mb-6">
@@ -56,7 +56,8 @@
                                                 <ul class="ml-4 mt-1 space-y-1">
                                                     @foreach (var service in system.Services.OrderBy(s => s))
                                                     {
-                                                        <NavLink href="@($"/resources/services/{service}")" class="block">
+                                                        <NavLink href="@($"/resources/services/{service}")"
+                                                                 class="block">
 
                                                             <li class="text-zinc-500">
                                                                 > @service
@@ -84,4 +85,5 @@
     {
         _tree = await HardwareRepository.GetTreeAsync();
     }
+
 }

+ 18 - 12
RackPeek.Web/Components/Pages/Home.razor

@@ -1,8 +1,7 @@
 @page "/"
-
 @using RackPeek.Domain.Resources.Hardware
-@using RackPeek.Domain.Resources.SystemResources.UseCases
 @using RackPeek.Domain.Resources.Services.UseCases
+@using RackPeek.Domain.Resources.SystemResources.UseCases
 @inject GetSystemSummaryUseCase SystemSummaryUseCase
 @inject GetServiceSummaryUseCase ServiceSummaryUseCase
 @inject GetHardwareUseCaseSummary HardwareSummaryUseCase
@@ -25,15 +24,21 @@
                 </div>
 
                 <div class="grid grid-cols-2 gap-y-2">
-                    
-                        <div><NavLink href="@($"/hardware/tree")">Hardware </NavLink></div>
-                        <div class="text-right">@_hardware!.TotalHardware</div>
-                        
-                        <div><NavLink href="@($"/systems/list")">Systems </NavLink></div>
-                        <div class="text-right">@_system!.TotalSystems</div>
-                        
-                        <div> <NavLink href="@($"/servers/list")">Services  </NavLink></div>
-                        <div class="text-right">@_service!.TotalServices</div>
+
+                    <div>
+                        <NavLink href="@("/hardware/tree")">Hardware</NavLink>
+                    </div>
+                    <div class="text-right">@_hardware!.TotalHardware</div>
+
+                    <div>
+                        <NavLink href="@("/systems/list")">Systems</NavLink>
+                    </div>
+                    <div class="text-right">@_system!.TotalSystems</div>
+
+                    <div>
+                        <NavLink href="@("/servers/list")">Services</NavLink>
+                    </div>
+                    <div class="text-right">@_service!.TotalServices</div>
                 </div>
             </div>
         </div>
@@ -66,7 +71,7 @@
                 </ul>
             </div>
 
-            
+
             <!-- Systems -->
             <div>
                 <div class="text-xs text-zinc-500 uppercase tracking-wider mb-3">
@@ -148,4 +153,5 @@
 
         _loading = false;
     }
+
 }

+ 17 - 8
RackPeek.Web/Components/Pages/ServiceDetailsPage.razor

@@ -1,11 +1,9 @@
 @page "/resources/services/{ServiceName}"
-@using RackPeek.Domain.Resources.Hardware
-@using RackPeek.Domain.Resources.Hardware.Models
 @using RackPeek.Domain.Resources.Services
-@using RackPeek.Domain.Resources.SystemResources
+@using RackPeek.Domain.Resources.Services.UseCases
 @using RackPeek.Web.Components.Components
 @inject IServiceRepository ServiceRepository
-
+@inject UpdateServiceUseCase UpdateServiceUseCase
 <PageTitle>Service Details</PageTitle>
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
@@ -23,14 +21,12 @@
             @_service.Name (@_service.Kind)
         </h1>
 
-        <ServiceCardComponent Service="_service" />
-
+        <ServiceCardComponent Service="_service" OnSave="UpdateService"/>
     }
 </div>
 
 @code {
-    [Parameter]
-    public string ServiceName { get; set; } = string.Empty;
+    [Parameter] public string ServiceName { get; set; } = string.Empty;
 
     private Service? _service;
     private bool _loading = true;
@@ -40,4 +36,17 @@
         _service = await ServiceRepository.GetByNameAsync(ServiceName);
         _loading = false;
     }
+
+    async Task UpdateService(ServiceEditModel edit)
+    {
+        await UpdateServiceUseCase.ExecuteAsync(
+            edit.Name,
+            edit.Ip,
+            edit.Port,
+            edit.Protocol,
+            edit.Url,
+            edit.RunsOn
+        );
+    }
+
 }

+ 35 - 5
RackPeek.Web/Components/Pages/SystemsDetailsPage.razor

@@ -1,10 +1,11 @@
 @page "/resources/systems/{SystemName}"
 @using RackPeek.Domain.Resources.Hardware
-@using RackPeek.Domain.Resources.Hardware.Models
 @using RackPeek.Domain.Resources.SystemResources
+@using RackPeek.Domain.Resources.SystemResources.UseCases
 @using RackPeek.Web.Components.Components
 @inject ISystemRepository SystemRepository
-
+@inject UpdateSystemUseCase UpdateSystemUseCase
+@inject GetSystemServiceTreeUseCase GetSystemServiceTreeUseCase
 <PageTitle>System Details</PageTitle>
 
 <div class="min-h-screen bg-zinc-950 text-zinc-200 font-mono p-6">
@@ -22,21 +23,50 @@
             @_system.Name (@_system.Kind)
         </h1>
 
-        <SystemCardComponent System="_system" />
+        <SystemCardComponent System="_system" OnSave="UpdateSystem"/>
 
+        @if (_tree is not null && _tree.Services.Any())
+        {
+            <SystemDependencyTreeComponent Tree="_tree"/>
+        }
+        else
+        {
+            <div class="text-zinc-400 m-2">
+                No child systems / services
+            </div>
+        }
     }
 </div>
 
 @code {
-    [Parameter]
-    public string SystemName { get; set; } = string.Empty;
+    [Parameter] public string SystemName { get; set; } = string.Empty;
 
     private SystemResource? _system;
     private bool _loading = true;
+    SystemDependencyTree? _tree;
 
     protected override async Task OnInitializedAsync()
     {
         _system = await SystemRepository.GetByNameAsync(SystemName);
+        _tree = null;
+        if (!string.IsNullOrEmpty(_system?.Name))
+        {
+            _tree = await GetSystemServiceTreeUseCase.ExecuteAsync(_system?.Name!);
+        }
+
         _loading = false;
     }
+
+    async Task UpdateSystem(SystemEditModel edit)
+    {
+        await UpdateSystemUseCase.ExecuteAsync(
+            edit.Name,
+            edit.Type,
+            edit.Os,
+            edit.Cores,
+            edit.Ram,
+            edit.RunsOn
+        );
+    }
+
 }

+ 3 - 2
RackPeek.Web/Components/Routes.razor

@@ -1,5 +1,6 @@
-<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
+@using RackPeek.Web.Components.Pages
+<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(NotFound)">
     <Found Context="routeData">
-        <RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)"/>
+        <RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)"/>
     </Found>
 </Router>

+ 4 - 4
RackPeek.Web/Program.cs

@@ -20,7 +20,7 @@ public class Program
         );
 
         var yamlDir = "./config";
-        
+
         var collection = new YamlResourceCollection();
         var basePath = Directory.GetCurrentDirectory();
 
@@ -38,7 +38,7 @@ public class Program
         var yamlFiles = Directory.EnumerateFiles(yamlPath, "*.yml")
             .Concat(Directory.EnumerateFiles(yamlPath, "*.yaml"))
             .ToArray();
-        
+
         collection.LoadFiles(yamlFiles.Select(f => Path.Combine(basePath, f)));
 
         // Infrastructure
@@ -46,13 +46,13 @@ public class Program
         builder.Services.AddScoped<ISystemRepository>(_ => new YamlSystemRepository(collection));
         builder.Services.AddScoped<IServiceRepository>(_ => new YamlServiceRepository(collection));
 
-        
+
         builder.Services.AddUseCases();
 
         // Add services to the container.
         builder.Services.AddRazorComponents()
             .AddInteractiveServerComponents();
-        
+
         var app = builder.Build();
 
         // Configure the HTTP request pipeline.

+ 18 - 18
RackPeek.Web/Properties/launchSettings.json

@@ -1,23 +1,23 @@
 {
   "$schema": "https://json.schemastore.org/launchsettings.json",
-    "profiles": {
-      "http": {
-        "commandName": "Project",
-        "dotnetRunMessages": true,
-        "launchBrowser": true,
-        "applicationUrl": "http://localhost:5287",
-        "environmentVariables": {
-          "ASPNETCORE_ENVIRONMENT": "Development"
-        }
-      },
-      "https": {
-        "commandName": "Project",
-        "dotnetRunMessages": true,
-        "launchBrowser": true,
-        "applicationUrl": "https://localhost:7083;http://localhost:5287",
-        "environmentVariables": {
-          "ASPNETCORE_ENVIRONMENT": "Development"
-        }
+  "profiles": {
+    "http": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "applicationUrl": "http://localhost:5287",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
+      }
+    },
+    "https": {
+      "commandName": "Project",
+      "dotnetRunMessages": true,
+      "launchBrowser": true,
+      "applicationUrl": "https://localhost:7083;http://localhost:5287",
+      "environmentVariables": {
+        "ASPNETCORE_ENVIRONMENT": "Development"
       }
     }
   }
+}

+ 1 - 1
RackPeek.Web/RackPeek.Web.csproj

@@ -8,7 +8,7 @@
     </PropertyGroup>
 
     <ItemGroup>
-      <ProjectReference Include="..\RackPeek\RackPeek.csproj" />
+        <ProjectReference Include="..\RackPeek\RackPeek.csproj"/>
     </ItemGroup>
 
 </Project>

+ 144 - 143
RackPeek.Web/config/Services.yaml

@@ -1,585 +1,586 @@
 resources:
   - kind: Service
-    name: immich
-    network:
-      ip: 192.168.0.4
-      port: 8080
-      protocol: TCP
-      url: http://immich.lan:8080
-    runsOn: proxmox-host
-
-  - kind: Service
-    name: jellyfin
     network:
       ip: 192.168.0.10
       port: 8096
       protocol: TCP
       url: http://jellyfin.lan:8096
     runsOn: docker-host
-
+    name: jellyfin
+    tags:
   - kind: Service
-    name: plex
     network:
       ip: 192.168.0.11
       port: 32400
       protocol: TCP
       url: http://plex.lan:32400
     runsOn: proxmox-host
-
+    name: plex
+    tags:
   - kind: Service
-    name: home-assistant
     network:
       ip: 192.168.1.20
       port: 8123
       protocol: TCP
       url: http://ha.lan:8123
     runsOn: k8s-node-1
-
+    name: home-assistant
+    tags:
   - kind: Service
-    name: pihole
     network:
       ip: 192.168.1.2
       port: 53
       protocol: UDP
       url: http://pihole.lan/admin
     runsOn: baremetal-rpi4
-
+    name: pihole
+    tags:
   - kind: Service
-    name: unifi-controller
     network:
       ip: 192.168.1.5
       port: 8443
       protocol: TCP
       url: https://unifi.lan:8443
     runsOn: vm-cluster-1
-
+    name: unifi-controller
+    tags:
   - kind: Service
-    name: syncthing
     network:
       ip: 10.0.0.15
       port: 8384
       protocol: TCP
       url: http://sync.internal:8384
     runsOn: docker-host
-
+    name: syncthing
+    tags:
   - kind: Service
-    name: grafana
     network:
       ip: 10.0.0.20
       port: 3000
       protocol: TCP
       url: http://grafana.internal:3000
     runsOn: monitoring-node
-
+    name: grafana
+    tags:
   - kind: Service
-    name: prometheus
     network:
       ip: 10.0.0.21
       port: 9090
       protocol: TCP
       url: http://prometheus.internal:9090
     runsOn: monitoring-node
-
+    name: prometheus
+    tags:
   - kind: Service
-    name: loki
     network:
       ip: 10.0.0.22
       port: 3100
       protocol: TCP
       url: http://loki.internal:3100
     runsOn: monitoring-node
-
+    name: loki
+    tags:
   - kind: Service
-    name: minio
     network:
       ip: 172.16.0.10
       port: 9000
       protocol: TCP
       url: http://minio.storage:9000
     runsOn: storage-node-1
-
+    name: minio
+    tags:
   - kind: Service
-    name: nextcloud
     network:
       ip: 172.16.0.11
       port: 443
       protocol: TCP
       url: https://nextcloud.storage
     runsOn: storage-node-2
-
+    name: nextcloud
+    tags:
   - kind: Service
-    name: vaultwarden
     network:
       ip: 192.168.0.30
       port: 8081
       protocol: TCP
       url: http://vault.lan:8081
     runsOn: docker-host
-
+    name: vaultwarden
+    tags:
   - kind: Service
-    name: traefik
     network:
       ip: 192.168.0.2
       port: 80
       protocol: TCP
       url: http://traefik.lan
     runsOn: k8s-node-1
-
+    name: traefik
+    tags:
   - kind: Service
-    name: nginx-reverse-proxy
     network:
       ip: 192.168.0.3
       port: 443
       protocol: TCP
       url: https://proxy.lan
     runsOn: docker-host
-
+    name: nginx-reverse-proxy
+    tags:
   - kind: Service
-    name: qbittorrent
     network:
       ip: 192.168.0.40
       port: 8080
       protocol: TCP
       url: http://torrent.lan:8080
     runsOn: proxmox-host
-
+    name: qbittorrent
+    tags:
   - kind: Service
-    name: radarr
     network:
       ip: 192.168.0.41
       port: 7878
       protocol: TCP
       url: http://radarr.lan:7878
     runsOn: docker-host
-
+    name: radarr
+    tags:
   - kind: Service
-    name: sonarr
     network:
       ip: 192.168.0.42
       port: 8989
       protocol: TCP
       url: http://sonarr.lan:8989
     runsOn: docker-host
-
+    name: sonarr
+    tags:
   - kind: Service
-    name: prowlarr
     network:
       ip: 192.168.0.43
       port: 9696
       protocol: TCP
       url: http://prowlarr.lan:9696
     runsOn: docker-host
-
+    name: prowlarr
+    tags:
   - kind: Service
-    name: sabnzbd
     network:
       ip: 192.168.0.44
       port: 8085
       protocol: TCP
       url: http://sabnzbd.lan:8085
     runsOn: docker-host
-
+    name: sabnzbd
+    tags:
   - kind: Service
-    name: frigate
     network:
       ip: 192.168.1.30
       port: 5000
       protocol: TCP
       url: http://frigate.lan:5000
     runsOn: k8s-node-2
-
+    name: frigate
+    tags:
   - kind: Service
-    name: mosquitto-mqtt
     network:
       ip: 192.168.1.31
       port: 1883
       protocol: TCP
       url: mqtt://mqtt.lan:1883
     runsOn: docker-host
-
+    name: mosquitto-mqtt
+    tags:
   - kind: Service
-    name: zigbee2mqtt
     network:
       ip: 192.168.1.32
       port: 8080
       protocol: TCP
       url: http://z2m.lan:8080
     runsOn: docker-host
-
+    name: zigbee2mqtt
+    tags:
   - kind: Service
-    name: postgres-main
     network:
       ip: 10.0.1.10
       port: 5432
       protocol: TCP
       url: postgres://db.internal:5432
     runsOn: db-node-1
-
+    name: postgres-main
+    tags:
   - kind: Service
-    name: mariadb
     network:
       ip: 10.0.1.11
       port: 3306
       protocol: TCP
       url: mysql://mariadb.internal:3306
     runsOn: db-node-2
-
+    name: mariadb
+    tags:
   - kind: Service
-    name: redis-cache
     network:
       ip: 10.0.1.12
       port: 6379
       protocol: TCP
       url: redis://redis.internal:6379
     runsOn: cache-node
-
+    name: redis-cache
+    tags:
   - kind: Service
-    name: elasticsearch
     network:
       ip: 10.0.2.10
       port: 9200
       protocol: TCP
       url: http://es.internal:9200
     runsOn: search-node
-
+    name: elasticsearch
+    tags:
   - kind: Service
-    name: kibana
     network:
       ip: 10.0.2.11
       port: 5601
       protocol: TCP
       url: http://kibana.internal:5601
     runsOn: search-node
-
+    name: kibana
+    tags:
   - kind: Service
-    name: uptime-kuma
     network:
       ip: 192.168.0.50
       port: 3001
       protocol: TCP
       url: http://uptime.lan:3001
     runsOn: docker-host
-
+    name: uptime-kuma
+    tags:
   - kind: Service
-    name: wireguard-vpn
     network:
       ip: 192.168.1.100
       port: 51820
       protocol: UDP
       url: wg://vpn.lan
     runsOn: baremetal-rpi4
-
+    name: wireguard-vpn
+    tags:
   - kind: Service
-    name: openvpn
     network:
       ip: 192.168.1.101
       port: 1194
       protocol: UDP
       url: ovpn://openvpn.lan
     runsOn: vm-cluster-2
-
-  - kind: Service
-    name: adguard-home
-    network:
-      ip: 192.168.1.3
-      port: 3000
-      protocol: TCP
-      url: http://adguard.lan:3000
-    runsOn: docker-host
-
+    name: openvpn
+    tags:
   - kind: Service
-    name: gitlab
     network:
       ip: 10.0.3.10
       port: 443
       protocol: TCP
       url: https://gitlab.internal
     runsOn: dev-node-1
-
+    name: gitlab
+    tags:
   - kind: Service
-    name: gitea
     network:
       ip: 10.0.3.11
       port: 3000
       protocol: TCP
       url: http://gitea.internal:3000
     runsOn: dev-node-2
-
+    name: gitea
+    tags:
   - kind: Service
-    name: drone-ci
     network:
       ip: 10.0.3.12
       port: 8080
       protocol: TCP
       url: http://drone.internal:8080
     runsOn: dev-node-2
-
+    name: drone-ci
+    tags:
   - kind: Service
-    name: harbor-registry
     network:
       ip: 10.0.3.13
       port: 5000
       protocol: TCP
       url: http://harbor.internal:5000
     runsOn: dev-node-3
-
+    name: harbor-registry
+    tags:
   - kind: Service
-    name: kubernetes-api
     network:
       ip: 10.0.4.1
       port: 6443
       protocol: TCP
       url: https://k8s-api.internal:6443
     runsOn: k8s-control-plane
-
+    name: kubernetes-api
+    tags:
   - kind: Service
-    name: longhorn-ui
     network:
       ip: 10.0.4.20
       port: 9500
       protocol: TCP
       url: http://longhorn.internal:9500
     runsOn: k8s-node-3
-
+    name: longhorn-ui
+    tags:
   - kind: Service
-    name: rook-ceph-dashboard
     network:
       ip: 10.0.4.21
       port: 8443
       protocol: TCP
       url: https://ceph.internal:8443
     runsOn: k8s-node-3
-
+    name: rook-ceph-dashboard
+    tags:
   - kind: Service
-    name: samba-fileserver
     network:
       ip: 192.168.0.60
       port: 445
       protocol: TCP
       url: smb://fileserver.lan
     runsOn: storage-node-1
-
+    name: samba-fileserver
+    tags:
   - kind: Service
-    name: nfs-server
     network:
       ip: 192.168.0.61
       port: 2049
       protocol: TCP
       url: nfs://nfs.lan
     runsOn: dell-c6400-node01
-
+    name: nfs-server
+    tags:
   - kind: Service
-    name: iscsi-target
     network:
       ip: 172.16.1.10
       port: 3260
       protocol: TCP
       url: iscsi://iscsi.storage
     runsOn: storage-node-3
-
+    name: iscsi-target
+    tags:
   - kind: Service
-    name: calibre-web
     network:
       ip: 192.168.0.70
       port: 8083
       protocol: TCP
       url: http://books.lan:8083
     runsOn: docker-host
-
+    name: calibre-web
+    tags:
   - kind: Service
-    name: paperless-ngx
     network:
       ip: 192.168.0.71
       port: 8000
       protocol: TCP
       url: http://docs.lan:8000
     runsOn: dell-c6400-node01
-
+    name: paperless-ngx
+    tags:
   - kind: Service
-    name: openldap
     network:
       ip: 10.0.5.10
       port: 389
       protocol: TCP
       url: ldap://ldap.internal:389
     runsOn: dell-c6400-node01
-
+    name: openldap
+    tags:
   - kind: Service
-    name: keycloak
     network:
       ip: 10.0.5.11
       port: 8080
       protocol: TCP
       url: http://keycloak.internal:8080
     runsOn: dell-c6400-node01
-
+    name: keycloak
+    tags:
   - kind: Service
-    name: ntp-server
     network:
       ip: 192.168.1.50
       port: 123
       protocol: UDP
       url: ntp://ntp.lan
     runsOn: baremetal-rpi3
-
+    name: ntp-server
+    tags:
   - kind: Service
-    name: syslog-server
     network:
       ip: 10.0.6.10
       port: 514
       protocol: UDP
       url: syslog://syslog.internal
     runsOn: monitoring-node
-
+    name: syslog-server
+    tags:
   - kind: Service
-    name: dhcp-server
     network:
       ip: 192.168.1.1
       port: 67
       protocol: UDP
       url: dhcp://dhcp.lan
     runsOn: router-appliance
-
+    name: dhcp-server
+    tags:
   - kind: Service
-    name: bind-dns
     network:
       ip: 10.0.7.10
       port: 53
       protocol: UDP
       url: dns://dns.internal
     runsOn: infra-node
-
+    name: bind-dns
+    tags:
   - kind: Service
-    name: vault
     network:
       ip: 10.0.7.11
       port: 8200
       protocol: TCP
       url: http://vault.internal:8200
     runsOn: infra-node
-
+    name: vault
+    tags:
   - kind: Service
-    name: consul
     network:
       ip: 10.0.7.12
       port: 8500
       protocol: TCP
       url: http://consul.internal:8500
     runsOn: infra-node
-
+    name: consul
+    tags:
   - kind: Service
-    name: nomad
     network:
       ip: 10.0.7.13
       port: 4646
       protocol: TCP
       url: http://nomad.internal:4646
     runsOn: infra-node
-
+    name: nomad
+    tags:
   - kind: Service
-    name: openhab
     network:
       ip: 192.168.1.40
       port: 8080
       protocol: TCP
       url: http://openhab.lan:8080
     runsOn: k8s-node-2
-
+    name: openhab
+    tags:
   - kind: Service
-    name: mqtt-explorer
     network:
       ip: 192.168.1.41
       port: 4000
       protocol: TCP
       url: http://mqtt-explorer.lan:4000
     runsOn: docker-host
-
+    name: mqtt-explorer
+    tags:
   - kind: Service
-    name: influxdb
     network:
       ip: 10.0.8.10
       port: 8086
       protocol: TCP
       url: http://influx.internal:8086
     runsOn: monitoring-node
-
+    name: influxdb
+    tags:
   - kind: Service
-    name: telegraf
     network:
       ip: 10.0.8.11
       port: 8125
       protocol: UDP
       url: statsd://telegraf.internal
     runsOn: monitoring-node
-
+    name: telegraf
+    tags:
   - kind: Service
-    name: speedtest-tracker
     network:
       ip: 192.168.0.80
       port: 8080
       protocol: TCP
       url: http://speedtest.lan:8080
     runsOn: docker-host
-
+    name: speedtest-tracker
+    tags:
   - kind: Service
-    name: navidrome
     network:
       ip: 192.168.0.81
       port: 4533
       protocol: TCP
       url: http://music.lan:4533
     runsOn: docker-host
-
+    name: navidrome
+    tags:
   - kind: Service
-    name: photoprism
     network:
       ip: 192.168.0.82
       port: 2342
       protocol: TCP
       url: http://photos.lan:2342
     runsOn: docker-host
-
+    name: photoprism
+    tags:
   - kind: Service
-    name: dnsdist
     network:
       ip: 10.0.9.10
       port: 53
       protocol: UDP
       url: dns://dnsdist.internal
     runsOn: infra-node
-
+    name: dnsdist
+    tags:
   - kind: Service
-    name: powerdns
     network:
       ip: 10.0.9.11
       port: 8081
       protocol: TCP
       url: http://pdns.internal:8081
     runsOn: infra-node
-
+    name: powerdns
+    tags:
   - kind: Service
-    name: openproject
     network:
       ip: 10.0.10.10
       port: 8080
       protocol: TCP
       url: http://openproject.internal:8080
     runsOn: dev-node-3
-
+    name: openproject
+    tags:
   - kind: Service
-    name: mattermost
     network:
       ip: 10.0.10.11
       port: 8065
       protocol: TCP
       url: http://chat.internal:8065
     runsOn: dev-node-3
-
+    name: mattermost
+    tags:
   - kind: Service
-    name: rocket-chat
     network:
       ip: 10.0.10.12
       port: 3000
       protocol: TCP
       url: http://rocket.internal:3000
     runsOn: dev-node-3
+    name: rocket-chat
+    tags:
+  - kind: Service
+    network:
+      ip: 192.168.0.4
+      port: 80801
+      protocol: TCP
+      url: http://immich.lan:8080
+    runsOn: proxmox-host
+    name: immich
+    tags:
+  - kind: Service
+    network:
+      ip: 192.168.1.3
+      port: 3002
+      protocol: TCP
+      url: http://adguard.lan:3002
+    runsOn: docker-host
+    name: adguard-home
+    tags: 

+ 107 - 83
RackPeek.Web/config/Systems.yaml

@@ -1,184 +1,208 @@
 resources:
-  - kind: System
-    type: Hypervisor
-    name: proxmox-host
-    os: proxmox
-    cores: 16
-    ram: 64gb
-    runsOn: dell-c6400-node01
-
-  - kind: System
-    type: ContainerHost
-    name: docker-host
-    os: ubuntu
-    cores: 12
-    ram: 32gb
-    runsOn: dell-c6400-node01
-
   - kind: System
     type: KubernetesNode
-    name: k8s-node-1
     os: ubuntu
     cores: 8
-    ram: 32gb
+    ram: 32
+    drives:
     runsOn: dell-c6400-node01
-
+    name: k8s-node-1
+    tags:
   - kind: System
     type: KubernetesNode
-    name: k8s-node-2
     os: ubuntu
     cores: 8
-    ram: 32gb
+    ram: 32
+    drives:
     runsOn: dell-c6400-node01
-
+    name: k8s-node-2
+    tags:
   - kind: System
     type: KubernetesNode
-    name: k8s-node-3
     os: ubuntu
     cores: 8
-    ram: 32gb
+    ram: 32
+    drives:
     runsOn: dell-c6400-node01
-
+    name: k8s-node-3
+    tags:
   - kind: System
     type: KubernetesControlPlane
-    name: k8s-control-plane
     os: ubuntu
     cores: 4
-    ram: 16gb
+    ram: 16
+    drives:
     runsOn: dell-c6400-node01
-
+    name: k8s-control-plane
+    tags:
   - kind: System
     type: Monitoring
-    name: monitoring-node
     os: ubuntu
     cores: 8
-    ram: 32gb
+    ram: 32
+    drives:
     runsOn: dell-c6400-node01
-
+    name: monitoring-node
+    tags:
   - kind: System
     type: Storage
-    name: storage-node-1
     os: truenas
     cores: 8
-    ram: 64gb
+    ram: 64
+    drives:
     runsOn: dell-c6400-node01
-
+    name: storage-node-1
+    tags:
   - kind: System
     type: Storage
-    name: storage-node-2
     os: truenas
     cores: 8
-    ram: 64gb
+    ram: 64
+    drives:
     runsOn: dell-c6400-node01
-
+    name: storage-node-2
+    tags:
   - kind: System
     type: Storage
-    name: storage-node-3
     os: truenas
     cores: 8
-    ram: 64gb
+    ram: 64
+    drives:
     runsOn: dell-c6400-node01
-
+    name: storage-node-3
+    tags:
   - kind: System
     type: Database
-    name: db-node-1
     os: ubuntu
     cores: 8
-    ram: 32gb
+    ram: 32
+    drives:
     runsOn: dell-c6400-node01
-
+    name: db-node-1
+    tags:
   - kind: System
     type: Database
-    name: db-node-2
     os: ubuntu
     cores: 8
-    ram: 32gb
+    ram: 32
+    drives:
     runsOn: dell-c6400-node01
-
+    name: db-node-2
+    tags:
   - kind: System
     type: Cache
-    name: cache-node
     os: ubuntu
     cores: 4
-    ram: 16gb
+    ram: 16
+    drives:
     runsOn: dell-c6400-node01
-
+    name: cache-node
+    tags:
   - kind: System
     type: Search
-    name: search-node
     os: ubuntu
     cores: 8
-    ram: 32gb
+    ram: 32
+    drives:
     runsOn: dell-c6400-node01
-
+    name: search-node
+    tags:
   - kind: System
     type: Development
-    name: dev-node-1
     os: ubuntu
     cores: 4
-    ram: 16gb
+    ram: 16
+    drives:
     runsOn: dell-c6400-node01
-
+    name: dev-node-1
+    tags:
   - kind: System
     type: Development
-    name: dev-node-2
     os: ubuntu
     cores: 4
-    ram: 16gb
+    ram: 16
+    drives:
     runsOn: dell-c6400-node01
-
+    name: dev-node-2
+    tags:
   - kind: System
     type: Development
-    name: dev-node-3
     os: ubuntu
     cores: 6
-    ram: 24gb
+    ram: 24
+    drives:
     runsOn: dell-c6400-node01
-
+    name: dev-node-3
+    tags:
   - kind: System
     type: VirtualMachineCluster
-    name: vm-cluster-1
     os: proxmox
     cores: 12
-    ram: 48gb
+    ram: 48
+    drives:
     runsOn: dell-c6400-node01
-
-  - kind: System
-    type: VirtualMachineCluster
-    name: vm-cluster-2
-    os: proxmox
-    cores: 12
-    ram: 48gb
-    runsOn: dell-c6400-node01
-
+    name: vm-cluster-1
+    tags:
   - kind: System
     type: BareMetal
-    name: baremetal-rpi4
     os: raspbian
     cores: 4
-    ram: 8gb
+    ram: 8
+    drives:
     runsOn: rack-edge
-
+    name: baremetal-rpi4
+    tags:
   - kind: System
     type: BareMetal
-    name: baremetal-rpi3
     os: raspbian
     cores: 4
-    ram: 4gb
+    ram: 4
+    drives:
     runsOn: rack-edge
-
+    name: baremetal-rpi3
+    tags:
   - kind: System
     type: Infrastructure
-    name: infra-node
     os: ubuntu
     cores: 4
-    ram: 16gb
+    ram: 16
+    drives:
     runsOn: dell-c6400-node01
-
+    name: infra-node
+    tags:
   - kind: System
     type: NetworkAppliance
-    name: router-appliance
     os: openwrt
     cores: 2
-    ram: 2gb
+    ram: 2
+    drives:
     runsOn: network-rack
+    name: router-appliance
+    tags:
+  - kind: System
+    type: Hypervisor
+    os: proxmox
+    cores: 16
+    ram: 61
+    drives:
+    runsOn: dell-c6400-node01
+    name: proxmox-host
+    tags:
+  - kind: System
+    type: ContainerHost
+    os: ubuntu
+    cores: 12
+    ram: 26
+    drives:
+    runsOn: dell-c6400-node01
+    name: docker-host
+    tags:
+  - kind: System
+    type: VirtualMachineCluster
+    os: proxmox
+    cores: 13
+    ram: 44
+    drives:
+    runsOn: dell-c6400-node01
+    name: vm-cluster-2
+    tags: 

+ 1 - 0
RackPeek.Web/config/desktops.yaml

@@ -17,5 +17,6 @@ resources:
     gpus:
       - model: RTX 3080
         vram: 12
+    model:
     name: dell-optiplex
     tags: 

+ 3 - 3
RackPeek.Web/wwwroot/app.css

@@ -20,9 +20,9 @@ h1:focus {
     color: white;
 }
 
-    .blazor-error-boundary::after {
-        content: "An error has occurred."
-    }
+.blazor-error-boundary::after {
+    content: "An error has occurred."
+}
 
 .darker-border-checkbox.form-check-input {
     border-color: #929292;

+ 139 - 139
RackPeek/CliBootstrap.cs

@@ -53,7 +53,7 @@ public static class CliBootstrap
         var yamlFiles = Directory.EnumerateFiles(yamlPath, "*.yml")
             .Concat(Directory.EnumerateFiles(yamlPath, "*.yaml"))
             .ToArray();
-        
+
         collection.LoadFiles(yamlFiles.Select(f => Path.Combine(basePath, f)));
 
         // Infrastructure
@@ -156,191 +156,191 @@ public static class CliBootstrap
                     });
                 });
             });
-                
+
             config.AddBranch("switches", switches =>
-                {
-                    switches.SetDescription("Manage switches");
+            {
+                switches.SetDescription("Manage switches");
 
-                    switches.AddCommand<SwitchReportCommand>("summary")
-                        .WithDescription("Show switch hardware report");
+                switches.AddCommand<SwitchReportCommand>("summary")
+                    .WithDescription("Show switch hardware report");
 
-                    switches.AddCommand<SwitchAddCommand>("add")
-                        .WithDescription("Add a new switch");
+                switches.AddCommand<SwitchAddCommand>("add")
+                    .WithDescription("Add a new switch");
 
-                    switches.AddCommand<SwitchGetCommand>("list")
-                        .WithDescription("List switches");
+                switches.AddCommand<SwitchGetCommand>("list")
+                    .WithDescription("List switches");
 
-                    switches.AddCommand<SwitchGetByNameCommand>("get")
-                        .WithDescription("Get a switches by name");
+                switches.AddCommand<SwitchGetByNameCommand>("get")
+                    .WithDescription("Get a switches by name");
 
-                    switches.AddCommand<SwitchDescribeCommand>("describe")
-                        .WithDescription("Show detailed information about a switch");
+                switches.AddCommand<SwitchDescribeCommand>("describe")
+                    .WithDescription("Show detailed information about a switch");
 
-                    switches.AddCommand<SwitchSetCommand>("set")
-                        .WithDescription("Update switch properties");
+                switches.AddCommand<SwitchSetCommand>("set")
+                    .WithDescription("Update switch properties");
 
-                    switches.AddCommand<SwitchDeleteCommand>("del")
-                        .WithDescription("Delete a switch");
-                });
+                switches.AddCommand<SwitchDeleteCommand>("del")
+                    .WithDescription("Delete a switch");
+            });
 
-                config.AddBranch("systems", system =>
-                {
-                    system.SetDescription("Manage systems");
+            config.AddBranch("systems", system =>
+            {
+                system.SetDescription("Manage systems");
 
-                    system.AddCommand<SystemReportCommand>("summary")
-                        .WithDescription("Show system report");
+                system.AddCommand<SystemReportCommand>("summary")
+                    .WithDescription("Show system report");
 
-                    system.AddCommand<SystemAddCommand>("add")
-                        .WithDescription("Add a new system");
+                system.AddCommand<SystemAddCommand>("add")
+                    .WithDescription("Add a new system");
 
-                    system.AddCommand<SystemGetCommand>("list")
-                        .WithDescription("List systems");
+                system.AddCommand<SystemGetCommand>("list")
+                    .WithDescription("List systems");
 
-                    system.AddCommand<SystemGetByNameCommand>("get")
-                        .WithDescription("Get a system by name");
+                system.AddCommand<SystemGetByNameCommand>("get")
+                    .WithDescription("Get a system by name");
 
-                    system.AddCommand<SystemDescribeCommand>("describe")
-                        .WithDescription("Show detailed information about a system");
+                system.AddCommand<SystemDescribeCommand>("describe")
+                    .WithDescription("Show detailed information about a system");
 
-                    system.AddCommand<SystemSetCommand>("set")
-                        .WithDescription("Update system properties");
+                system.AddCommand<SystemSetCommand>("set")
+                    .WithDescription("Update system properties");
 
-                    system.AddCommand<SystemDeleteCommand>("del")
-                        .WithDescription("Delete a system");
+                system.AddCommand<SystemDeleteCommand>("del")
+                    .WithDescription("Delete a system");
 
-                    system.AddCommand<SystemTreeCommand>("tree")
-                        .WithDescription("Displays a dependency tree for the system.");
-                });
+                system.AddCommand<SystemTreeCommand>("tree")
+                    .WithDescription("Displays a dependency tree for the system.");
+            });
 
-                config.AddBranch("accesspoints", ap =>
-                {
-                    ap.SetDescription("Manage access points");
+            config.AddBranch("accesspoints", ap =>
+            {
+                ap.SetDescription("Manage access points");
 
-                    ap.AddCommand<AccessPointReportCommand>("summary")
-                        .WithDescription("Show access point hardware report");
+                ap.AddCommand<AccessPointReportCommand>("summary")
+                    .WithDescription("Show access point hardware report");
 
-                    ap.AddCommand<AccessPointAddCommand>("add")
-                        .WithDescription("Add a new access point");
+                ap.AddCommand<AccessPointAddCommand>("add")
+                    .WithDescription("Add a new access point");
 
-                    ap.AddCommand<AccessPointGetCommand>("list")
-                        .WithDescription("List access points");
+                ap.AddCommand<AccessPointGetCommand>("list")
+                    .WithDescription("List access points");
 
-                    ap.AddCommand<AccessPointGetByNameCommand>("get")
-                        .WithDescription("Get an access point by name");
+                ap.AddCommand<AccessPointGetByNameCommand>("get")
+                    .WithDescription("Get an access point by name");
 
-                    ap.AddCommand<AccessPointDescribeCommand>("describe")
-                        .WithDescription("Show detailed information about an access point");
+                ap.AddCommand<AccessPointDescribeCommand>("describe")
+                    .WithDescription("Show detailed information about an access point");
 
-                    ap.AddCommand<AccessPointSetCommand>("set")
-                        .WithDescription("Update access point properties");
+                ap.AddCommand<AccessPointSetCommand>("set")
+                    .WithDescription("Update access point properties");
 
-                    ap.AddCommand<AccessPointDeleteCommand>("del")
-                        .WithDescription("Delete an access point");
-                });
+                ap.AddCommand<AccessPointDeleteCommand>("del")
+                    .WithDescription("Delete an access point");
+            });
 
-                config.AddBranch("ups", ups =>
-                {
-                    ups.SetDescription("Manage UPS units");
+            config.AddBranch("ups", ups =>
+            {
+                ups.SetDescription("Manage UPS units");
 
-                    ups.AddCommand<UpsReportCommand>("summary")
-                        .WithDescription("Show UPS hardware report");
+                ups.AddCommand<UpsReportCommand>("summary")
+                    .WithDescription("Show UPS hardware report");
 
-                    ups.AddCommand<UpsAddCommand>("add")
-                        .WithDescription("Add a new UPS");
+                ups.AddCommand<UpsAddCommand>("add")
+                    .WithDescription("Add a new UPS");
 
-                    ups.AddCommand<UpsGetCommand>("list")
-                        .WithDescription("List UPS units");
+                ups.AddCommand<UpsGetCommand>("list")
+                    .WithDescription("List UPS units");
 
-                    ups.AddCommand<UpsGetByNameCommand>("get")
-                        .WithDescription("Get a UPS by name");
+                ups.AddCommand<UpsGetByNameCommand>("get")
+                    .WithDescription("Get a UPS by name");
 
-                    ups.AddCommand<UpsDescribeCommand>("describe")
-                        .WithDescription("Show detailed information about a UPS");
+                ups.AddCommand<UpsDescribeCommand>("describe")
+                    .WithDescription("Show detailed information about a UPS");
 
-                    ups.AddCommand<UpsSetCommand>("set")
-                        .WithDescription("Update UPS properties");
+                ups.AddCommand<UpsSetCommand>("set")
+                    .WithDescription("Update UPS properties");
 
-                    ups.AddCommand<UpsDeleteCommand>("del")
-                        .WithDescription("Delete a UPS");
-                });
+                ups.AddCommand<UpsDeleteCommand>("del")
+                    .WithDescription("Delete a UPS");
+            });
 
-                config.AddBranch("desktops", desktops =>
+            config.AddBranch("desktops", desktops =>
+            {
+                // CRUD
+                desktops.AddCommand<DesktopAddCommand>("add");
+                desktops.AddCommand<DesktopGetCommand>("list");
+                desktops.AddCommand<DesktopGetByNameCommand>("get");
+                desktops.AddCommand<DesktopDescribeCommand>("describe");
+                desktops.AddCommand<DesktopSetCommand>("set");
+                desktops.AddCommand<DesktopDeleteCommand>("del");
+                desktops.AddCommand<DesktopReportCommand>("summary")
+                    .WithDescription("Show desktop hardware report");
+                desktops.AddCommand<DesktopTreeCommand>("tree");
+
+
+                // CPU
+                desktops.AddBranch("cpu", cpu =>
                 {
-                    // CRUD
-                    desktops.AddCommand<DesktopAddCommand>("add");
-                    desktops.AddCommand<DesktopGetCommand>("list");
-                    desktops.AddCommand<DesktopGetByNameCommand>("get");
-                    desktops.AddCommand<DesktopDescribeCommand>("describe");
-                    desktops.AddCommand<DesktopSetCommand>("set");
-                    desktops.AddCommand<DesktopDeleteCommand>("del");
-                    desktops.AddCommand<DesktopReportCommand>("summary")
-                        .WithDescription("Show desktop hardware report");
-                    desktops.AddCommand<DesktopTreeCommand>("tree");
-
-
-                    // CPU
-                    desktops.AddBranch("cpu", cpu =>
-                    {
-                        cpu.AddCommand<DesktopCpuAddCommand>("add");
-                        cpu.AddCommand<DesktopCpuSetCommand>("set");
-                        cpu.AddCommand<DesktopCpuRemoveCommand>("del");
-                    });
-
-                    // Drives
-                    desktops.AddBranch("drive", drive =>
-                    {
-                        drive.AddCommand<DesktopDriveAddCommand>("add");
-                        drive.AddCommand<DesktopDriveSetCommand>("set");
-                        drive.AddCommand<DesktopDriveRemoveCommand>("del");
-                    });
+                    cpu.AddCommand<DesktopCpuAddCommand>("add");
+                    cpu.AddCommand<DesktopCpuSetCommand>("set");
+                    cpu.AddCommand<DesktopCpuRemoveCommand>("del");
+                });
 
-                    // GPUs
-                    desktops.AddBranch("gpu", gpu =>
-                    {
-                        gpu.AddCommand<DesktopGpuAddCommand>("add");
-                        gpu.AddCommand<DesktopGpuSetCommand>("set");
-                        gpu.AddCommand<DesktopGpuRemoveCommand>("del");
-                    });
+                // Drives
+                desktops.AddBranch("drive", drive =>
+                {
+                    drive.AddCommand<DesktopDriveAddCommand>("add");
+                    drive.AddCommand<DesktopDriveSetCommand>("set");
+                    drive.AddCommand<DesktopDriveRemoveCommand>("del");
+                });
 
-                    // NICs
-                    desktops.AddBranch("nic", nic =>
-                    {
-                        nic.AddCommand<DesktopNicAddCommand>("add");
-                        nic.AddCommand<DesktopNicSetCommand>("set");
-                        nic.AddCommand<DesktopNicRemoveCommand>("del");
-                    });
+                // GPUs
+                desktops.AddBranch("gpu", gpu =>
+                {
+                    gpu.AddCommand<DesktopGpuAddCommand>("add");
+                    gpu.AddCommand<DesktopGpuSetCommand>("set");
+                    gpu.AddCommand<DesktopGpuRemoveCommand>("del");
                 });
 
-                config.AddBranch("services", service =>
+                // NICs
+                desktops.AddBranch("nic", nic =>
                 {
-                    service.SetDescription(
-                        "Manage services."
-                    );
+                    nic.AddCommand<DesktopNicAddCommand>("add");
+                    nic.AddCommand<DesktopNicSetCommand>("set");
+                    nic.AddCommand<DesktopNicRemoveCommand>("del");
+                });
+            });
 
-                    service.AddCommand<ServiceReportCommand>("summary")
-                        .WithDescription("Show service summary report");
+            config.AddBranch("services", service =>
+            {
+                service.SetDescription(
+                    "Manage services."
+                );
 
-                    service.AddCommand<ServiceAddCommand>("add")
-                        .WithDescription("Add a new service");
+                service.AddCommand<ServiceReportCommand>("summary")
+                    .WithDescription("Show service summary report");
 
-                    service.AddCommand<ServiceGetCommand>("list")
-                        .WithDescription("List all services");
+                service.AddCommand<ServiceAddCommand>("add")
+                    .WithDescription("Add a new service");
 
-                    service.AddCommand<ServiceGetByNameCommand>("get")
-                        .WithDescription("Get a service by name");
+                service.AddCommand<ServiceGetCommand>("list")
+                    .WithDescription("List all services");
 
-                    service.AddCommand<ServiceDescribeCommand>("describe")
-                        .WithDescription("Show detailed information about a service");
+                service.AddCommand<ServiceGetByNameCommand>("get")
+                    .WithDescription("Get a service by name");
 
-                    service.AddCommand<ServiceSetCommand>("set")
-                        .WithDescription("Update service properties");
+                service.AddCommand<ServiceDescribeCommand>("describe")
+                    .WithDescription("Show detailed information about a service");
 
-                    service.AddCommand<ServiceDeleteCommand>("del")
-                        .WithDescription("Delete a service");
-                    
-                    service.AddCommand<ServiceSubnetsCommand>("subnets")
-                        .WithDescription("List service subnets or filter by CIDR");
-                });
+                service.AddCommand<ServiceSetCommand>("set")
+                    .WithDescription("Update service properties");
+
+                service.AddCommand<ServiceDeleteCommand>("del")
+                    .WithDescription("Delete a service");
+
+                service.AddCommand<ServiceSubnetsCommand>("subnets")
+                    .WithDescription("List service subnets or filter by CIDR");
+            });
         });
     }
 }

+ 2 - 2
RackPeek/Commands/Desktops/DesktopTreeCommand.cs

@@ -1,10 +1,10 @@
-using RackPeek.Domain.Resources.Hardware.Desktops;
+using RackPeek.Domain.Resources.Hardware;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
 namespace RackPeek.Commands.Desktops;
 
-public sealed class DesktopTreeCommand(GetDesktopSystemTreeUseCase useCase)
+public sealed class DesktopTreeCommand(GetHardwareSystemTreeUseCase useCase)
     : AsyncCommand<DesktopNameSettings>
 {
     public override async Task<int> ExecuteAsync(

+ 6 - 5
RackPeek/Commands/GetTotalSummaryCommand.cs

@@ -50,14 +50,15 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand
 
         foreach (var (kind, count) in hardwareSummary.HardwareByKind.OrderByDescending(h => h.Value).ThenBy(h => h.Key))
             hardwareNode.AddNode($"{kind}: {count}");
-        
+
         var systemsNode = tree.AddNode(
             $"[bold]Systems[/] ({systemSummary.TotalSystems})");
 
         if (systemSummary.SystemsByType.Count > 0)
         {
             var typesNode = systemsNode.AddNode("[bold]Types[/]");
-            foreach (var (type, count) in systemSummary.SystemsByType.OrderByDescending(h => h.Value).ThenBy(h => h.Key))
+            foreach (var (type, count) in systemSummary.SystemsByType.OrderByDescending(h => h.Value)
+                         .ThenBy(h => h.Key))
                 typesNode.AddNode($"{type}: {count}");
         }
 
@@ -67,7 +68,7 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand
             foreach (var (os, count) in systemSummary.SystemsByOs.OrderByDescending(h => h.Value).ThenBy(h => h.Key))
                 osNode.AddNode($"{os}: {count}");
         }
-        
+
         var servicesNode = tree.AddNode(
             $"[bold]Services[/] ({serviceSummary.TotalServices})");
 
@@ -76,7 +77,7 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand
 
         AnsiConsole.Write(tree);
     }
-    
+
     private static void RenderTotals(
         SystemSummary systemSummary,
         AllServicesSummary serviceSummary,
@@ -135,4 +136,4 @@ public class GetTotalSummaryCommand(IServiceProvider provider) : AsyncCommand
 
         AnsiConsole.Write(table);
     }
-}
+}

+ 2 - 2
RackPeek/Commands/Servers/ServerTreeCommand.cs

@@ -1,10 +1,10 @@
-using RackPeek.Domain.Resources.Hardware.Servers;
+using RackPeek.Domain.Resources.Hardware;
 using Spectre.Console;
 using Spectre.Console.Cli;
 
 namespace RackPeek.Commands.Servers;
 
-public sealed class ServerTreeCommand(GetServerSystemTreeUseCase useCase) : AsyncCommand<ServerNameSettings>
+public sealed class ServerTreeCommand(GetHardwareSystemTreeUseCase useCase) : AsyncCommand<ServerNameSettings>
 {
     public override async Task<int> ExecuteAsync(
         CommandContext context,

+ 2 - 1
RackPeek/Commands/Services/ServiceDescribeCommand.cs

@@ -38,7 +38,8 @@ public class ServiceDescribeCommand(
         grid.AddRow("Port:", service.Port?.ToString() ?? "Unknown");
         grid.AddRow("Protocol:", service.Protocol ?? "Unknown");
         grid.AddRow("Url:", service.Url ?? "Unknown");
-        grid.AddRow("Runs On:", ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost));
+        grid.AddRow("Runs On:",
+            ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost));
 
         AnsiConsole.Write(
             new Panel(grid)

+ 5 - 15
RackPeek/Commands/Services/ServicesFormatExtensions.cs

@@ -4,22 +4,12 @@ public static class ServicesFormatExtensions
 {
     public static string FormatRunsOn(string? runsOn, string? runsOnHost)
     {
-        if (string.IsNullOrEmpty(runsOn) && string.IsNullOrEmpty(runsOnHost))
-        {
-            return "Unknown";
-        }
-        
-        if (string.IsNullOrEmpty(runsOn))
-        {
-            return runsOnHost!;
-        }
+        if (string.IsNullOrEmpty(runsOn) && string.IsNullOrEmpty(runsOnHost)) return "Unknown";
+
+        if (string.IsNullOrEmpty(runsOn)) return runsOnHost!;
+
+        if (string.IsNullOrEmpty(runsOnHost)) return runsOn!;
 
-        if (string.IsNullOrEmpty(runsOnHost))
-        {
-            return runsOn!;
-        }
-        
         return $"{runsOnHost}/{runsOn}";
-        
     }
 }

+ 1 - 6
RackPeek/Yaml/YamlHardwareRepository.cs

@@ -3,7 +3,6 @@ using RackPeek.Domain.Resources.Hardware.Models;
 
 namespace RackPeek.Yaml;
 
-
 public class YamlHardwareRepository(YamlResourceCollection resources) : IHardwareRepository
 {
     public Task<int> GetCountAsync()
@@ -27,7 +26,7 @@ public class YamlHardwareRepository(YamlResourceCollection resources) : IHardwar
     {
         return Task.FromResult(resources.GetByName(name) as Hardware);
     }
-    
+
     public Task<List<HardwareTree>> GetTreeAsync()
     {
         var hardwareTree = new List<HardwareTree>();
@@ -48,16 +47,13 @@ public class YamlHardwareRepository(YamlResourceCollection resources) : IHardwar
             var hardwareKey = hardware.Name.Trim();
 
             if (systemGroups.TryGetValue(hardwareKey, out var systemResources))
-            {
                 foreach (var system in systemResources)
                 {
                     var services = new List<string>();
                     var systemKey = system.Name.Trim();
 
                     if (serviceGroups.TryGetValue(systemKey, out var serviceResources))
-                    {
                         services.AddRange(serviceResources.Select(s => s.Name));
-                    }
 
                     systems.Add(new SystemTree
                     {
@@ -65,7 +61,6 @@ public class YamlHardwareRepository(YamlResourceCollection resources) : IHardwar
                         Services = services
                     });
                 }
-            }
 
             hardwareTree.Add(new HardwareTree
             {

+ 1 - 1
RackPeek/Yaml/YamlServiceRepository.cs

@@ -17,7 +17,7 @@ public class YamlServiceRepository(YamlResourceCollection resources) : IServiceR
             .Distinct()
             .Count());
     }
-    
+
     public Task<IReadOnlyList<Service>> GetAllAsync()
     {
         return Task.FromResult(resources.ServiceResources);

+ 3 - 2
RackPeek/Yaml/YamlSystemRepository.cs

@@ -6,7 +6,8 @@ public class YamlSystemRepository(YamlResourceCollection resources) : ISystemRep
 {
     public Task<int> GetSystemCountAsync()
     {
-        return Task.FromResult(resources.SystemResources.Count);    }
+        return Task.FromResult(resources.SystemResources.Count);
+    }
 
     public Task<Dictionary<string, int>> GetSystemTypeCountAsync()
     {
@@ -23,7 +24,7 @@ public class YamlSystemRepository(YamlResourceCollection resources) : ISystemRep
             .GroupBy(h => h.Os!)
             .ToDictionary(k => k.Key, v => v.Count()));
     }
-    
+
     public Task<IReadOnlyList<SystemResource>> GetAllAsync()
     {
         return Task.FromResult(resources.SystemResources);

+ 1 - 1
Tests/EndToEnd/DesktopYamlE2ETest.cs

@@ -90,4 +90,4 @@ public class DesktopYamlE2ETests(TempYamlCliFixture fs, ITestOutputHelper output
 
                      """, output);
     }
-}
+}

+ 1 - 1
Tests/HardwareResources/Services/ServiceSubnetsCommandTests.cs

@@ -88,4 +88,4 @@ public class ServiceSubnetsUseCaseTests
         Assert.Contains(result.Subnets, s => s.Cidr == "192.168.1.0/24" && s.Count == 2);
         Assert.Contains(result.Subnets, s => s.Cidr == "192.168.2.0/24" && s.Count == 1);
     }
-}
+}