Ver Fonte

feat(system): Enable a resource to run on a list of resources

This will enable specifying multi-node systems.  For example, you can have 3 servers listed as hardware, then 1 system called k8s that runs on all 3 servers, then various services that run on the k8s system.  For HA/Distributed systems, it can be difficult to statically tell what service is running on what hardware, so we list all possible options.

Signed-off-by: Stephen Reaves <reaves735@gmail.com>
Stephen Reaves há 1 mês atrás
pai
commit
9cf3e6bc5b
41 ficheiros alterados com 919 adições e 163 exclusões
  1. 12 5
      RackPeek.Domain/Persistence/HardwareRepository.cs
  2. 2 2
      RackPeek.Domain/Persistence/InMemoryResourceCollection.cs
  3. 2 2
      RackPeek.Domain/Persistence/ServiceRepository.cs
  4. 2 2
      RackPeek.Domain/Persistence/SystemRepository.cs
  5. 41 4
      RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs
  6. 4 3
      RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs
  7. 2 2
      RackPeek.Domain/Resources/Resource.cs
  8. 16 7
      RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs
  9. 16 7
      RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs
  10. 2 2
      RackPeek.Domain/Resources/Services/UseCases/ServiceSubnetsUseCase.cs
  11. 13 7
      RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs
  12. 1 1
      RackPeek.Domain/Resources/SystemResources/SystemResource.cs
  13. 2 2
      RackPeek.Domain/Resources/SystemResources/UseCases/DescribeSystemUseCase.cs
  14. 2 2
      RackPeek.Domain/Resources/SystemResources/UseCases/SystemReportUseCase.cs
  15. 14 8
      RackPeek.Domain/Resources/SystemResources/UseCases/UpdateSystemUseCase.cs
  16. 15 12
      RackPeek.Domain/UseCases/AddResourceUseCase.cs
  17. 1 1
      RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs
  18. 2 2
      RackPeek.Domain/UseCases/RenameResourceUseCase.cs
  19. 2 2
      Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs
  20. 5 2
      Shared.Rcl/Commands/Services/ServiceGetByNameCommand.cs
  21. 17 2
      Shared.Rcl/Commands/Services/ServiceGetCommand.cs
  22. 16 2
      Shared.Rcl/Commands/Services/ServiceReportCommand.cs
  23. 5 2
      Shared.Rcl/Commands/Services/ServiceSetCommand.cs
  24. 10 2
      Shared.Rcl/Commands/Services/ServiceSubnetsCommand.cs
  25. 3 2
      Shared.Rcl/Commands/Systems/SystemDescribeCommand.cs
  26. 2 2
      Shared.Rcl/Commands/Systems/SystemGetByNameCommand.cs
  27. 2 2
      Shared.Rcl/Commands/Systems/SystemGetCommand.cs
  28. 2 2
      Shared.Rcl/Commands/Systems/SystemReportCommand.cs
  29. 8 2
      Shared.Rcl/Commands/Systems/SystemSetCommand.cs
  30. 1 1
      Shared.Rcl/Components/AddResourceComponent.razor
  31. 27 18
      Shared.Rcl/Components/ResourceBreadCrumbComponent.razor
  32. 4 1
      Shared.Rcl/Hardware/HardwareDetailsPage.razor
  33. 53 19
      Shared.Rcl/Services/ServiceCardComponent.razor
  34. 2 2
      Shared.Rcl/Services/ServiceEditModel.cs
  35. 6 2
      Shared.Rcl/Services/ServicesListPage.razor
  36. 54 22
      Shared.Rcl/Systems/SystemCardComponent.razor
  37. 2 2
      Shared.Rcl/Systems/SystemEditModel.cs
  38. 2 2
      Shared.Rcl/Systems/SystemsDetailsPage.razor
  39. 5 1
      Shared.Rcl/Systems/SystemsListPage.razor
  40. 271 0
      Tests/schemas/schema.v2.json
  41. 271 0
      schemas/v2/schema.v2.json

+ 12 - 5
RackPeek.Domain/Persistence/HardwareRepository.cs

@@ -20,14 +20,21 @@ public class YamlHardwareRepository(IResourceCollection resources) : IHardwareRe
     {
     {
         var hardwareTree = new List<HardwareTree>();
         var hardwareTree = new List<HardwareTree>();
 
 
+        // List<string> systemGroups = resources.SystemResources.ToList<string>();
+            // .Where(s => s.RunsOn != null)
+            // .ToList();
+
         var systemGroups = resources.SystemResources
         var systemGroups = resources.SystemResources
-            .Where(s => !string.IsNullOrWhiteSpace(s.RunsOn))
-            .GroupBy(s => s.RunsOn!.Trim(), StringComparer.OrdinalIgnoreCase)
+            // .Where(s => !string.IsNullOrWhiteSpace(s.RunsOn))
+            // .Where(s => s.RunsOn != null)
+            // TODO: Get rid of 'First'
+            .GroupBy(s => s.RunsOn.First().Trim(), StringComparer.OrdinalIgnoreCase)
             .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
             .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
 
 
         var serviceGroups = resources.ServiceResources
         var serviceGroups = resources.ServiceResources
-            .Where(s => !string.IsNullOrWhiteSpace(s.RunsOn))
-            .GroupBy(s => s.RunsOn!.Trim(), StringComparer.OrdinalIgnoreCase)
+            // .Where(s => !string.IsNullOrWhiteSpace(s.RunsOn))
+            // TODO: Get rid of 'First'
+            .GroupBy(s => s.RunsOn.First().Trim(), StringComparer.OrdinalIgnoreCase)
             .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
             .ToDictionary(g => g.Key, g => g.ToList(), StringComparer.OrdinalIgnoreCase);
 
 
         foreach (var hardware in resources.HardwareResources)
         foreach (var hardware in resources.HardwareResources)
@@ -61,4 +68,4 @@ public class YamlHardwareRepository(IResourceCollection resources) : IHardwareRe
 
 
         return Task.FromResult(hardwareTree);
         return Task.FromResult(hardwareTree);
     }
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Persistence/InMemoryResourceCollection.cs

@@ -99,7 +99,7 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
         lock (_lock)
         lock (_lock)
         {
         {
             return Task.FromResult<IReadOnlyList<Resource>>(_resources
             return Task.FromResult<IReadOnlyList<Resource>>(_resources
-                .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false).ToList());
+                .Where(r => r.RunsOn.Select(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)).ToList().Count() > 0 ).ToList());
         }
         }
     }
     }
 
 
@@ -192,4 +192,4 @@ public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = nul
                 $"Unknown resource type: {resource.GetType().Name}")
                 $"Unknown resource type: {resource.GetType().Name}")
         };
         };
     }
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Persistence/ServiceRepository.cs

@@ -22,7 +22,7 @@ public class ServiceRepository(IResourceCollection resources) : IServiceReposito
     {
     {
         var systemHostNameLower = systemHostName.ToLower().Trim();
         var systemHostNameLower = systemHostName.ToLower().Trim();
         var results = resources.ServiceResources
         var results = resources.ServiceResources
-            .Where(s => s.RunsOn != null && s.RunsOn.ToLower().Equals(systemHostNameLower)).ToList();
+            .Where(s => s.RunsOn.Select(p => p.ToLower().Equals(systemHostNameLower)).ToList().Count > 0).ToList();
         return Task.FromResult<IReadOnlyList<Service>>(results);
         return Task.FromResult<IReadOnlyList<Service>>(results);
     }
     }
 
 
@@ -67,4 +67,4 @@ public class ServiceRepository(IResourceCollection resources) : IServiceReposito
 
 
         await resources.DeleteAsync(name);
         await resources.DeleteAsync(name);
     }
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Persistence/SystemRepository.cs

