4
0

BuildLogicalGraphUseCase.cs 7.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188
  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. Each system (hypervisor, VM, LXC,
  10. /// container) becomes a single "host card" whose body lists every
  11. /// service running on it. Cards are grouped subnet → hardware. No edges
  12. /// are emitted — containment alone conveys "runs on", and the
  13. /// serialiser stacks siblings vertically via invisible links.
  14. /// </summary>
  15. public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase {
  16. private const int _defaultPrefix = 24;
  17. public async Task<Graph> ExecuteAsync() {
  18. IReadOnlyList<Service> services = await repo.GetAllOfTypeAsync<Service>();
  19. IReadOnlyList<SystemResource> systems = await repo.GetAllOfTypeAsync<SystemResource>();
  20. IReadOnlyList<Hardware> hardware = repo.HardwareResources;
  21. var byName = new Dictionary<string, Resource>(StringComparer.OrdinalIgnoreCase);
  22. foreach (Hardware hw in hardware) byName[hw.Name] = hw;
  23. foreach (SystemResource s in systems) byName[s.Name] = s;
  24. foreach (Service svc in services) byName[svc.Name] = svc;
  25. // Group services by the system they ultimately run on. We resolve
  26. // the immediate runsOn first — that's the host the service was
  27. // declared against. Services whose immediate runsOn isn't a known
  28. // system (e.g. it points at hardware or is missing) are dropped from
  29. // the compact view since they have no host card to live inside.
  30. var servicesByHost = new Dictionary<string, List<Service>>(StringComparer.OrdinalIgnoreCase);
  31. foreach (Service service in services) {
  32. var parent = service.RunsOn.FirstOrDefault();
  33. if (parent is null) continue;
  34. if (!byName.TryGetValue(parent, out Resource? parentResource)) continue;
  35. if (parentResource is not SystemResource) continue;
  36. if (!servicesByHost.TryGetValue(parent, out List<Service>? list))
  37. servicesByHost[parent] = list = new List<Service>();
  38. list.Add(service);
  39. }
  40. // Each system becomes a host card. Hosts without services still
  41. // appear as a labelled card (e.g. a hypervisor that only contains
  42. // VMs has no services running directly on it, but is still a
  43. // meaningful logical entity).
  44. var hostEntries = new List<HostEntry>();
  45. foreach (SystemResource sys in systems) {
  46. var ip = FindIp(sys, byName);
  47. var subnet = SubnetCidr(ip, _defaultPrefix);
  48. if (subnet is null) continue;
  49. Hardware? parentHw = FindParentHardware(sys, byName);
  50. servicesByHost.TryGetValue(sys.Name, out List<Service>? hostServices);
  51. var rows = (hostServices ?? new List<Service>())
  52. .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
  53. .Select(s => new GraphNodeRow(s.Name, ServiceDetail(s)))
  54. .ToList();
  55. hostEntries.Add(new HostEntry(sys, subnet, parentHw?.Name, ip, rows));
  56. }
  57. var nodes = hostEntries
  58. .OrderBy(e => e.Subnet, StringComparer.Ordinal)
  59. .ThenBy(e => e.HardwareName ?? string.Empty, StringComparer.OrdinalIgnoreCase)
  60. .ThenByDescending(e => e.Rows.Count) // big cards first within a hardware bucket
  61. .ThenBy(e => e.System.Name, StringComparer.OrdinalIgnoreCase)
  62. .Select(e => new GraphNode(
  63. e.System.Name,
  64. e.System.Name,
  65. NodeKind(e.System),
  66. e.Ip,
  67. Rows: e.Rows.Count > 0 ? e.Rows : null))
  68. .ToList();
  69. List<GraphGroup> groups = BuildGroups(hostEntries);
  70. return new Graph(nodes, [], groups, GraphRenderHint.Compact);
  71. }
  72. private static List<GraphGroup> BuildGroups(IReadOnlyList<HostEntry> entries) {
  73. var groups = new List<GraphGroup>();
  74. IOrderedEnumerable<IGrouping<string, HostEntry>> bySubnet = entries
  75. .GroupBy(e => e.Subnet, StringComparer.Ordinal)
  76. .OrderBy(g => g.Key, StringComparer.Ordinal);
  77. foreach (IGrouping<string, HostEntry> subnetGroup in bySubnet) {
  78. var subnetId = "g_" + Slug(subnetGroup.Key);
  79. var directNodes = new List<string>();
  80. IOrderedEnumerable<IGrouping<string?, HostEntry>> byHardware = subnetGroup
  81. .GroupBy(e => e.HardwareName)
  82. .OrderBy(g => g.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase);
  83. foreach (IGrouping<string?, HostEntry> hwGroup in byHardware) {
  84. if (hwGroup.Key is null) {
  85. directNodes.AddRange(hwGroup.Select(e => e.System.Name));
  86. continue;
  87. }
  88. var hwGroupId = subnetId + "__" + Slug(hwGroup.Key);
  89. groups.Add(new GraphGroup(
  90. hwGroupId,
  91. hwGroup.Key,
  92. hwGroup.Select(e => e.System.Name).ToList(),
  93. subnetId));
  94. }
  95. groups.Add(new GraphGroup(subnetId, subnetGroup.Key, directNodes, null));
  96. }
  97. return groups;
  98. }
  99. private static string NodeKind(SystemResource sys) {
  100. if (string.IsNullOrWhiteSpace(sys.Type)) return "System";
  101. var t = sys.Type.Trim().ToLowerInvariant();
  102. return t switch {
  103. "hypervisor" => "Hypervisor",
  104. "vm" => "Vm",
  105. "container" => "Container",
  106. _ => "System"
  107. };
  108. }
  109. private static string? ServiceDetail(Service service) {
  110. var port = service.Network?.Port;
  111. return port.HasValue ? ":" + port.Value : null;
  112. }
  113. private static string? FindIp(Resource resource, Dictionary<string, Resource> byName) {
  114. var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  115. Resource? current = resource;
  116. while (current is not null && visited.Add(current.Name)) {
  117. switch (current) {
  118. case Service { Network.Ip: { Length: > 0 } svcIp }:
  119. return svcIp;
  120. case SystemResource { Ip: { Length: > 0 } sysIp }:
  121. return sysIp;
  122. }
  123. var parent = current.RunsOn.FirstOrDefault();
  124. if (parent is null) return null;
  125. current = byName.GetValueOrDefault(parent);
  126. }
  127. return null;
  128. }
  129. private static Hardware? FindParentHardware(Resource resource, Dictionary<string, Resource> byName) {
  130. var visited = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
  131. Resource? current = resource;
  132. while (current is not null && visited.Add(current.Name)) {
  133. if (current is Hardware hw) return hw;
  134. var parent = current.RunsOn.FirstOrDefault();
  135. if (parent is null) return null;
  136. current = byName.GetValueOrDefault(parent);
  137. }
  138. return null;
  139. }
  140. private static string? SubnetCidr(string? ip, int prefix) {
  141. if (string.IsNullOrWhiteSpace(ip)) return null;
  142. try {
  143. var u = IpHelper.ToUInt32(ip);
  144. var mask = IpHelper.MaskFromPrefix(prefix);
  145. return $"{IpHelper.ToIp(u & mask)}/{prefix}";
  146. }
  147. catch {
  148. return null;
  149. }
  150. }
  151. private static string Slug(string value) {
  152. var chars = value.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_').ToArray();
  153. return new string(chars);
  154. }
  155. private readonly record struct HostEntry(
  156. SystemResource System,
  157. string Subnet,
  158. string? HardwareName,
  159. string? Ip,
  160. IReadOnlyList<GraphNodeRow> Rows);
  161. }