using System.Collections.ObjectModel; using System.Collections.Specialized; using System.Diagnostics; using RackPeek.Domain.Resources; using RackPeek.Domain.Resources.AccessPoints; using RackPeek.Domain.Resources.Connections; using RackPeek.Domain.Resources.Desktops; using RackPeek.Domain.Resources.Firewalls; using RackPeek.Domain.Resources.Hardware; using RackPeek.Domain.Resources.Laptops; using RackPeek.Domain.Resources.Routers; using RackPeek.Domain.Resources.Servers; using RackPeek.Domain.Resources.Services; using RackPeek.Domain.Resources.SystemResources; using RackPeek.Domain.Resources.UpsUnits; using YamlDotNet.Serialization; using YamlDotNet.Serialization.NamingConventions; using Switch = RackPeek.Domain.Resources.Switches.Switch; namespace RackPeek.Domain.Persistence.Yaml; public class ResourceCollection { public readonly SemaphoreSlim FileLock = new(1, 1); public List Resources { get; } = new(); public List Connections { get; } = new(); } public sealed class YamlResourceCollection( string filePath, ITextFileStore fileStore, ResourceCollection resourceCollection, IResourceYamlMigrationService migrationService) : IResourceCollection { // Bump this when your YAML schema changes, and add a migration step below. private static readonly int _currentSchemaVersion = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count; public Task Exists(string name) { return Task.FromResult(resourceCollection.Resources.Exists(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))); } public Task GetKind(string? name) { return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind); } public Task> GetByLabelAsync(string name) { ReadOnlyCollection<(Resource r, string)> result = resourceCollection.Resources .Where(r => r.Labels != null && r.Labels.TryGetValue(name, out _)) .Select(r => (r, r.Labels![name])) .ToList() .AsReadOnly(); return Task.FromResult>(result); } public Task> GetLabelsAsync() { var result = resourceCollection.Resources .SelectMany(r => r.Labels ?? Enumerable.Empty>()) .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key)) .GroupBy(kvp => kvp.Key) .ToDictionary(g => g.Key, g => g.Count()); return Task.FromResult(result); } public Task> GetResourceIpsAsync() { var result = new List<(Resource, string)>(); List allResources = resourceCollection.Resources; // Build fast lookup for systems var systemsByName = allResources .OfType() .ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase); // Cache resolved system IPs (prevents repeated recursion) var resolvedSystemIps = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (Resource resource in allResources) switch (resource) { case SystemResource system: { var ip = ResolveSystemIp(system, systemsByName, resolvedSystemIps); if (!string.IsNullOrWhiteSpace(ip)) result.Add((system, ip)); break; } case Service service: { var ip = ResolveServiceIp(service, systemsByName, resolvedSystemIps); if (!string.IsNullOrWhiteSpace(ip)) result.Add((service, ip)); break; } } return Task.FromResult((IReadOnlyList<(Resource, string)>)result); } public Task> GetTagsAsync() { var result = resourceCollection.Resources .SelectMany(r => r.Tags) // flatten all tag arrays .Where(t => !string.IsNullOrWhiteSpace(t)) .GroupBy(t => t) .ToDictionary(g => g.Key, g => g.Count()); return Task.FromResult(result); } public Task> GetAllOfTypeAsync() => Task.FromResult>(resourceCollection.Resources.OfType().ToList()); public Task> GetDependantsAsync(string name) { var result = resourceCollection.Resources .Where(r => r.RunsOn.Any(p => p.Equals(name, StringComparison.OrdinalIgnoreCase))) .ToList(); return Task.FromResult>(result); } public async Task Merge(string incomingYaml, MergeMode mode) { if (string.IsNullOrWhiteSpace(incomingYaml)) return; await resourceCollection.FileLock.WaitAsync(); try { YamlRoot incomingRoot = await migrationService.DeserializeAsync(incomingYaml); List incomingResources = incomingRoot.Resources ?? new List(); List merged = ResourceCollectionMerger.Merge( resourceCollection.Resources, incomingResources, mode); resourceCollection.Resources.Clear(); resourceCollection.Resources.AddRange(merged); var rootToSave = new YamlRoot { Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count, Resources = resourceCollection.Resources, Connections = resourceCollection.Connections }; await SaveRootAsync(rootToSave); } finally { resourceCollection.FileLock.Release(); } } public Task> GetByTagAsync(string name) { return Task.FromResult>( resourceCollection.Resources .Where(r => r.Tags.Contains(name)) .ToList() ); } public IReadOnlyList HardwareResources => resourceCollection.Resources.OfType().ToList(); public IReadOnlyList SystemResources => resourceCollection.Resources.OfType().ToList(); public IReadOnlyList ServiceResources => resourceCollection.Resources.OfType().ToList(); public Task GetByNameAsync(string name) { return Task.FromResult(resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))); } public Task GetByNameAsync(string name) where T : Resource { Resource? resource = resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(resource as T); } public Resource? GetByName(string name) { return resourceCollection.Resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)); } public async Task LoadAsync() { var yaml = await fileStore.ReadAllTextAsync(filePath); YamlRoot root = await migrationService.DeserializeAsync( yaml, async originalYaml => await BackupOriginalAsync(originalYaml), async migratedRoot => await SaveRootAsync(migratedRoot) ); resourceCollection.Resources.Clear(); if (root.Resources != null) resourceCollection.Resources.AddRange(root.Resources); resourceCollection.Connections.Clear(); if (root.Connections != null) resourceCollection.Connections.AddRange(root.Connections); } public Task AddAsync(Resource resource) { return UpdateWithLockAsync(list => { if (list.Any(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase))) throw new InvalidOperationException($"'{resource.Name}' already exists."); resource.Kind = GetKind(resource); list.Add(resource); }); } public Task UpdateAsync(Resource resource) { return UpdateWithLockAsync(list => { var index = list.FindIndex(r => r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)); if (index == -1) throw new InvalidOperationException("Not found."); resource.Kind = GetKind(resource); list[index] = resource; }); } public Task DeleteAsync(string name) { return UpdateWithLockAsync(list => list.RemoveAll(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))); } public Task AddConnectionAsync(Connection connection) => UpdateConnectionsWithLockAsync(list => { list.Add(connection); }); public Task RemoveConnectionAsync(Connection connection) { return UpdateConnectionsWithLockAsync(list => { list.RemoveAll(c => (PortsMatch(c.A, connection.A) && PortsMatch(c.B, connection.B)) || (PortsMatch(c.A, connection.B) && PortsMatch(c.B, connection.A))); }); } public Task RemoveConnectionsForPortAsync(PortReference port) { return UpdateConnectionsWithLockAsync(list => { list.RemoveAll(c => PortsMatch(c.A, port) || PortsMatch(c.B, port)); }); } public Task> GetConnectionsAsync() { IReadOnlyList result = resourceCollection.Connections .ToList() .AsReadOnly(); return Task.FromResult(result); } public Task> GetConnectionsForResourceAsync(string resource) { IReadOnlyList result = resourceCollection.Connections .Where(c => c.A.Resource.Equals(resource, StringComparison.OrdinalIgnoreCase) || c.B.Resource.Equals(resource, StringComparison.OrdinalIgnoreCase)) .ToList() .AsReadOnly(); return Task.FromResult(result); } public Task GetConnectionForPortAsync(PortReference port) { Connection? connection = resourceCollection.Connections .FirstOrDefault(c => PortsMatch(c.A, port) || PortsMatch(c.B, port)); return Task.FromResult(connection); } private string? ResolveSystemIp( SystemResource system, Dictionary systemsByName, Dictionary cache) { // Return cached result if already resolved if (cache.TryGetValue(system.Name, out var cached)) return cached; // Direct IP wins if (!string.IsNullOrWhiteSpace(system.Ip)) { cache[system.Name] = system.Ip; return system.Ip; } // Must have exactly one parent if (system.RunsOn?.Count != 1) { cache[system.Name] = null; return null; } var parentName = system.RunsOn.First(); if (!systemsByName.TryGetValue(parentName, out SystemResource? parent)) { cache[system.Name] = null; return null; } var resolved = ResolveSystemIp(parent, systemsByName, cache); cache[system.Name] = resolved; return resolved; } private string? ResolveServiceIp( Service service, Dictionary systemsByName, Dictionary cache) { // Direct IP wins if (!string.IsNullOrWhiteSpace(service.Network?.Ip)) return service.Network!.Ip; // Must have exactly one parent if (service.RunsOn?.Count != 1) return null; var parentName = service.RunsOn.First(); if (!systemsByName.TryGetValue(parentName, out SystemResource? parent)) return null; return ResolveSystemIp(parent, systemsByName, cache); } private async Task UpdateWithLockAsync(Action> action) { await resourceCollection.FileLock.WaitAsync(); try { action(resourceCollection.Resources); // Always write current schema version when app writes the file. var root = new YamlRoot { Version = _currentSchemaVersion, Resources = resourceCollection.Resources, Connections = resourceCollection.Connections }; await SaveRootAsync(root); } finally { resourceCollection.FileLock.Release(); } } // ---------------------------- // Versioning + migration // ---------------------------- private async Task BackupOriginalAsync(string originalYaml) { // Timestamped backup for safe rollback var backupPath = $"{filePath}.bak.{DateTime.UtcNow:yyyyMMddHHmmss}"; await fileStore.WriteAllTextAsync(backupPath, originalYaml); } private async Task SaveRootAsync(YamlRoot? root) { var contents = SerializeRootAsync(root); await fileStore.WriteAllTextAsync(filePath, contents); } public static string SerializeRootAsync(YamlRoot? root) { ISerializer serializer = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .WithTypeConverter(new StorageSizeYamlConverter()) .WithTypeConverter(new NotesStringYamlConverter()) .ConfigureDefaultValuesHandling( DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections ) .Build(); // Preserve ordering: version first, then resources Debug.Assert(root != null, nameof(root) + " != null"); var payload = new OrderedDictionary { ["version"] = root.Version, ["resources"] = (root.Resources ?? new List()).Select(SerializeResource).ToList(), ["connections"] = root.Connections ?? new List() }; return serializer.Serialize(payload); } private static string GetKind(Resource resource) { return resource switch { Server => "Server", Switch => "Switch", Firewall => "Firewall", Router => "Router", Desktop => "Desktop", Laptop => "Laptop", AccessPoint => "AccessPoint", Ups => "Ups", SystemResource => "System", Service => "Service", _ => throw new InvalidOperationException($"Unknown resource type: {resource.GetType().Name}") }; } public static OrderedDictionary SerializeResource(Resource resource) { var map = new OrderedDictionary { ["kind"] = GetKind(resource) }; ISerializer serializer = new SerializerBuilder() .WithNamingConvention(CamelCaseNamingConvention.Instance) .WithTypeConverter(new NotesStringYamlConverter()) .ConfigureDefaultValuesHandling( DefaultValuesHandling.OmitNull | DefaultValuesHandling.OmitEmptyCollections ) .Build(); var yaml = serializer.Serialize(resource); Dictionary props = new DeserializerBuilder() .Build() .Deserialize>(yaml); foreach ((var key, var value) in props) if (!string.Equals(key, "kind", StringComparison.OrdinalIgnoreCase)) map[key] = value; return map; } private static bool PortsMatch(PortReference a, PortReference b) { return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase) && a.PortGroup == b.PortGroup && a.PortIndex == b.PortIndex; } private async Task UpdateConnectionsWithLockAsync(Action> action) { await resourceCollection.FileLock.WaitAsync(); try { action(resourceCollection.Connections); var root = new YamlRoot { Version = _currentSchemaVersion, Resources = resourceCollection.Resources, Connections = resourceCollection.Connections }; await SaveRootAsync(root); } finally { resourceCollection.FileLock.Release(); } } } public class YamlRoot { public int Version { get; set; } public List? Resources { get; set; } public List? Connections { get; set; } }