@@ -48,7 +48,7 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
     {
     {
         var physicalHostNameLower = physicalHostName.ToLower().Trim();
         var physicalHostNameLower = physicalHostName.ToLower().Trim();
         var results = resources.SystemResources
         var results = resources.SystemResources
-            .Where(s => s.RunsOn != null && s.RunsOn.ToLower().Equals(physicalHostNameLower)).ToList();
+            .Where(s => s.RunsOn.Select(sys => sys.ToLower().Equals(physicalHostNameLower)).ToList().Count > 0).ToList();
         return Task.FromResult<IReadOnlyList<SystemResource>>(results);
         return Task.FromResult<IReadOnlyList<SystemResource>>(results);
     }
     }
 
 
@@ -99,4 +99,4 @@ public class YamlSystemRepository(IResourceCollection resources) : ISystemReposi
 
 
         await resources.DeleteAsync(name);
         await resources.DeleteAsync(name);
     }
     }
-}
+}

+ 41 - 4
RackPeek.Domain/Persistence/Yaml/RackPeekConfigMigrationDeserializer.cs

@@ -18,12 +18,16 @@ namespace RackPeek.Domain.Persistence.Yaml;
 
 
 public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<YamlRoot>
 public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<YamlRoot>
 {
 {
+    // List migration functions here
+    public static readonly IReadOnlyList<Func<IServiceProvider, Dictionary<object, object>, ValueTask>> ListOfMigrations = new List<Func<IServiceProvider, Dictionary<object,object>, ValueTask>>{
+        EnsureSchemaVersionExists,
+        ConvertScalarRunsOnToList,
+    };
+
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
     public RackPeekConfigMigrationDeserializer(IServiceProvider serviceProvider,
         ILogger<YamlMigrationDeserializer<YamlRoot>> logger) :
         ILogger<YamlMigrationDeserializer<YamlRoot>> logger) :
         base(serviceProvider, logger, 
         base(serviceProvider, logger, 
-            new List<Func<IServiceProvider, Dictionary<object,object>, ValueTask>>{
-                EnsureSchemaVersionExists,
-            },
+            ListOfMigrations,
             "version",
             "version",
             new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance)
             new DeserializerBuilder().WithNamingConvention(CamelCaseNamingConvention.Instance)
                 .WithCaseInsensitivePropertyMatching()
                 .WithCaseInsensitivePropertyMatching()
@@ -67,5 +71,38 @@ public class RackPeekConfigMigrationDeserializer : YamlMigrationDeserializer<Yam
         return ValueTask.CompletedTask;
         return ValueTask.CompletedTask;
     }
     }
 
 
+    public static ValueTask ConvertScalarRunsOnToList(IServiceProvider serviceProvider, Dictionary<object, object> obj)
+    {
+        const string key = "runsOn";
+        var resourceList = obj["resources"];
+        if (resourceList is List<object> resources)
+        {
+            foreach(var resourceObj in resources)
+            {
+                if (resourceObj is Dictionary<object,object> resourceDict)
+                {
+                    if (resourceDict.ContainsKey(key))
+                    {
+                        var runsOn = resourceDict[key];
+                        Type t = runsOn.GetType();
+                        switch (runsOn)
+                        {
+                            case string r:
+                                resourceDict[key] = new List<string>{r};
+                                break;
+                            case List<string> r:
+                                // Nothing to do
+                                break;
+                            default:
+                                throw new InvalidCastException($"Cannot convert from {t} to List<string> in {resourceDict}[{key}]");
+                        }
+                    }
+                }
+            }
+        }
+
+        return ValueTask.CompletedTask;
+    }
+
     #endregion
     #endregion
-}
+}

+ 4 - 3
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -31,7 +31,7 @@ public sealed class YamlResourceCollection(
     : IResourceCollection
     : IResourceCollection
 {
 {
     // Bump this when your YAML schema changes, and add a migration step below.
     // Bump this when your YAML schema changes, and add a migration step below.
-    private const int CurrentSchemaVersion = 1;
+    private static readonly int CurrentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
 
 
     public Task<bool> Exists(string name)
     public Task<bool> Exists(string name)
     {
     {
@@ -58,7 +58,7 @@ public sealed class YamlResourceCollection(
     public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
     public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
     {
     {
         return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources
         return Task.FromResult<IReadOnlyList<Resource>>(resourceCollection.Resources
-            .Where(r => r.RunsOn?.Equals(name, StringComparison.OrdinalIgnoreCase) ?? false)
+            .Where(r => r.RunsOn.Select(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)).ToList().Count > 0 )
             .ToList());
             .ToList());
     }
     }
 
 
@@ -123,7 +123,8 @@ public sealed class YamlResourceCollection(
         if (version < CurrentSchemaVersion)
         if (version < CurrentSchemaVersion)
         {
         {
             await BackupOriginalAsync(yaml);
             await BackupOriginalAsync(yaml);
-            root = await _deserializer.Deserialize(yaml);
+
+            root = await _deserializer.Deserialize(yaml) ?? new YamlRoot();
             
             
             // Ensure we persist the migrated root (with updated version)
             // Ensure we persist the migrated root (with updated version)
             await SaveRootAsync(root);
             await SaveRootAsync(root);

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

@@ -51,7 +51,7 @@ public abstract class Resource
     public Dictionary<string, string> Labels { get; set; } = new();
     public Dictionary<string, string> Labels { get; set; } = new();
     public string? Notes { get; set; }
     public string? Notes { get; set; }
 
 
-    public string? RunsOn { get; set; }
+    public List<string> RunsOn { get; set; } = new List<string>();
 
 
     public static string KindToPlural(string kind)
     public static string KindToPlural(string kind)
     {
     {
@@ -82,4 +82,4 @@ public abstract class Resource
 
 
         return false;
         return false;
     }
     }
-}
+}

+ 16 - 7
RackPeek.Domain/Resources/Services/UseCases/DescribeServiceUseCase.cs

@@ -10,8 +10,8 @@ public record ServiceDescription(
     int? Port,
     int? Port,
     string? Protocol,
     string? Protocol,
     string? Url,
     string? Url,
-    string? RunsOnSystemHost,
-    string? RunsOnPhysicalHost,
+    List<string> RunsOnSystemHost,
+    List<string> RunsOnPhysicalHost,
     Dictionary<string, string> Labels
     Dictionary<string, string> Labels
 );
 );
 
 
@@ -25,11 +25,20 @@ public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
         if (service is null)
         if (service is null)
             throw new NotFoundException($"Service '{name}' not found.");
             throw new NotFoundException($"Service '{name}' not found.");
 
 
-        string? runsOnPhysicalHost = null;
-        if (!string.IsNullOrEmpty(service.RunsOn))
+        List<string> runsOnPhysicalHost = new List<string>();
+        foreach (var systemName in service.RunsOn)
         {
         {
-            var systemResource = await repository.GetByNameAsync(service.RunsOn) as SystemResource;
-            runsOnPhysicalHost = systemResource?.RunsOn;
+            var systemResource = await repository.GetByNameAsync(systemName) as SystemResource;
+            if (systemResource is not null)
+            {
+                foreach(var physicalName in systemResource.RunsOn)
+                {
+                    if (!runsOnPhysicalHost.Contains(physicalName))
+                    {
+                        runsOnPhysicalHost.Add(physicalName);
+                    }
+                }
+            }
         }
         }
 
 
         return new ServiceDescription(
         return new ServiceDescription(
@@ -43,4 +52,4 @@ public class DescribeServiceUseCase(IResourceCollection repository) : IUseCase
             service.Labels
             service.Labels
         );
         );
     }
     }
-}
+}

