BuildLogicalGraphUseCase.cs 8.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. using RackPeek.Domain.Persistence;
  2. using RackPeek.Domain.Resources;
  3. using RackPeek.Domain.Resources.Hardware;
  4. using RackPeek.Domain.Resources.Services;
  5. using RackPeek.Domain.Resources.Services.Networking;
  6. using RackPeek.Domain.Resources.SystemResources;
  7. namespace RackPeek.Domain.Graph.UseCases;
  8. /// <summary>
  9. /// Logical / service-oriented view: services and systems grouped first
  10. /// by IP subnet (/24), then by their ultimate parent hardware. Edges
  11. /// show the immediate <c>runsOn</c> dependency.
  12. /// </summary>
  13. public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase {
  14. private const int _defaultPrefix = 24;
  15. public async Task<Graph> ExecuteAsync() {
  16. IReadOnlyList<Service> services = await repo.GetAllOfTypeAsync<Service>();
  17. IReadOnlyList<SystemResource> systems = await repo.GetAllOfTypeAsync<SystemResource>();
  18. IReadOnlyList<Hardware> hardware = repo.HardwareResources;
  19. var byName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
  20. foreach (Hardware hw in hardware) byName[hw.Name] = hw;
  21. foreach (SystemResource s in systems) byName[s.Name] = s;
  22. foreach (Service svc in services) byName[svc.Name] = svc;
  23. // Classify each non-hardware resource: which subnet, which parent host,
  24. // and what ip[:port] to show as the subtitle.
  25. var entries = new List<Entry>();
  26. foreach (Resource resource in services.Cast<Resource>().Concat(systems)) {
  27. var ip = FindIp(resource, byName);
  28. var subnet = SubnetCidr(ip, _defaultPrefix);
  29. Hardware? parentHw = FindParentHardware(resource, byName);
  30. if (subnet is null) continue; // skip orphans with no IP anywhere up the chain
  31. var subtitle = BuildSubtitle(resource, ip);
  32. entries.Add(new Entry(resource, subnet, parentHw?.Name, subtitle));
  33. }
  34. var nodes = entries
  35. .OrderBy(e => e.Subnet, StringComparer.Ordinal)
  36. .ThenBy(e => e.HardwareName ?? string.Empty, StringComparer.OrdinalIgnoreCase)
  37. .ThenBy(e => e.Resource.Name, StringComparer.OrdinalIgnoreCase)
  38. .Select(e => new GraphNode(
  39. e.Resource.Name, e.Resource.Name, NodeKind(e.Resource), e.Subtitle))
  40. .ToList();
  41. List<GraphGroup> groups = BuildGroups(entries);
  42. // Edges from each resource to its immediate runsOn target if both
  43. // ends are nodes in the graph. We omit edges that point at hardware
  44. // (hardware is the grouping label, not a node).
  45. HashSet<string> nodeIds = new(nodes.Select(n => n.Id), StringComparer.OrdinalIgnoreCase);
  46. List<GraphEdge> edges = new();
  47. foreach (Entry entry in entries) {
  48. var parentName = entry.Resource.RunsOn?.FirstOrDefault();
  49. if (parentName is null) continue;
  50. if (!nodeIds.Contains(parentName)) continue;
  51. edges.Add(new GraphEdge(entry.Resource.Name, parentName, null, "runsOn"));
  52. }
  53. return new Graph(nodes, edges, groups);
  54. }
  55. private static List<GraphGroup> BuildGroups(IReadOnlyList<Entry> entries) {
  56. var groups = new List<GraphGroup>();
  57. IOrderedEnumerable<IGrouping<string, Entry>> bySubnet = entries
  58. .GroupBy(e => e.Subnet, StringComparer.Ordinal)
  59. .OrderBy(g => g.Key, StringComparer.Ordinal);
  60. foreach (IGrouping<string, Entry> subnetGroup in bySubnet) {
  61. var subnetId = "g_" + Slug(subnetGroup.Key!);
  62. // Inner groups keyed by parent hardware. Entries with no parent
  63. // hardware fall directly into the subnet group.
  64. var directNodes = new List<string>();
  65. IOrderedEnumerable<IGrouping<string?, Entry>> byHardware = subnetGroup
  66. .GroupBy(e => e.HardwareName)
  67. .OrderBy(g => g.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase);
  68. foreach (IGrouping<string?, Entry> hwGroup in byHardware) {
  69. if (hwGroup.Key is null) {
  70. directNodes.AddRange(hwGroup.Select(e => e.Resource.Name));
  71. continue;
  72. }
  73. var hwGroupId = subnetId + "__" + Slug(hwGroup.Key);
  74. groups.Add(new GraphGroup(
  75. hwGroupId,
  76. hwGroup.Key,
  77. hwGroup.Select(e => e.Resource.Name).ToList(),
  78. subnetId));
  79. }
  80. groups.Add(new GraphGroup(subnetId, subnetGroup.Key!, directNodes, null));
  81. }
  82. return groups;
  83. }
  84. private static string NodeKind(Resource resource) {
  85. if (resource is Service) return "Service";
  86. if (resource is SystemResource sys) {
  87. if (string.IsNullOrWhiteSpace(sys.Type)) return "System";
  88. // "vm" → "Vm", "hypervisor" → "Hypervisor"; we look these up in the
  89. // serialiser's shape map case-insensitively, so casing doesn't
  90. // matter — but a canonical form keeps test assertions tidy.
  91. var t = sys.Type.Trim().ToLowerInvariant();
  92. return t switch {
  93. "hypervisor" => "Hypervisor",
  94. "vm" => "Vm",
  95. "container" => "Container",
  96. _ => "System"
  97. };
  98. }
  99. return resource.Kind;
  100. }
  101. private static string? FindIp(Resource resource, Dictionary<string, Resource> byName) {
  102. var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  103. Resource? current = resource;
  104. while (current is not null && visited.Add(current.Name)) {
  105. switch (current) {
  106. case Service { Network.Ip: { Length: > 0 } svcIp }:
  107. return svcIp;
  108. case SystemResource { Ip: { Length: > 0 } sysIp }:
  109. return sysIp;
  110. }
  111. var parent = current.RunsOn.FirstOrDefault();
  112. if (parent is null) return null;
  113. current = byName.GetValueOrDefault(parent);
  114. }
  115. return null;
  116. }
  117. private static Hardware? FindParentHardware(Resource resource, Dictionary<string, Resource> byName) {
  118. var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  119. Resource? current = resource;
  120. while (current is not null && visited.Add(current.Name)) {
  121. if (current is Hardware hw) return hw;
  122. var parent = current.RunsOn.FirstOrDefault();
  123. if (parent is null) return null;
  124. current = byName.GetValueOrDefault(parent);
  125. }
  126. return null;
  127. }
  128. private static string? SubnetCidr(string? ip, int prefix) {
  129. if (string.IsNullOrWhiteSpace(ip)) return null;
  130. try {
  131. var u = IpHelper.ToUInt32(ip);
  132. var mask = IpHelper.MaskFromPrefix(prefix);
  133. return $"{IpHelper.ToIp(u & mask)}/{prefix}";
  134. }
  135. catch {
  136. return null;
  137. }
  138. }
  139. private static string Slug(string value) {
  140. var chars = value.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_').ToArray();
  141. return new string(chars);
  142. }
  143. private static string? BuildSubtitle(Resource resource, string? ip) {
  144. // Services: ip:port (port from Network.Port if present). The ip is
  145. // whatever the runsOn chain resolves to — it may belong to the host,
  146. // not the service itself, but that's the relevant address for users.
  147. if (resource is Service service) {
  148. var port = service.Network?.Port;
  149. if (!string.IsNullOrWhiteSpace(ip) && port.HasValue) return $"{ip}:{port}";
  150. if (!string.IsNullOrWhiteSpace(ip)) return ip;
  151. return port.HasValue ? $":{port}" : null;
  152. }
  153. // Systems: just their own IP. We don't show ports for systems because
  154. // the data model doesn't track listening ports at the system level.
  155. if (resource is SystemResource system && !string.IsNullOrWhiteSpace(system.Ip))
  156. return system.Ip;
  157. return null;
  158. }
  159. private readonly record struct Entry(
  160. Resource Resource,
  161. string Subnet,
  162. string? HardwareName,
  163. string? Subtitle);
  164. }