using RackPeek.Domain.Persistence; using RackPeek.Domain.Resources; using RackPeek.Domain.Resources.Hardware; using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.Services.Networking; using RackPeek.Domain.Resources.SystemResources; namespace RackPeek.Domain.Graph.UseCases; /// /// Logical / service-oriented view: services and systems grouped first /// by IP subnet (/24), then by their ultimate parent hardware. Edges /// show the immediate runsOn dependency. /// public class BuildLogicalGraphUseCase(IResourceCollection repo) : IUseCase { private const int _defaultPrefix = 24; public async Task ExecuteAsync() { IReadOnlyList services = await repo.GetAllOfTypeAsync(); IReadOnlyList systems = await repo.GetAllOfTypeAsync(); IReadOnlyList hardware = repo.HardwareResources; var byName = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (Hardware hw in hardware) byName[hw.Name] = hw; foreach (SystemResource s in systems) byName[s.Name] = s; foreach (Service svc in services) byName[svc.Name] = svc; // Classify each non-hardware resource: which subnet, which parent host, // and what ip[:port] to show as the subtitle. var entries = new List(); foreach (Resource resource in services.Cast().Concat(systems)) { var ip = FindIp(resource, byName); var subnet = SubnetCidr(ip, _defaultPrefix); Hardware? parentHw = FindParentHardware(resource, byName); if (subnet is null) continue; // skip orphans with no IP anywhere up the chain var subtitle = BuildSubtitle(resource, ip); entries.Add(new Entry(resource, subnet, parentHw?.Name, subtitle)); } var nodes = entries .OrderBy(e => e.Subnet, StringComparer.Ordinal) .ThenBy(e => e.HardwareName ?? string.Empty, StringComparer.OrdinalIgnoreCase) .ThenBy(e => e.Resource.Name, StringComparer.OrdinalIgnoreCase) .Select(e => new GraphNode( e.Resource.Name, e.Resource.Name, NodeKind(e.Resource), e.Subtitle)) .ToList(); List groups = BuildGroups(entries); // Edges from each resource to its immediate runsOn target if both // ends are nodes in the graph. We omit edges that point at hardware // (hardware is the grouping label, not a node). HashSet nodeIds = new(nodes.Select(n => n.Id), StringComparer.OrdinalIgnoreCase); List edges = new(); foreach (Entry entry in entries) { var parentName = entry.Resource.RunsOn?.FirstOrDefault(); if (parentName is null) continue; if (!nodeIds.Contains(parentName)) continue; edges.Add(new GraphEdge(entry.Resource.Name, parentName, null, "runsOn")); } return new Graph(nodes, edges, groups); } private static List BuildGroups(IReadOnlyList entries) { var groups = new List(); IOrderedEnumerable> bySubnet = entries .GroupBy(e => e.Subnet, StringComparer.Ordinal) .OrderBy(g => g.Key, StringComparer.Ordinal); foreach (IGrouping subnetGroup in bySubnet) { var subnetId = "g_" + Slug(subnetGroup.Key!); // Inner groups keyed by parent hardware. Entries with no parent // hardware fall directly into the subnet group. var directNodes = new List(); IOrderedEnumerable> byHardware = subnetGroup .GroupBy(e => e.HardwareName) .OrderBy(g => g.Key ?? string.Empty, StringComparer.OrdinalIgnoreCase); foreach (IGrouping hwGroup in byHardware) { if (hwGroup.Key is null) { directNodes.AddRange(hwGroup.Select(e => e.Resource.Name)); continue; } var hwGroupId = subnetId + "__" + Slug(hwGroup.Key); groups.Add(new GraphGroup( hwGroupId, hwGroup.Key, hwGroup.Select(e => e.Resource.Name).ToList(), subnetId)); } groups.Add(new GraphGroup(subnetId, subnetGroup.Key!, directNodes, null)); } return groups; } private static string NodeKind(Resource resource) { if (resource is Service) return "Service"; if (resource is SystemResource sys) { if (string.IsNullOrWhiteSpace(sys.Type)) return "System"; // "vm" → "Vm", "hypervisor" → "Hypervisor"; we look these up in the // serialiser's shape map case-insensitively, so casing doesn't // matter — but a canonical form keeps test assertions tidy. var t = sys.Type.Trim().ToLowerInvariant(); return t switch { "hypervisor" => "Hypervisor", "vm" => "Vm", "container" => "Container", _ => "System" }; } return resource.Kind; } private static string? FindIp(Resource resource, Dictionary byName) { var visited = new HashSet(StringComparer.OrdinalIgnoreCase); Resource? current = resource; while (current is not null && visited.Add(current.Name)) { switch (current) { case Service { Network.Ip: { Length: > 0 } svcIp }: return svcIp; case SystemResource { Ip: { Length: > 0 } sysIp }: return sysIp; } var parent = current.RunsOn.FirstOrDefault(); if (parent is null) return null; current = byName.GetValueOrDefault(parent); } return null; } private static Hardware? FindParentHardware(Resource resource, Dictionary byName) { var visited = new HashSet(StringComparer.OrdinalIgnoreCase); Resource? current = resource; while (current is not null && visited.Add(current.Name)) { if (current is Hardware hw) return hw; var parent = current.RunsOn.FirstOrDefault(); if (parent is null) return null; current = byName.GetValueOrDefault(parent); } return null; } private static string? SubnetCidr(string? ip, int prefix) { if (string.IsNullOrWhiteSpace(ip)) return null; try { var u = IpHelper.ToUInt32(ip); var mask = IpHelper.MaskFromPrefix(prefix); return $"{IpHelper.ToIp(u & mask)}/{prefix}"; } catch { return null; } } private static string Slug(string value) { var chars = value.Select(c => char.IsLetterOrDigit(c) ? char.ToLowerInvariant(c) : '_').ToArray(); return new string(chars); } private static string? BuildSubtitle(Resource resource, string? ip) { // Services: ip:port (port from Network.Port if present). The ip is // whatever the runsOn chain resolves to — it may belong to the host, // not the service itself, but that's the relevant address for users. if (resource is Service service) { var port = service.Network?.Port; if (!string.IsNullOrWhiteSpace(ip) && port.HasValue) return $"{ip}:{port}"; if (!string.IsNullOrWhiteSpace(ip)) return ip; return port.HasValue ? $":{port}" : null; } // Systems: just their own IP. We don't show ports for systems because // the data model doesn't track listening ports at the system level. if (resource is SystemResource system && !string.IsNullOrWhiteSpace(system.Ip)) return system.Ip; return null; } private readonly record struct Entry( Resource Resource, string Subnet, string? HardwareName, string? Subtitle); }