+ 16 - 7
RackPeek.Domain/Resources/Services/UseCases/ServiceReportUseCase.cs

@@ -12,8 +12,8 @@ public record ServiceReportRow(
     int? Port,
     int? Port,
     string? Protocol,
     string? Protocol,
     string? Url,
     string? Url,
-    string? RunsOnSystemHost,
-    string? RunsOnPhysicalHost
+    List<string>? RunsOnSystemHost,
+    List<string>? RunsOnPhysicalHost
 );
 );
 
 
 public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
 public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
@@ -24,11 +24,20 @@ public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
 
 
         var rows = services.Select(async s =>
         var rows = services.Select(async s =>
         {
         {
-            string? runsOnPhysicalHost = null;
-            if (!string.IsNullOrEmpty(s.RunsOn))
+            List<string> runsOnPhysicalHost = new List<string>();
+            if (s.RunsOn is not null)
             {
             {
-                var systemResource = await repository.GetByNameAsync(s.RunsOn);
-                runsOnPhysicalHost = systemResource?.RunsOn;
+                foreach (var system in s.RunsOn)
+                {
+                    var systemResource = await repository.GetByNameAsync(system);
+                    if (systemResource?.RunsOn is not null)
+                    {
+                        foreach (var parent in systemResource.RunsOn)
+                        {
+                            if (!runsOnPhysicalHost.Contains(parent)) runsOnPhysicalHost.Add(parent);
+                        }
+                    }
+                }
             }
             }
 
 
             return new ServiceReportRow(
             return new ServiceReportRow(
@@ -45,4 +54,4 @@ public class ServiceReportUseCase(IResourceCollection repository) : IUseCase
         var result = await Task.WhenAll(rows);
         var result = await Task.WhenAll(rows);
         return new ServiceReport(result);
         return new ServiceReport(result);
     }
     }
-}
+}

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

@@ -54,7 +54,7 @@ public class ServiceSubnetsUseCase(IResourceCollection repo) : IUseCase
 
 
 public record SubnetSummary(string Cidr, int Count);
 public record SubnetSummary(string Cidr, int Count);
 
 
-public record ServiceSummary(string Name, string Ip, string? RunsOn);
+public record ServiceSummary(string Name, string Ip, List<string>? RunsOn);
 
 
 public class ServiceSubnetsResult
 public class ServiceSubnetsResult
 {
 {
@@ -80,4 +80,4 @@ public class ServiceSubnetsResult
     {
     {
         return new ServiceSubnetsResult { Services = services, FilteredCidr = cidr };
         return new ServiceSubnetsResult { Services = services, FilteredCidr = cidr };
     }
     }
-}
+}

+ 13 - 7
RackPeek.Domain/Resources/Services/UseCases/UpdateServiceUseCase.cs

@@ -11,7 +11,7 @@ public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
         int? port = null,
         int? port = null,
         string? protocol = null,
         string? protocol = null,
         string? url = null,
         string? url = null,
-        string? runsOn = null,
+        List<string>? runsOn = null,
         string? notes = null
         string? notes = null
     )
     )
     {
     {
@@ -48,16 +48,22 @@ public class UpdateServiceUseCase(IResourceCollection repository) : IUseCase
             service.Network.Port = port.Value;
             service.Network.Port = port.Value;
         }
         }
 
 
-        if (!string.IsNullOrWhiteSpace(runsOn))
+        if (runsOn is not null)
         {
         {
-            ThrowIfInvalid.ResourceName(runsOn);
-            var parentSystem = await repository.GetByNameAsync(runsOn);
-            if (parentSystem == null) throw new NotFoundException($"Parent system '{runsOn}' not found.");
-            service.RunsOn = runsOn;
+            foreach (var parent in runsOn)
+            {
+                ThrowIfInvalid.ResourceName(parent);
+
+                var parentSystem = await repository.GetByNameAsync(parent);
+
+                if (parentSystem == null) throw new NotFoundException($"Parent system '{parent}' not found.");
+
+                if (!service.RunsOn.Contains(parent)) service.RunsOn.Add(parent);
+            }
         }
         }
 
 
         if (notes != null) service.Notes = notes;
         if (notes != null) service.Notes = notes;
 
 
         await repository.UpdateAsync(service);
         await repository.UpdateAsync(service);
     }
     }
-}
+}

+ 1 - 1
RackPeek.Domain/Resources/SystemResources/SystemResource.cs

@@ -23,4 +23,4 @@ public class SystemResource : Resource, IDriveResource
     public int? Cores { get; set; }
     public int? Cores { get; set; }
     public double? Ram { get; set; }
     public double? Ram { get; set; }
     public List<Drive>? Drives { get; set; }
     public List<Drive>? Drives { get; set; }
-}
+}

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

@@ -10,7 +10,7 @@ public record SystemDescription(
     int Cores,
     int Cores,
     double RamGb,
     double RamGb,
     int TotalStorageGb,
     int TotalStorageGb,
-    string? RunsOn,
+    List<string> RunsOn,
     Dictionary<string, string> Labels
     Dictionary<string, string> Labels
 );
 );
 
 
@@ -35,4 +35,4 @@ public class DescribeSystemUseCase(IResourceCollection repository) : IUseCase
             system.Labels
             system.Labels
         );
         );
     }
     }
-}
+}

+ 2 - 2
RackPeek.Domain/Resources/SystemResources/UseCases/SystemReportUseCase.cs

@@ -13,7 +13,7 @@ public record SystemReportRow(
     int Cores,
     int Cores,
     double RamGb,
     double RamGb,
     int TotalStorageGb,
     int TotalStorageGb,
-    string? RunsOn
+    List<string> RunsOn
 );
 );
 
 
 public class SystemReportUseCase(IResourceCollection repository) : IUseCase
 public class SystemReportUseCase(IResourceCollection repository) : IUseCase
@@ -39,4 +39,4 @@ public class SystemReportUseCase(IResourceCollection repository) : IUseCase
 
 
         return new SystemReport(rows);
         return new SystemReport(rows);
     }
     }
-}
+}

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

@@ -11,7 +11,7 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
         string? os = null,
         string? os = null,
         int? cores = null,
         int? cores = null,
         double? ram = null,
         double? ram = null,
-        string? runsOn = null,
+        List<string>? runsOn = null,
         string? notes = null
         string? notes = null
     )
     )
     {
     {
@@ -44,15 +44,21 @@ public class UpdateSystemUseCase(IResourceCollection repository) : IUseCase
 
 
         if (notes != null) system.Notes = notes;
         if (notes != null) system.Notes = notes;
 
 
-        if (!string.IsNullOrWhiteSpace(runsOn))
+        if (runsOn?.Count > 0)
         {
         {
-            ThrowIfInvalid.ResourceName(runsOn);
-            var parentHardware = await repository.GetByNameAsync(runsOn) as Hardware.Hardware;
-            if (parentHardware == null) throw new NotFoundException($"Parent hardware '{runsOn}' not found.");
-            system.RunsOn = runsOn;
-        }
+            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.");
 
 
+                    if (!system.RunsOn.Contains(parent)) system.RunsOn.Add(parent);
+
+                }
+            }
+        }
 
 
         await repository.UpdateAsync(system);
         await repository.UpdateAsync(system);
     }
     }
-}
+}

+ 15 - 12
RackPeek.Domain/UseCases/AddResourceUseCase.cs

