Browse Source

Added System<->System RunsOn (#201)

Tim Jones 1 month ago
parent
commit
527ed29a87

+ 3 - 0
README.md

@@ -78,6 +78,9 @@ volumes:
 * 
   [**CLI Commands Reference**](https://timmoth.github.io/RackPeek/docs/cli-commands)
 
+* 
+  [**Versioning**](https://timmoth.github.io/RackPeek/docs/versioning)
+
 
 ## POST V1.0.0 Roadmap
 - Support for IoT and networked devices

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

@@ -19,6 +19,9 @@ public interface IResourceCollection
 
     Resource? GetByName(string name);
     Task<bool> Exists(string name);
+    
+    Task<string?> GetKind(string? name);
+
 
     Task LoadAsync(); // required for WASM startup
     Task<IReadOnlyList<Resource>> GetByTagAsync(string name);

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

@@ -60,6 +60,16 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
         }
     }
 
+    public Task<string?> GetKind(string name)
+    {
+        lock (_lock)
+        {
+            return Task.FromResult(_resources.FirstOrDefault(r =>
+                r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
+        }
+        
+    }
+
     public Task LoadAsync()
     {
         return Task.CompletedTask;

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

@@ -39,6 +39,13 @@ public sealed class YamlResourceCollection(
             r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
     }
 
+    public Task<string?> GetKind(string? name)
+    {
+        return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r =>
+            r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
+        
+    }
+
     public Task<Dictionary<string, int>> GetTagsAsync()
     {
         var result = resourceCollection.Resources

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

@@ -34,7 +34,7 @@ public class GetHardwareSystemTreeUseCase(
     {
         var services = await repo.GetDependantsAsync(system.Name);
 
-        return new SystemDependencyTree(system, services.OfType<Service>());
+        return new SystemDependencyTree(system, services);
     }
 }
 
@@ -44,8 +44,8 @@ public sealed class HardwareDependencyTree(Hardware hardware, IEnumerable<System
     public IEnumerable<SystemDependencyTree> Systems { get; } = systems;
 }
 
-public sealed class SystemDependencyTree(SystemResource system, IEnumerable<Service> services)
+public sealed class SystemDependencyTree(SystemResource system, IEnumerable<Resource> childResources)
 {
     public SystemResource System { get; } = system;
-    public IEnumerable<Service> Services { get; } = services;
+    public IEnumerable<Resource> ChildResources { get; } = childResources;
 }

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

@@ -79,6 +79,10 @@ public abstract class Resource
         // System -> Hardware
         if (childKind == "system" && parent is Hardware.Hardware)
             return true;
+        
+        // System -> System
+        if (childKind == "system" && parent is SystemResource)
+            return true;
 
         return false;
     }

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

@@ -16,6 +16,6 @@ public class GetSystemServiceTreeUseCase(
         if (system is null) throw new NotFoundException($"System '{name}' not found.");
         var services = await repo.GetDependantsAsync(system.Name);
 
-        return new SystemDependencyTree(system, services.OfType<Service>());
+        return new SystemDependencyTree(system, services.OfType<Resource>());
     }
 }

+ 8 - 3
RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs

@@ -49,10 +49,15 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
             foreach(string parent in runsOn) {
                 if (!string.IsNullOrWhiteSpace(parent)) {
                     ThrowIfInvalid.ResourceName(parent);
-                    var parentHardware = await repository.GetByNameAsync(parent) as Hardware.Hardware;
-
-                    if (parentHardware == null) throw new NotFoundException($"Parent hardware '{parent}' not found.");
+                    var parentHardware = await repository.GetByNameAsync(parent);
 
+                    
+                    if (parentHardware == null) throw new NotFoundException($"Parent '{parent}' not found.");
+                    if (parentHardware is not Hardware.Hardware and not SystemResource)
+                    {
+                        throw new Exception("System cannot run on this resource.");
+                    }
+                    
                     if (!system.RunsOn.Contains(parent)) system.RunsOn.Add(parent);
 
                 }

+ 1 - 1
Shared.Rcl/Commands/Desktops/DesktopTreeCommand.cs

@@ -19,7 +19,7 @@ public sealed class DesktopTreeCommand(GetHardwareSystemTreeUseCase useCase)
         foreach (var system in tree.Systems)
         {
             var systemNode = root.AddNode($"[green]System:[/] {system.System.Name}");
-            foreach (var service in system.Services)
+            foreach (var service in system.ChildResources)
                 systemNode.AddNode($"[green]Service:[/] {service.Name}");
         }
 

+ 1 - 1
Shared.Rcl/Commands/Laptops/LaptopTreeCommand.cs

@@ -19,7 +19,7 @@ public sealed class LaptopTreeCommand(GetHardwareSystemTreeUseCase useCase)
         foreach (var system in tree.Systems)
         {
             var systemNode = root.AddNode($"[green]System:[/] {system.System.Name}");
-            foreach (var service in system.Services)
+            foreach (var service in system.ChildResources)
                 systemNode.AddNode($"[green]Service:[/] {service.Name}");
         }
 

+ 1 - 1
Shared.Rcl/Commands/Servers/ServerTreeCommand.cs

@@ -24,7 +24,7 @@ public sealed class ServerTreeCommand(GetHardwareSystemTreeUseCase useCase) : As
         foreach (var system in tree.Systems)
         {
             var systemNode = root.AddNode($"[green]System:[/] {system.System.Name}");
-            foreach (var service in system.Services) systemNode.AddNode($"[green]Service:[/] {service.Name}");
+            foreach (var service in system.ChildResources) systemNode.AddNode($"[green]Service:[/] {service.Name}");
         }
 
         AnsiConsole.Write(root);

+ 1 - 1
Shared.Rcl/Commands/Systems/SystemTreeCommand.cs

@@ -15,7 +15,7 @@ public sealed class SystemTreeCommand(GetSystemServiceTreeUseCase useCase) : Asy
 
         var root = new Tree($"[bold]{tree.System.Name}[/]");
 
-        foreach (var system in tree.Services)
+        foreach (var system in tree.ChildResources)
         {
             var systemNode = root.AddNode($"[green]Service:[/] {system.Name}");
         }

+ 59 - 39
Shared.Rcl/Components/HardwareDependencyTreeComponent.razor

@@ -1,4 +1,7 @@
 @using RackPeek.Domain.Resources.Hardware
+@using RackPeek.Domain.Resources.Hardware
+@using RackPeek.Domain.Resources.SystemResources
+
 @if (Tree is null)
 {
     <div class="text-zinc-500 text-sm">
@@ -8,15 +11,15 @@
 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/{Uri.EscapeDataString(systemTree.System.Name)}")" class="block">
+                    <!-- Root System -->
+                    <NavLink href="@($"resources/systems/{Uri.EscapeDataString(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
@@ -27,55 +30,72 @@ else
                         </div>
                     </NavLink>
 
-                    <!-- Services -->
-                    @if (systemTree.Services.Any())
+                    <!-- Children -->
+                    @if (systemTree.ChildResources?.Any() == true)
                     {
                         <div class="ml-4 space-y-2 border-l border-zinc-800 pl-4">
-                            @foreach (var service in systemTree.Services)
+
+                            @foreach (var child in systemTree.ChildResources)
                             {
-                                var url = service.NetworkString();
-                                <NavLink href="@($"resources/services/{Uri.EscapeDataString(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>
-
-                                        @{
-                                            var srv = BuildServiceSubtitle(service);
-                                        }
-
-
-                                        <div class="text-xs text-zinc-500 mt-1">
-                                            Service -
-                                            @if (!string.IsNullOrEmpty(srv))
-                                            {
-                                                <a href="@url"
-                                                   target="_blank"
-                                                   rel="noopener noreferrer"
-                                                   class="underline hover:text-emerald-400"
-                                                   @onclick:stopPropagation>
-                                                    @srv
-                                                </a>
-                                            }
-
-                                        </div>
-
-                                    </div>
-                                </NavLink>
+                                switch (child)
+                                {
+                                    case SystemResource nestedSystem:
+                                        <NavLink href="@($"resources/systems/{Uri.EscapeDataString(nestedSystem.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">
+                                                    @nestedSystem.Name
+                                                </div>
+                                                <div class="text-xs text-zinc-500 mt-1">
+                                                    System
+                                                </div>
+                                            </div>
+                                        </NavLink>
+                                        break;
+
+                                    case Service service:
+                                        var endpoint = service.NetworkString();
+
+                                        <NavLink href="@($"resources/services/{Uri.EscapeDataString(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
+                                                    @if (!string.IsNullOrWhiteSpace(endpoint))
+                                                    {
+                                                        <span> - </span>
+                                                        <a href="@endpoint"
+                                                           target="_blank"
+                                                           rel="noopener noreferrer"
+                                                           class="underline hover:text-emerald-400"
+                                                           @onclick:stopPropagation>
+                                                            @endpoint
+                                                        </a>
+                                                    }
+                                                </div>
+                                            </div>
+                                        </NavLink>
+                                        break;
+                                }
                             }
+
                         </div>
                     }
                     else
                     {
                         <div class="ml-4 text-xs text-zinc-600 italic">
-                            No services
+                            No child resources
                         </div>
                     }
+
                 </div>
             }
-        </div>
 
+        </div>
     </div>
 }
 

+ 191 - 0
Shared.Rcl/Modals/HardwareOrSystemSelectionModal.razor

@@ -0,0 +1,191 @@
+@using System.ComponentModel.DataAnnotations
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Resources.Hardware
+@using RackPeek.Domain.Resources.SystemResources
+@inject IResourceCollection Repo
+
+@if (IsOpen)
+{
+    <div class="fixed inset-0 z-50 flex items-center justify-center">
+        <!-- Backdrop -->
+        <div class="absolute inset-0 bg-black/70" @onclick="Cancel"></div>
+
+        <!-- Modal -->
+        <div class="relative bg-zinc-900 border border-zinc-800 rounded w-full max-w-lg p-4">
+            <!-- Header -->
+            <div class="flex justify-between items-center mb-4">
+                <div class="text-zinc-100 text-sm font-medium">
+                    @Title
+                </div>
+
+                <button class="text-zinc-400 hover:text-zinc-200" @onclick="Cancel">
+                    ✕
+                </button>
+            </div>
+
+            <EditForm Model="_model" OnValidSubmit="HandleAccept">
+                <DataAnnotationsValidator />
+
+                <div class="space-y-3 text-sm">
+                    <!-- Search -->
+                    <div>
+                        <InputText class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                                   placeholder="Search hardware or systems…"
+                                   Value="@_search"
+                                   ValueChanged="OnSearchChanged"
+                                   ValueExpression="() => _search"
+                                   @oninput="OnSearchInput" />
+                    </div>
+
+                    <!-- Selection -->
+                    <div>
+                        <InputSelect class="w-full bg-zinc-800 border border-zinc-700 rounded px-2 py-1 text-zinc-100"
+                                     @bind-Value="_model.Value">
+                            <option value="">Select hardware or system…</option>
+
+                            @foreach (var group in FilteredItems)
+                            {
+                                <optgroup label="@group.Key">
+                                    @foreach (var item in group.Value)
+                                    {
+                                        <option value="@item.Value">@item.Label</option>
+                                    }
+                                </optgroup>
+                            }
+                        </InputSelect>
+
+                        @* Optional helper text *@
+                        @if (!string.IsNullOrWhiteSpace(_model.Value) && _lookup.TryGetValue(_model.Value!, out var selected))
+                        {
+                            <div class="mt-2 text-xs text-zinc-400">
+                                Selected: <span class="text-zinc-200">@selected.Type</span>
+                                <span class="text-zinc-600">•</span>
+                                <span>@selected.Group</span>
+                            </div>
+                        }
+                    </div>
+                </div>
+
+                <!-- Actions -->
+                <div class="flex justify-end items-center mt-5 gap-2">
+                    <button type="button"
+                            class="px-3 py-1 rounded border border-zinc-700 text-zinc-300 hover:bg-zinc-800"
+                            @onclick="Cancel">
+                        Cancel
+                    </button>
+
+                    <button type="submit"
+                            class="px-3 py-1 rounded bg-emerald-600 text-black hover:bg-emerald-500"
+                            disabled="@(!CanAccept)">
+                        Accept
+                    </button>
+                </div>
+            </EditForm>
+        </div>
+    </div>
+}
+
+@code {
+    /* ---------- Parameters ---------- */
+
+    [Parameter] public bool IsOpen { get; set; }
+    [Parameter] public EventCallback<bool> IsOpenChanged { get; set; }
+
+    [Parameter] public string Title { get; set; } = "Select hardware or system";
+
+    [Parameter] public string? Value { get; set; }
+    
+    [Parameter] public EventCallback<string?> OnAccept { get; set; }
+    
+    private SelectionFormModel _model = new();
+    private string _search = string.Empty;
+
+    private List<SelectionItem> _items = new();
+
+    private Dictionary<string, SelectionItem> _lookup = new();
+
+    private bool CanAccept => !string.IsNullOrWhiteSpace(_model.Value);
+
+    /* ---------- Lifecycle ---------- */
+
+    protected override async Task OnParametersSetAsync()
+    {
+        if (!IsOpen) return;
+
+        // Load both sets
+        var hardware = (await Repo.GetAllOfTypeAsync<Hardware>())
+            .Select(h => SelectionItem.Hardware(h.Name, h.Kind))
+            .ToList();
+
+        var systems = (await Repo.GetAllOfTypeAsync<SystemResource>())
+            .Select(s => SelectionItem.System(s.Name, string.IsNullOrWhiteSpace(s.Type) ? "System" : s.Type!))
+            .ToList();
+
+        _items = hardware
+            .Concat(systems)
+            .OrderBy(i => i.TypeSort)
+            .ThenBy(i => i.Group)
+            .ThenBy(i => i.Label)
+            .ToList();
+
+        _lookup = _items.ToDictionary(i => i.Value, i => i);
+
+        _model = new SelectionFormModel { Value = Value };
+        _search = string.Empty;
+    }
+    
+    private IReadOnlyDictionary<string, List<SelectionItem>> FilteredItems =>
+        _items
+            .Where(i =>
+                string.IsNullOrWhiteSpace(_search) ||
+                i.Label.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase) ||
+                i.Group.Contains(_search.Trim(), StringComparison.OrdinalIgnoreCase))
+            .GroupBy(i => $"{i.Type} — {i.Group}")
+            .ToDictionary(
+                g => g.Key,
+                g => g.OrderBy(i => i.Label).ToList());
+    
+    private async Task HandleAccept()
+    {
+        await OnAccept.InvokeAsync(_model.Value);
+        await Close();
+    }
+
+    private async Task Cancel() => await Close();
+
+    private async Task Close()
+    {
+        _model = new SelectionFormModel();
+        _search = string.Empty;
+        await IsOpenChanged.InvokeAsync(false);
+    }
+
+    private void OnSearchChanged(string? value) => _search = value ?? string.Empty;
+
+    private void OnSearchInput(ChangeEventArgs e)
+    {
+        _search = e.Value?.ToString() ?? string.Empty;
+
+        if (FilteredItems.SelectMany(g => g.Value).All(i => i.Value != _model.Value))
+            _model.Value = null;
+    }
+    
+    private sealed class SelectionFormModel
+    {
+        [Required] public string? Value { get; set; }
+    }
+    
+    private sealed record SelectionItem(
+        string Type,
+        string Group,
+        string Label,
+        string Value,
+        int TypeSort)
+    {
+        public static SelectionItem Hardware(string name, string kind) =>
+            new("Hardware", kind, name, $"{name}", 0);
+
+        public static SelectionItem System(string name, string category) =>
+            new("System", category, name, $"{name}", 1);
+    }
+}

+ 59 - 38
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -1,3 +1,4 @@
+@using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Resources.SubResources
 @using RackPeek.Domain.Resources.SystemResources
 @using RackPeek.Domain.Resources.SystemResources.UseCases
@@ -11,6 +12,7 @@
 @inject ICloneResourceUseCase<SystemResource> CloneUseCase
 @inject NavigationManager Nav
 @inject IRenameResourceUseCase<SystemResource> RenameUseCase
+@inject IResourceCollection ResourceCollection
 
 <div class="border border-zinc-800 rounded p-4 bg-zinc-900"
      data-testid=@($"system-item-{System.Name.Replace(" ", "-")}")>
@@ -166,54 +168,41 @@
         </div>
 
         <!-- Runs On -->
-        <div>
+        <div class="text-sm">
             <div class="text-zinc-400 mb-1">
-              Runs On
-
-              <button
-                  class="hover:text-emerald-400 pr-4"
-                  title="Add Runs On"
-                  @onclick="() => _selectParentOpen = true">
-                      @("+")
-              </button>
+                Runs On
+                <button class="hover:text-emerald-400 pl-2"
+                        @onclick="() => _selectParentOpen = true">+</button>
             </div>
 
-            @if (_isEditing)
+            @if (System.RunsOn?.Count > 0)
             {
-                @if (_edit.RunsOn?.Count > 0)
+                @foreach (var parent in System.RunsOn)
                 {
-                    @foreach (var parent in _edit.RunsOn)
+                    var kind = _parentKinds.TryGetValue(parent, out var k) ? k : null;
+
+                    var url = kind switch
                     {
-                        <button
-                            type="button"
-                            class="hover:text-emerald-400"
-                            title="Edit Runs On"
-                            @onclick="() => _selectParentOpen = true">
-                            @parent
-                        </button>
+                        "Hardware" => $"resources/hardware/{Uri.EscapeDataString(parent)}",
+                        "System" => $"resources/systems/{Uri.EscapeDataString(parent)}",
+                        _ => "#"
+                    };
+
+                    <NavLink href="@url"
+                             class="text-emerald-400 text-sm pr-4">
+                        @parent
+                    </NavLink>
 
-                        <button
-                            type="button"
-                            class="text-red-400 hover:text-red-300 pr-4"
-                            title="Remove"
-                            @onclick="async () => await HandleParentDeleted(parent)">
+                    @if (_isEditing)
+                    {
+                        <button class="text-red-400 hover:text-red-300 pr-4"
+                                @onclick="async () => await HandleParentDeleted(parent)">
                         </button>
                     }
                 }
             }
-            else if (System.RunsOn?.Count > 0)
-            {
-                @foreach(var parent in System.RunsOn)
-                {
-                  <NavLink href="@($"resources/hardware/{Uri.EscapeDataString(parent)}")"
-                           class="text-emerald-400 text-sm pr-4">
-                      @parent
-                  </NavLink>
-                }
-            }
         </div>
-
         <div>
             <div class="flex items-center justify-between mb-1 group">
                 <div class="text-zinc-400">Drives
@@ -277,12 +266,13 @@
     </div>
 </div>
 
-<HardwareSelectionModal
+<!-- Parent Selector -->
+<HardwareOrSystemSelectionModal
     IsOpen="@_selectParentOpen"
     IsOpenChanged="v => _selectParentOpen = v"
-    Title="Select a parent"
+    Title="Select a parent resource"
     Value="@SelectedParentName"
-    OnAccept="HandleParentSelected"/>
+    OnAccept="HandleParentSelected" />
 <DriveModal
     IsOpen="@_driveModalOpen"
     IsOpenChanged="v => _driveModalOpen = v"
@@ -343,9 +333,34 @@
 
     bool _selectParentOpen;
     string? SelectedParentName;
+    private Dictionary<string, string> _parentKinds = new();
+
+    protected override async Task OnParametersSetAsync()
+    {
+        await RefreshParentKinds();
+    }
+    
+    private async Task RefreshParentKinds()
+    {
+        _parentKinds.Clear();
 
+        if (System.RunsOn == null)
+            return;
+
+        foreach (var parent in System.RunsOn)
+        {
+            var kind = await ResourceCollection.GetKind(parent);
+            _parentKinds[parent] = kind;
+        }
+    }
+    
   async Task HandleParentSelected(string? name)
 {
+    if (name == System.Name)
+    {
+        return;
+    }
+    
     SelectedParentName = name;
 
     // Start from the edit buffer when editing, otherwise from the persisted model
@@ -377,6 +392,9 @@
         _edit.RunsOn = runsOn;
     else
         _edit = SystemEditModel.From(System);
+    
+    await RefreshParentKinds();
+
 }
 
 async Task HandleParentDeleted(string? name)
@@ -410,6 +428,9 @@ async Task HandleParentDeleted(string? name)
         _edit.RunsOn = runsOn;
     else
         _edit = SystemEditModel.From(System);
+    
+    await RefreshParentKinds();
+
 }
   
 

+ 54 - 33
Shared.Rcl/Systems/SystemDependencyTreeComponent.razor

@@ -1,4 +1,6 @@
 @using RackPeek.Domain.Resources.Hardware
+@using RackPeek.Domain.Resources.SystemResources
+
 @if (Tree is null)
 {
     <div class="text-zinc-500 text-sm">
@@ -9,46 +11,65 @@ else
 {
     <div class="ml-4 border-l border-zinc-800 pl-4 space-y-2">
 
-        @if (Tree.Services.Any())
+        @if (Tree.ChildResources?.Any() == true)
         {
-            @foreach (var service in Tree.Services)
+            @foreach (var child in Tree.ChildResources)
             {
-                var url = service.NetworkString();
-                <NavLink href="@($"resources/services/{Uri.EscapeDataString(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>
-
-                        @{
-                            var srv = BuildServiceSubtitle(service);
-                        }
-
-
-                        <div class="text-xs text-zinc-500 mt-1">
-                            Service -
-                            @if (!string.IsNullOrEmpty(srv))
-                            {
-                                <a href="@url"
-                                   target="_blank"
-                                   rel="noopener noreferrer"
-                                   class="underline hover:text-emerald-400"
-                                   @onclick:stopPropagation>
-                                    @srv
-                                </a>
-                            }
-
-                        </div>
-
-                    </div>
-                </NavLink>
+                switch (child)
+                {
+                    case Service service:
+                        var endpoint = service.NetworkString();
+
+                        <NavLink href="@($"resources/services/{Uri.EscapeDataString(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
+                                    @if (!string.IsNullOrWhiteSpace(endpoint))
+                                    {
+                                        <span> - </span>
+                                        <a href="@endpoint"
+                                           target="_blank"
+                                           rel="noopener noreferrer"
+                                           class="underline hover:text-emerald-400"
+                                           @onclick:stopPropagation>
+                                            @endpoint
+                                        </a>
+                                    }
+                                </div>
+
+                            </div>
+                        </NavLink>
+                        break;
+
+                    case SystemResource system:
+                        <NavLink href="@($"resources/systems/{Uri.EscapeDataString(system.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">
+                                    @system.Name
+                                </div>
+
+                                <div class="text-xs text-zinc-500 mt-1">
+                                    System
+                                </div>
+
+                            </div>
+                        </NavLink>
+                        break;
+                }
             }
         }
         else
         {
             <div class="text-xs text-zinc-600 italic">
-                No services
+                No child resources
             </div>
         }
 

+ 1 - 1
Shared.Rcl/Systems/SystemsDetailsPage.razor

@@ -31,7 +31,7 @@
 
         <SystemCardComponent System="_system" OnSave="UpdateSystem" OnDeleted="OnDeleted"/>
 
-        @if (_tree is not null && _tree.Services.Any())
+        @if (_tree is not null && _tree.ChildResources.Any())
         {
             <SystemDependencyTreeComponent Tree="_tree"/>
         }

+ 2 - 1
Shared.Rcl/wwwroot/raw_docs/docs-index.json

@@ -3,5 +3,6 @@
   "install-guide.md",
   "ansible-generator-guide.md",
   "cli-commands.md",
-  "cli-commands-index.md"
+  "cli-commands-index.md",
+  "versioning.md"
 ]

+ 101 - 0
Shared.Rcl/wwwroot/raw_docs/versioning.md

@@ -0,0 +1,101 @@
+## Versioning
+
+RackPeek follows **Semantic Versioning (SemVer)** for both the CLI and Docker images:
+
+```
+MAJOR.MINOR.PATCH
+Example: 1.2.3
+```
+
+### MAJOR (X.0.0)
+
+* Sweeping changes to the CLI / WebUi
+* Large schema changes
+* Breaking Changes
+
+### MINOR (1.X.0)
+
+Backward-compatible features:
+
+* New CLI commands or flags
+* New WebUI features
+* New config options
+* Performance improvements
+
+### PATCH (1.0.X)
+
+Backward-compatible fixes:
+
+* Bug fixes
+* Security patches
+* Docs or minor UX improvements
+
+### CLI & Docker
+
+The CLI and Docker image share the **same version number**.
+
+Docker tags:
+
+* `latest` → newest stable
+* `v1.2.3` → Major Minor Patch
+
+For production, pin to a specific version instead of `latest`.
+
+## Versioning
+
+RackPeek follows **Semantic Versioning (SemVer)** for both the CLI and Docker images:
+
+```
+MAJOR.MINOR.PATCH
+Example: 1.2.3
+```
+
+### MAJOR (X.0.0)
+
+Breaking changes:
+
+* CLI command/flag changes
+* `config.yaml` structure changes
+* Removed/renamed fields
+* Behavior requiring migration
+
+### MINOR (1.X.0)
+
+Backward-compatible features:
+
+* New CLI commands or flags
+* New WebUI features
+* New non-breaking config options
+* Performance improvements
+
+### PATCH (1.0.X)
+
+Backward-compatible fixes:
+
+* Bug fixes
+* Security patches
+* Docs / minor UX improvements
+
+## CLI & Docker
+
+The CLI binary and Docker image share the **same version number**.
+
+Docker tags:
+
+* `latest` → newest stable release
+* `1` → latest major
+* `1.2` → latest patch in that minor line
+* `1.2.3` → exact immutable version (recommended for production)
+
+## Nightly Docker Builds
+
+In addition to stable releases, RackPeek publishes a **nightly Docker image** from the `main` branch.
+
+* Triggered on every push to `main`
+* Built for `linux/amd64`
+* Tagged as:
+
+  * `aptacode/rackpeek:nightly`
+  * `aptacode/rackpeek:nightly-<short-sha>`
+
+Nightly images provide early access to upcoming changes and are intended for testing and development (not production use).

+ 71 - 0
Tests/EndToEnd/SystemTests/SystemWorkflowTests.cs

@@ -112,4 +112,75 @@ public class SystemWorkflowTests(TempYamlCliFixture fs, ITestOutputHelper output
 
                      """, output);
     }
+    
+    [Fact]
+public async Task systems_cli_workflow_runs_on_hardware_and_systems_test()
+{
+    await File.WriteAllTextAsync(Path.Combine(fs.Root, "config.yaml"), "");
+
+    // Create hardware (server)
+    await ExecuteAsync("servers", "add", "proxmox-node01");
+
+    // Add first system
+    var (output, yaml) = await ExecuteAsync("systems", "add", "sys01");
+    Assert.Equal("System 'sys01' added.\n", output);
+    Assert.Contains("name: sys01", yaml);
+
+    // Set sys01 to run on the created hardware
+    (output, yaml) = await ExecuteAsync(
+        "systems", "set", "sys01",
+        "--type", "VM",
+        "--os", "debian-12",
+        "--cores", "2",
+        "--ram", "4",
+        "--runs-on", "proxmox-node01"
+    );
+    Assert.Equal("System 'sys01' updated.\n", output);
+
+    // Add second system
+    (output, yaml) = await ExecuteAsync("systems", "add", "sys02");
+    Assert.Equal("System 'sys02' added.\n", output);
+    Assert.Contains("name: sys02", yaml);
+
+    // Set sys02 to run on BOTH: hardware + sys01
+    // NOTE: '--runs-on' accepts multiple values via repeated options.
+    (output, yaml) = await ExecuteAsync(
+        "systems", "set", "sys02",
+        "--type", "VM",
+        "--os", "debian-12",
+        "--cores", "4",
+        "--ram", "8",
+        "--runs-on", "proxmox-node01",
+        "--runs-on", "sys01"
+    );
+    Assert.Equal("System 'sys02' updated.\n", output);
+
+    outputHelper.WriteLine(yaml);
+
+    // Assert resulting YAML
+    Assert.Equal("""
+                 version: 2
+                 resources:
+                 - kind: Server
+                   name: proxmox-node01
+                 - kind: System
+                   type: vm
+                   os: debian-12
+                   cores: 2
+                   ram: 4
+                   name: sys01
+                   runsOn:
+                   - proxmox-node01
+                 - kind: System
+                   type: vm
+                   os: debian-12
+                   cores: 4
+                   ram: 8
+                   name: sys02
+                   runsOn:
+                   - proxmox-node01
+                   - sys01
+
+                 """, yaml);
+}
 }