@@ -7,12 +7,12 @@ namespace RackPeek.Domain.UseCases;
 public interface IAddResourceUseCase<T> : IResourceUseCase<T>
 public interface IAddResourceUseCase<T> : IResourceUseCase<T>
     where T : Resource
     where T : Resource
 {
 {
-    Task ExecuteAsync(string name, string? runsOn = null);
+    Task ExecuteAsync(string name, List<string>? runsOn = null);
 }
 }
 
 
 public class AddResourceUseCase<T>(IResourceCollection repo) : IAddResourceUseCase<T> where T : Resource
 public class AddResourceUseCase<T>(IResourceCollection repo) : IAddResourceUseCase<T> where T : Resource
 {
 {
-    public async Task ExecuteAsync(string name, string? runsOn = null)
+    public async Task ExecuteAsync(string name, List<string>? runsOn = null)
     {
     {
         name = Normalize.HardwareName(name);
         name = Normalize.HardwareName(name);
         ThrowIfInvalid.ResourceName(name);
         ThrowIfInvalid.ResourceName(name);
@@ -23,20 +23,23 @@ public class AddResourceUseCase<T>(IResourceCollection repo) : IAddResourceUseCa
 
 
         if (runsOn != null)
         if (runsOn != null)
         {
         {
-            runsOn = Normalize.HardwareName(runsOn);
-            ThrowIfInvalid.ResourceName(runsOn);
-            var parentResource = await repo.GetByNameAsync(runsOn);
-            if (parentResource == null) throw new NotFoundException($"Resource '{runsOn}' not found.");
-
-            if (!Resource.CanRunOn<T>(parentResource))
-                throw new InvalidOperationException(
-                    $" {Resource.GetKind<T>()} cannot run on {parentResource.Kind} '{runsOn}'.");
+
+            foreach (var parent in runsOn) {
+                var normalizedParent = Normalize.HardwareName(parent);
+                ThrowIfInvalid.ResourceName(normalizedParent);
+                var parentResource = await repo.GetByNameAsync(normalizedParent);
+                if (parentResource == null) throw new NotFoundException($"Resource '{normalizedParent}' not found.");
+
+                if (!Resource.CanRunOn<T>(parentResource))
+                    throw new InvalidOperationException(
+                        $" {Resource.GetKind<T>()} cannot run on {parentResource.Kind} '{normalizedParent}'.");
+            }
         }
         }
 
 
         var resource = Activator.CreateInstance<T>();
         var resource = Activator.CreateInstance<T>();
         resource.Name = name;
         resource.Name = name;
-        resource.RunsOn = runsOn;
+        resource.RunsOn = runsOn ?? new List<string>();
 
 
         await repo.AddAsync(resource);
         await repo.AddAsync(resource);
     }
     }
-}
+}

+ 1 - 1
RackPeek.Domain/UseCases/Ports/UpdatePortUseCase.cs

@@ -49,4 +49,4 @@ public class UpdatePortUseCase<T>(IResourceCollection repository) : IUpdatePortU
 
 
         await repository.UpdateAsync(resource);
         await repository.UpdateAsync(resource);
     }
     }
-}
+}

+ 2 - 2
RackPeek.Domain/UseCases/RenameResourceUseCase.cs

@@ -33,8 +33,8 @@ public class RenameResourceUseCase<T>(IResourceCollection repo) : IRenameResourc
         var children = await repo.GetDependantsAsync(originalName);
         var children = await repo.GetDependantsAsync(originalName);
         foreach (var child in children)
         foreach (var child in children)
         {
         {
-            child.RunsOn = newName;
+            child.RunsOn = child.RunsOn.ConvertAll<string>(p => p == originalName ? newName : p);
             await repo.UpdateAsync(child);
             await repo.UpdateAsync(child);
         }
         }
     }
     }
-}
+}

+ 2 - 2
Shared.Rcl/Commands/Services/ServiceDescribeCommand.cs

@@ -33,7 +33,7 @@ public class ServiceDescribeCommand(
         grid.AddRow("Protocol:", service.Protocol ?? "Unknown");
         grid.AddRow("Protocol:", service.Protocol ?? "Unknown");
         grid.AddRow("Url:", service.Url ?? "Unknown");
         grid.AddRow("Url:", service.Url ?? "Unknown");
         grid.AddRow("Runs On:",
         grid.AddRow("Runs On:",
-            ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost));
+            ServicesFormatExtensions.FormatRunsOn(string.Join(", ", service.RunsOnSystemHost), string.Join(", ", service.RunsOnPhysicalHost)));
 
 
         if (service.Labels.Count > 0)
         if (service.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", service.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
             grid.AddRow("Labels:", string.Join(", ", service.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
@@ -45,4 +45,4 @@ public class ServiceDescribeCommand(
 
 
         return 0;
         return 0;
     }
     }
-}
+}

+ 5 - 2
Shared.Rcl/Commands/Services/ServiceGetByNameCommand.cs

@@ -19,8 +19,11 @@ public class ServiceGetByNameCommand(
 
 
         var service = await useCase.ExecuteAsync(settings.Name);
         var service = await useCase.ExecuteAsync(settings.Name);
 
 
+        var sys = string.Join(", ", service.RunsOnSystemHost);
+        var phys = string.Join(", ", service.RunsOnPhysicalHost);
+
         AnsiConsole.MarkupLine(
         AnsiConsole.MarkupLine(
-            $"[green]{service.Name}[/]  Ip: {service.Ip ?? "Unknown"}, Port: {service.Port.ToString() ?? "Unknown"}, Protocol: {service.Protocol ?? "Unknown"}, Url: {service.Url ?? "Unknown"}, RunsOn: {ServicesFormatExtensions.FormatRunsOn(service.RunsOnSystemHost, service.RunsOnPhysicalHost)}");
+            $"[green]{service.Name}[/]  Ip: {service.Ip ?? "Unknown"}, Port: {service.Port.ToString() ?? "Unknown"}, Protocol: {service.Protocol ?? "Unknown"}, Url: {service.Url ?? "Unknown"}, RunsOn: {ServicesFormatExtensions.FormatRunsOn(sys, phys)}");
         return 0;
         return 0;
     }
     }
-}
+}

+ 17 - 2
Shared.Rcl/Commands/Services/ServiceGetCommand.cs

@@ -34,16 +34,31 @@ public class ServiceGetCommand(
             .AddColumn("Runs On");
             .AddColumn("Runs On");
 
 
         foreach (var s in report.Services)
         foreach (var s in report.Services)
+        {
+            var sys = "Unknown";
+            var phys = "Unkown";
+
+
+            if (s.RunsOnSystemHost is not null)
+            {
+                sys = string.Join(", ", s.RunsOnSystemHost);
+            }
+            if (s.RunsOnPhysicalHost is not null)
+            {
+                phys = string.Join(", ", s.RunsOnPhysicalHost);
+            }
+
             table.AddRow(
             table.AddRow(
                 s.Name,
                 s.Name,
                 s.Ip ?? "",
                 s.Ip ?? "",
                 s.Port.ToString() ?? "",
                 s.Port.ToString() ?? "",
                 s.Protocol ?? "",
                 s.Protocol ?? "",
                 s.Url ?? "",
                 s.Url ?? "",
-                ServicesFormatExtensions.FormatRunsOn(s.RunsOnSystemHost, s.RunsOnPhysicalHost)
+                ServicesFormatExtensions.FormatRunsOn(sys, phys)
             );
             );
+        }
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);
         return 0;
         return 0;
     }
     }
-}
+}

+ 16 - 2
Shared.Rcl/Commands/Services/ServiceReportCommand.cs

@@ -34,16 +34,30 @@ public class ServiceReportCommand(
             .AddColumn("Runs On");
             .AddColumn("Runs On");
 
 
         foreach (var s in report.Services)
         foreach (var s in report.Services)
+        {
+            string sys = "Unknown";
+            string phys = "Unknown";
+
+            if (s.RunsOnSystemHost?.Count > 0 )
+            {
+                sys = string.Join(", ", s.RunsOnSystemHost);
+            }
+            if (s.RunsOnPhysicalHost?.Count > 0 )
+            {
+                phys = string.Join(", ", s.RunsOnPhysicalHost);
+            }
+
             table.AddRow(
             table.AddRow(
                 s.Name,
                 s.Name,
                 s.Ip ?? "",
                 s.Ip ?? "",
                 s.Port.ToString() ?? "",
                 s.Port.ToString() ?? "",
                 s.Protocol ?? "",
                 s.Protocol ?? "",
                 s.Url ?? "",
                 s.Url ?? "",
-                ServicesFormatExtensions.FormatRunsOn(s.RunsOnSystemHost, s.RunsOnPhysicalHost)
+                ServicesFormatExtensions.FormatRunsOn(sys, phys)
             );
             );
+        }
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);
         return 0;
         return 0;
     }
     }
-}
+}

+ 5 - 2
Shared.Rcl/Commands/Services/ServiceSetCommand.cs

@@ -25,9 +25,12 @@ public class ServiceSetSettings : ServerNameSettings
     [Description("The service URL.")]
     [Description("The service URL.")]
     public string? Url { get; set; }
     public string? Url { get; set; }
 
 
+    // TODO: How do you specify a list?
+    // foo --runs-on a --runs-on b
+    // foo --runs-on a,b
     [CommandOption("--runs-on")]
     [CommandOption("--runs-on")]
     [Description("The system the service is running on.")]
     [Description("The system the service is running on.")]
-    public string? RunsOn { get; set; }
+    public List<string>? RunsOn { get; set; }
 }
 }
 
 
 public class ServiceSetCommand(
 public class ServiceSetCommand(
@@ -54,4 +57,4 @@ public class ServiceSetCommand(
         AnsiConsole.MarkupLine($"[green]Service '{settings.Name}' updated.[/]");
         AnsiConsole.MarkupLine($"[green]Service '{settings.Name}' updated.[/]");
         return 0;
         return 0;
     }
     }
-}
+}

+ 10 - 2
Shared.Rcl/Commands/Services/ServiceSubnetsCommand.cs

@@ -79,7 +79,15 @@ public class ServiceSubnetsCommand(
                 .AddColumn("Runs On");
                 .AddColumn("Runs On");
 
 
             foreach (var s in services)
             foreach (var s in services)
-                table.AddRow(s.Name, s.Ip, s.RunsOn ?? "Unknown");
+            {
+                var runsOn = "";
+                if (s.RunsOn?.Count > 0)
+                {
+                    runsOn = string.Join(", ", s.RunsOn);
+                }
+
+                table.AddRow(s.Name, s.Ip, runsOn);
+            }
 
 
             AnsiConsole.MarkupLine($"[green]Services in {result.FilteredCidr}[/]");
             AnsiConsole.MarkupLine($"[green]Services in {result.FilteredCidr}[/]");
             AnsiConsole.Write(table);
             AnsiConsole.Write(table);
@@ -137,4 +145,4 @@ public class ServiceSubnetsCommand(
 
 
         [CommandOption("--prefix <PREFIX>")] public int? Prefix { get; set; }
         [CommandOption("--prefix <PREFIX>")] public int? Prefix { get; set; }
     }
     }
-}
+}

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

@@ -29,7 +29,8 @@ public class SystemDescribeCommand(
         grid.AddRow("Cores:", system.Cores.ToString());
         grid.AddRow("Cores:", system.Cores.ToString());
         grid.AddRow("RAM (GB):", system.RamGb.ToString());
         grid.AddRow("RAM (GB):", system.RamGb.ToString());
         grid.AddRow("Total Storage (GB):", system.TotalStorageGb.ToString());
         grid.AddRow("Total Storage (GB):", system.TotalStorageGb.ToString());
-        grid.AddRow("Runs On:", system.RunsOn ?? "Unknown");
+        grid.AddRow("Runs On:", string.Join(", ", system.RunsOn) ?? "Unknown");
+                
 
 
         if (system.Labels.Count > 0)
         if (system.Labels.Count > 0)
             grid.AddRow("Labels:", string.Join(", ", system.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
             grid.AddRow("Labels:", string.Join(", ", system.Labels.Select(kvp => $"{kvp.Key}: {kvp.Value}")));
@@ -41,4 +42,4 @@ public class SystemDescribeCommand(
 
 
         return 0;
         return 0;
     }
     }
-}
+}

+ 2 - 2
Shared.Rcl/Commands/Systems/SystemGetByNameCommand.cs

@@ -20,8 +20,8 @@ public class SystemGetByNameCommand(
         var system = await useCase.ExecuteAsync(settings.Name);
         var system = await useCase.ExecuteAsync(settings.Name);
         AnsiConsole.MarkupLine(
         AnsiConsole.MarkupLine(
             $"[green]{system.Name}[/]  Type: {system.Type ?? "Unknown"}, OS: {system.Os ?? "Unknown"}, " +
             $"[green]{system.Name}[/]  Type: {system.Type ?? "Unknown"}, OS: {system.Os ?? "Unknown"}, " +
-            $"Cores: {system.Cores}, RAM: {system.RamGb}GB, Storage: {system.TotalStorageGb}GB, RunsOn: {system.RunsOn ?? "Unknown"}");
+            $"Cores: {system.Cores}, RAM: {system.RamGb}GB, Storage: {system.TotalStorageGb}GB, RunsOn: {string.Join(", ", system.RunsOn!)}");
 
 
         return 0;
         return 0;
     }
     }
-}
+}

+ 2 - 2
Shared.Rcl/Commands/Systems/SystemGetCommand.cs

@@ -42,10 +42,10 @@ public class SystemGetCommand(
                 s.Cores.ToString(),
                 s.Cores.ToString(),
                 s.RamGb.ToString(),
                 s.RamGb.ToString(),
                 s.TotalStorageGb.ToString(),
                 s.TotalStorageGb.ToString(),
-                s.RunsOn ?? "Unknown"
+                string.Join(", ", s.RunsOn) ?? "Unkown"
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);
         return 0;
         return 0;
     }
     }
-}
+}

+ 2 - 2
Shared.Rcl/Commands/Systems/SystemReportCommand.cs

@@ -42,10 +42,10 @@ public class SystemReportCommand(
                 s.Cores.ToString(),
                 s.Cores.ToString(),
                 s.RamGb.ToString(),
                 s.RamGb.ToString(),
                 s.TotalStorageGb.ToString(),
                 s.TotalStorageGb.ToString(),
-                s.RunsOn ?? "Unknown"
+                string.Join(", ", s.RunsOn) ?? "Unknown"
             );
             );
 
 
         AnsiConsole.Write(table);
         AnsiConsole.Write(table);
         return 0;
         return 0;
     }
     }
-}
+}

+ 8 - 2
Shared.Rcl/Commands/Systems/SystemSetCommand.cs

@@ -31,16 +31,22 @@ public class SystemSetCommand(
         using var scope = serviceProvider.CreateScope();
         using var scope = serviceProvider.CreateScope();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateSystemUseCase>();
         var useCase = scope.ServiceProvider.GetRequiredService<UpdateSystemUseCase>();
 
 
+        List<string> runsOn = new List<string>();
+        if (settings.RunsOn is not null)
+        {
+            runsOn.Add(settings.RunsOn);
+        }
+
         await useCase.ExecuteAsync(
         await useCase.ExecuteAsync(
             settings.Name,
             settings.Name,
             settings.Type,
             settings.Type,
             settings.Os,
             settings.Os,
             settings.Cores,
             settings.Cores,
             settings.Ram,
             settings.Ram,
-            settings.RunsOn
+            runsOn
         );
         );
 
 
         AnsiConsole.MarkupLine($"[green]System '{settings.Name}' updated.[/]");
         AnsiConsole.MarkupLine($"[green]System '{settings.Name}' updated.[/]");
         return 0;
         return 0;
     }
     }
-}
+}

+ 1 - 1
Shared.Rcl/Components/AddResourceComponent.razor

@@ -46,7 +46,7 @@
 
 
     [Parameter] public EventCallback<string> OnCreated { get; set; }
     [Parameter] public EventCallback<string> OnCreated { get; set; }
     [Parameter] public string? Placeholder { get; set; }
     [Parameter] public string? Placeholder { get; set; }
-    [Parameter] public string? RunsOn { get; set; }
+    [Parameter] public List<string>? RunsOn { get; set; }
 
 
     private string _name = string.Empty;
     private string _name = string.Empty;
     private string? _error;
     private string? _error;

+ 27 - 18
Shared.Rcl/Components/ResourceBreadCrumbComponent.razor

@@ -48,12 +48,15 @@
     {
     {
         var system = await Repo.GetByNameAsync(systemName);
         var system = await Repo.GetByNameAsync(systemName);
 
 
-        if (system?.RunsOn is not null)
+        if (system?.RunsOn?.Count > 0)
         {
         {
-            Breadcrumbs.Add(new Breadcrumb(
-                system.RunsOn,
-                $"resources/hardware/{system.RunsOn}"
-            ));
+            foreach(string parent in system.RunsOn)
+            {
+                Breadcrumbs.Add(new Breadcrumb(
+                    parent,
+                    $"resources/hardware/{parent}"
+                ));
+            }
         }
         }
 
 
         Breadcrumbs.Add(new Breadcrumb(
         Breadcrumbs.Add(new Breadcrumb(
@@ -66,22 +69,28 @@
     {
     {
         var service = await Repo.GetByNameAsync(serviceName);
         var service = await Repo.GetByNameAsync(serviceName);
 
 
-        if (service?.RunsOn is not null)
+        if (service?.RunsOn?.Count > 0)
         {
         {
-            var system = await Repo.GetByNameAsync(service.RunsOn);
-
-            if (system?.RunsOn is not null)
+            foreach (var sys in service.RunsOn)
             {
             {
-                Breadcrumbs.Add(new Breadcrumb(
-                    system.RunsOn,
-                    $"resources/hardware/{system.RunsOn}"
-                ));
+              var system = await Repo.GetByNameAsync(sys);
+
+              if (system?.RunsOn?.Count > 0)
+              {
+                  foreach(string parent in system.RunsOn)
+                  {
+                      Breadcrumbs.Add(new Breadcrumb(
+                          parent,
+                          $"resources/hardware/{parent}"
+                      ));
+                  }
+              }
+
+              Breadcrumbs.Add(new Breadcrumb(
+                  sys,
+                  $"resources/systems/{sys}"
+              ));
             }
             }
-
-            Breadcrumbs.Add(new Breadcrumb(
-                service.RunsOn,
-                $"resources/systems/{service.RunsOn}"
-            ));
         }
         }
 
 
         Breadcrumbs.Add(new Breadcrumb(
         Breadcrumbs.Add(new Breadcrumb(

+ 4 - 1
Shared.Rcl/Hardware/HardwareDetailsPage.razor

@@ -94,12 +94,15 @@
             </div>
             </div>
         }
         }
 
 
+<!---
+TODO: Figure out how lists work
         <div class="m-4">
         <div class="m-4">
             <AddResourceComponent TResource="SystemResource"
             <AddResourceComponent TResource="SystemResource"
                                   Placeholder="System name"
                                   Placeholder="System name"
                                   OnCreated="NavigateToNewResource"
                                   OnCreated="NavigateToNewResource"
                                   RunsOn="@HardwareName"/>
                                   RunsOn="@HardwareName"/>
         </div>
         </div>
+--->
     }
     }
 </div>
 </div>
 
 
@@ -137,4 +140,4 @@
         return Task.CompletedTask;
         return Task.CompletedTask;
     }
     }
 
 
-}
+}

+ 53 - 19
Shared.Rcl/Services/ServiceCardComponent.razor

@@ -159,31 +159,49 @@
 
 
         <!-- Runs On -->
         <!-- Runs On -->
         <div data-testid="service-runson-section">
         <div data-testid="service-runson-section">
-            <div class="text-zinc-400 mb-1">Runs On</div>
+            <div class="text-zinc-400 mb-1">
+              Runs On
+
+              <button
+                  data-testid="service-runson-button"
+                  class="hover:text-emerald-400 pr-4"
+                  title="Add Runs On"
+                  @onclick="() => _selectParentOpen = true">
+                      @("+")
+              </button>
+            </div>
 
 
             @if (_isEditing)
             @if (_isEditing)
             {
             {
-                <button
-                    data-testid="service-runson-button"
-                    class="hover:text-emerald-400"
-                    @onclick="() => _selectParentOpen = true">
-                    @if (!string.IsNullOrWhiteSpace(Service.RunsOn))
+                    @if (Service.RunsOn?.Count > 0)
                     {
                     {
-                        @($"{Service.RunsOn} +")
+                      @foreach(var parent in Service.RunsOn)
+                      {
+                        <button
+                          class="hover:text-emerald-400"
+                          title="Edit Runs On"
+                          @onclick="() => _selectParentOpen = true">
+                            @($"{parent}")
+                        </button>
+                        <button
+                          class="text-red-400 hover:text-red-300 pr-4"
+                          title="Remove"
+                          @onclick="() => HandleParentDeleted(parent)">
+                            @($"✕")
+                        </button>
+                      }
                     }
                     }
-                    else
-                    {
-                        @("Edit parent")
-                    }
-                </button>
             }
             }
-            else if (!string.IsNullOrWhiteSpace(Service.RunsOn))
+            else if (Service.RunsOn?.Count > 0)
             {
             {
-                <NavLink href="@($"resources/systems/{Service.RunsOn}")"
-                         data-testid="service-runson-link"
-                         class="text-emerald-400">
-                    @Service.RunsOn
-                </NavLink>
+                @foreach(var parent in Service.RunsOn)
+                {
+                  <NavLink href="@($"resources/systems/{parent}")"
+                           data-testid="service-runson-link"
+                           class="text-emerald-400 pr-4">
+                      @parent
+                  </NavLink>
+                }
             }
             }
         </div>
         </div>
 
 
@@ -322,7 +340,23 @@
             Service.Network?.Port,
             Service.Network?.Port,
             Service.Network?.Protocol,
             Service.Network?.Protocol,
             Service.Network?.Url,
             Service.Network?.Url,
-            name,
+            new List<string>{name},
+            Service.Notes);
+        Service = await GetByNameUseCase.ExecuteAsync(Service.Name);
+        _edit = ServiceEditModel.From(Service);
+    }
+
+    async Task HandleParentDeleted(string? name)
+    {
+        SelectedParentName = name;
+        Service.RunsOn.Remove(SelectedParentName);
+        await UpdateUseCase.ExecuteAsync(
+            Service.Name,
+            Service.Network?.Ip,
+            Service.Network?.Port,
+            Service.Network?.Protocol,
+            Service.Network?.Url,
+            Service.RunsOn,
             Service.Notes);
             Service.Notes);
         Service = await GetByNameUseCase.ExecuteAsync(Service.Name);
         Service = await GetByNameUseCase.ExecuteAsync(Service.Name);
         _edit = ServiceEditModel.From(Service);
         _edit = ServiceEditModel.From(Service);

+ 2 - 2
Shared.Rcl/Services/ServiceEditModel.cs

@@ -9,7 +9,7 @@ public sealed class ServiceEditModel
     public int? Port { get; set; }
     public int? Port { get; set; }
     public string? Protocol { get; set; }
     public string? Protocol { get; set; }
     public string? Url { get; set; }
     public string? Url { get; set; }
-    public string? RunsOn { get; set; }
+    public List<string>? RunsOn { get; set; }
     public string? Notes { get; set; }
     public string? Notes { get; set; }
 
 
     public static ServiceEditModel From(Service s)
     public static ServiceEditModel From(Service s)
@@ -25,4 +25,4 @@ public sealed class ServiceEditModel
             Notes = s.Notes
             Notes = s.Notes
         };
         };
     }
     }
-}
+}

+ 6 - 2
Shared.Rcl/Services/ServicesListPage.razor

@@ -2,10 +2,14 @@
 @using RackPeek.Domain.Persistence
 @using RackPeek.Domain.Persistence
 @inject NavigationManager Nav
 @inject NavigationManager Nav
 
 
+<!-- TODO: Get rid of First -->
 <ResourcesListComponent TResource="Service"
 <ResourcesListComponent TResource="Service"
                         Title="Services"
                         Title="Services"
                         TestId="services"
                         TestId="services"
-                        GroupBy="@(s => s.RunsOn)"
+                        GroupBy="@(s => {
+                          if (s.RunsOn is null) return "Unkown";
+                          return s.RunsOn.FirstOrDefault();
+                        })"
                         ShouldGroup="true"
                         ShouldGroup="true"
                         OnCreated="NavigateToNewResource">
                         OnCreated="NavigateToNewResource">
 
 
@@ -33,4 +37,4 @@
         await Repo.GetAllOfTypeAsync<Service>();
         await Repo.GetAllOfTypeAsync<Service>();
     }
     }
 
 
-}
+}

+ 54 - 22
Shared.Rcl/Systems/SystemCardComponent.razor

@@ -167,31 +167,47 @@
 
 
         <!-- Runs On -->
         <!-- Runs On -->
         <div>
         <div>
-            <div class="text-zinc-400 mb-1">Runs On</div>
+            <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>
+            </div>
+
             @if (_isEditing)
             @if (_isEditing)
             {
             {
-                <button
-                    class="hover:text-emerald-400"
-                    title="Edit Runs On"
-                    data-testid="system-runs-on-button"
-                    @onclick="() => _selectParentOpen = true">
-                    @if (!string.IsNullOrWhiteSpace(System.RunsOn))
-                    {
-                        @($"{System.RunsOn} +")
-                    }
-                    else
-                    {
-                        @("Edit parent")
-                    }
-
-                </button>
+                  @if (System.RunsOn?.Count > 0)
+                  {
+                      @foreach(var parent in System.RunsOn)
+                      {
+                        <button
+                            class="hover:text-emerald-400"
+                            title="Edit Runs On"
+                            @onclick="() => _selectParentOpen = true">
+                                  @($"{parent}")
+                        </button>
+                        <button
+                            class="text-red-400 hover:text-red-300 pr-4"
+                            title="Remove"
+                            @onclick="() => HandleParentDeleted(parent)">
+                                  @($"✕")
+                        </button>
+                      }
+                  }
             }
             }
-            else if (!string.IsNullOrWhiteSpace(System.RunsOn))
+            else if (System.RunsOn?.Count > 0)
             {
             {
-                <NavLink href="@($"resources/hardware/{System.RunsOn}")"
-                         class="text-emerald-400 text-sm">
-                    @System.RunsOn
-                </NavLink>
+                @foreach(var parent in System.RunsOn)
+                {
+                  <NavLink href="@($"resources/hardware/{parent}")"
+                           class="text-emerald-400 text-sm pr-4">
+                      @parent
+                  </NavLink>
+                }
             }
             }
         </div>
         </div>
 
 
@@ -334,7 +350,23 @@
             System.Os,
             System.Os,
             System.Cores,
             System.Cores,
             System.Ram,
             System.Ram,
-            name,
+            new List<string>{name},
+            System.Notes);
+        System = await GetByNameUseCase.ExecuteAsync(System.Name);
+        _edit = SystemEditModel.From(System);
+    }
+
+    async Task HandleParentDeleted(string? name)
+    {
+        SelectedParentName = name;
+        System.RunsOn.Remove(SelectedParentName);
+        await UpdateUseCase.ExecuteAsync(
+            System.Name,
+            System.Type,
+            System.Os,
+            System.Cores,
+            System.Ram,
+            System.RunsOn,
             System.Notes);
             System.Notes);
         System = await GetByNameUseCase.ExecuteAsync(System.Name);
         System = await GetByNameUseCase.ExecuteAsync(System.Name);
         _edit = SystemEditModel.From(System);
         _edit = SystemEditModel.From(System);

+ 2 - 2
Shared.Rcl/Systems/SystemEditModel.cs

@@ -9,7 +9,7 @@ public sealed class SystemEditModel
     public string? Os { get; set; }
     public string? Os { get; set; }
     public int? Cores { get; set; }
     public int? Cores { get; set; }
     public double? Ram { get; set; }
     public double? Ram { get; set; }
-    public string? RunsOn { get; set; }
+    public List<string> RunsOn { get; set; } = new List<string>();
     public string? Notes { get; set; }
     public string? Notes { get; set; }
 
 
     public static SystemEditModel From(SystemResource system)
     public static SystemEditModel From(SystemResource system)
@@ -25,4 +25,4 @@ public sealed class SystemEditModel
             Notes = system.Notes
             Notes = system.Notes
         };
         };
     }
     }
-}
+}

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

@@ -46,7 +46,7 @@
             <AddResourceComponent TResource="Service"
             <AddResourceComponent TResource="Service"
                                   Placeholder="Service name"
                                   Placeholder="Service name"
                                   OnCreated="NavigateToNewResource"
                                   OnCreated="NavigateToNewResource"
-                                  RunsOn="@SystemName"/>
+                                  RunsOn="@(new List<string>{SystemName})"/>
         </div>
         </div>
     }
     }
 </div>
 </div>
@@ -95,4 +95,4 @@
         return Task.CompletedTask;
         return Task.CompletedTask;
     }
     }
 
 
-}
+}

+ 5 - 1
Shared.Rcl/Systems/SystemsListPage.razor

@@ -4,12 +4,16 @@
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @using RackPeek.Domain.Resources.SystemResources.UseCases
 @inject NavigationManager Nav
 @inject NavigationManager Nav
 
 
+<!-- TODO: Get rid of First -->
 <ResourcesListComponent TResource="SystemResource"
 <ResourcesListComponent TResource="SystemResource"
                         Title="@PageTitle"
                         Title="@PageTitle"
                         TestId="systems"
                         TestId="systems"
                         Resources="@Systems"
                         Resources="@Systems"
                         ShouldGroup="true"
                         ShouldGroup="true"
-                        GroupBy="@(s => s.RunsOn)"
+                        GroupBy="@(s => {
+                          if (s.RunsOn is null) return "Unkown";
+                          return s.RunsOn.FirstOrDefault();
+                        })"
                         OnCreated="NavigateToNewResource">
                         OnCreated="NavigateToNewResource">
 
 
     <ItemTemplate Context="systemResource">
     <ItemTemplate Context="systemResource">

+ 271 - 0
Tests/schemas/schema.v2.json

@@ -0,0 +1,271 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 1
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "oneOf": [
+          { "$ref": "#/$defs/server" },
+          { "$ref": "#/$defs/firewall" },
+          { "$ref": "#/$defs/router" },
+          { "$ref": "#/$defs/switch" },
+          { "$ref": "#/$defs/accessPoint" },
+          { "$ref": "#/$defs/ups" },
+          { "$ref": "#/$defs/desktop" },
+          { "$ref": "#/$defs/laptop" },
+          { "$ref": "#/$defs/service" },
+          { "$ref": "#/$defs/system" }
+        ]
+      }
+    }
+  },
+  "$defs": {
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme","ssd","hdd","sas","sata","usb","sdcard","micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45","sfp","sfp+","sfp28","sfp56",
+            "qsfp+","qsfp28","qsfp56","qsfp-dd",
+            "osfp","xfp","cx4","mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": ["type","speed","count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": ["ip","port","protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP","UDP"] },
+        "url": { "type": "string" }
+      }
+    },
+    "runsOn": {
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
+
+    "server": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Server" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "notes": { "type": "string" },
+        "runsOn": { "$ref": "#/$defs/runsOn" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "ipmi": { "type": "boolean" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+      }
+    },
+
+    "desktop": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Desktop" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+      }
+    },
+
+    "laptop": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Laptop" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+      }
+    },
+
+    "firewall": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Firewall" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "router": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Router" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "switch": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Switch" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "accessPoint": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "AccessPoint" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "model": { "type": "string" },
+        "speed": { "type": "number" }
+      }
+    },
+
+    "ups": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Ups" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "model": { "type": "string" },
+        "va": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "service": {
+      "type": "object",
+      "required": ["kind","name","network","runsOn"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Service" },
+        "name": { "type": "string" },
+        "runsOn": { "$ref": "#/$defs/runsOn" },
+        "network": { "$ref": "#/$defs/network" }
+      }
+    },
+
+    "system": {
+      "type": "object",
+      "required": ["kind","name","type","os","cores","ram"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "System" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "runsOn": { "$ref": "#/$defs/runsOn" },
+        "type": {
+          "type": "string",
+          "enum": [
+            "baremetal","Baremetal",
+            "hypervisor","Hypervisor",
+            "vm","VM",
+            "container","embedded","cloud","other"
+          ]
+        },
+        "os": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "ram": { "type": "number", "minimum": 0 },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+      }
+    }
+  }
+}

+ 271 - 0
schemas/v2/schema.v2.json

@@ -0,0 +1,271 @@
+{
+  "$schema": "http://json-schema.org/draft-07/schema#",
+  "$id": "https://timmoth.github.io/RackPeek/schemas/v1/schema.v1.json",
+  "title": "RackPeek Infrastructure Specification",
+  "type": "object",
+  "additionalProperties": false,
+  "required": ["version", "resources"],
+  "properties": {
+    "version": {
+      "type": "integer",
+      "const": 1
+    },
+    "resources": {
+      "type": "array",
+      "items": {
+        "oneOf": [
+          { "$ref": "#/$defs/server" },
+          { "$ref": "#/$defs/firewall" },
+          { "$ref": "#/$defs/router" },
+          { "$ref": "#/$defs/switch" },
+          { "$ref": "#/$defs/accessPoint" },
+          { "$ref": "#/$defs/ups" },
+          { "$ref": "#/$defs/desktop" },
+          { "$ref": "#/$defs/laptop" },
+          { "$ref": "#/$defs/service" },
+          { "$ref": "#/$defs/system" }
+        ]
+      }
+    }
+  },
+  "$defs": {
+    "ram": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "size": { "type": "number", "minimum": 0 },
+        "mts": { "type": "integer", "minimum": 0 }
+      }
+    },
+    "cpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "threads": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "drive": {
+      "type": "object",
+      "required": ["size"],
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": ["nvme","ssd","hdd","sas","sata","usb","sdcard","micro-sd"]
+        },
+        "size": { "type": "number", "minimum": 1 }
+      }
+    },
+    "gpu": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "model": { "type": "string" },
+        "vram": { "type": "number", "minimum": 0 }
+      }
+    },
+    "nic": {
+      "type": "object",
+      "additionalProperties": false,
+      "properties": {
+        "type": {
+          "type": "string",
+          "enum": [
+            "rj45","sfp","sfp+","sfp28","sfp56",
+            "qsfp+","qsfp28","qsfp56","qsfp-dd",
+            "osfp","xfp","cx4","mgmt"
+          ]
+        },
+        "speed": { "type": "number", "minimum": 0 },
+        "ports": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "port": {
+      "type": "object",
+      "required": ["type","speed","count"],
+      "additionalProperties": false,
+      "properties": {
+        "type": { "type": "string" },
+        "speed": { "type": "number", "minimum": 0 },
+        "count": { "type": "integer", "minimum": 1 }
+      }
+    },
+    "network": {
+      "type": "object",
+      "required": ["ip","port","protocol"],
+      "additionalProperties": false,
+      "properties": {
+        "ip": {
+          "type": "string",
+          "pattern": "^((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\\.|$)){4}$"
+        },
+        "port": { "type": "integer", "minimum": 1, "maximum": 65535 },
+        "protocol": { "type": "string", "enum": ["TCP","UDP"] },
+        "url": { "type": "string" }
+      }
+    },
+    "runsOn": {
+      "type": "array",
+      "items": {
+        "type": "string"
+      }
+    },
+
+    "server": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Server" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "notes": { "type": "string" },
+        "runsOn": { "$ref": "#/$defs/runsOn" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "ipmi": { "type": "boolean" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+      }
+    },
+
+    "desktop": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Desktop" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } },
+        "gpus": { "type": "array", "items": { "$ref": "#/$defs/gpu" } },
+        "nics": { "type": "array", "items": { "$ref": "#/$defs/nic" } }
+      }
+    },
+
+    "laptop": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Laptop" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "ram": { "$ref": "#/$defs/ram" },
+        "cpus": { "type": "array", "items": { "$ref": "#/$defs/cpu" } },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+      }
+    },
+
+    "firewall": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Firewall" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "router": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Router" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "switch": {
+      "type": "object",
+      "required": ["kind","name","ports"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Switch" },
+        "name": { "type": "string" },
+        "model": { "type": "string" },
+        "managed": { "type": "boolean" },
+        "poe": { "type": "boolean" },
+        "ports": { "type": "array", "items": { "$ref": "#/$defs/port" } }
+      }
+    },
+
+    "accessPoint": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "AccessPoint" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "model": { "type": "string" },
+        "speed": { "type": "number" }
+      }
+    },
+
+    "ups": {
+      "type": "object",
+      "required": ["kind","name"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Ups" },
+        "name": { "type": "string" },
+        "tags": { "type": "array", "items": { "type": "string" } },
+        "model": { "type": "string" },
+        "va": { "type": "integer", "minimum": 1 }
+      }
+    },
+
+    "service": {
+      "type": "object",
+      "required": ["kind","name","network","runsOn"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "Service" },
+        "name": { "type": "string" },
+        "runsOn": { "$ref": "#/$defs/runsOn" },
+        "network": { "$ref": "#/$defs/network" }
+      }
+    },
+
+    "system": {
+      "type": "object",
+      "required": ["kind","name","type","os","cores","ram"],
+      "additionalProperties": false,
+      "properties": {
+        "kind": { "const": "System" },
+        "name": { "type": "string" },
+        "notes": { "type": "string" },
+        "runsOn": { "$ref": "#/$defs/runsOn" },
+        "type": {
+          "type": "string",
+          "enum": [
+            "baremetal","Baremetal",
+            "hypervisor","Hypervisor",
+            "vm","VM",
+            "container","embedded","cloud","other"
+          ]
+        },
+        "os": { "type": "string" },
+        "cores": { "type": "integer", "minimum": 1 },
+        "ram": { "type": "number", "minimum": 0 },
+        "drives": { "type": "array", "items": { "$ref": "#/$defs/drive" } }
+      }
+    }
+  }
+}