4
0
Tim Jones 1 сар өмнө
parent
commit
514657b907

+ 4 - 0
RackPeek.Domain/Persistence/IResourceCollection.cs

@@ -34,4 +34,8 @@ public interface IResourceCollection
 
     Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>();
     Task<IReadOnlyList<Resource>> GetDependantsAsync(string name);
+
+    Task Merge(string incomingYaml, MergeMode mode);
+
+
 }

+ 0 - 335
RackPeek.Domain/Persistence/InMemoryResourceCollection.cs

@@ -1,335 +0,0 @@
-using RackPeek.Domain.Resources;
-using RackPeek.Domain.Resources.AccessPoints;
-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.Switches;
-using RackPeek.Domain.Resources.SystemResources;
-using RackPeek.Domain.Resources.UpsUnits;
-
-namespace RackPeek.Domain.Persistence;
-
-public sealed class InMemoryResourceCollection(IEnumerable<Resource>? seed = null) : IResourceCollection
-{
-    private readonly object _lock = new();
-    private readonly List<Resource> _resources = seed?.ToList() ?? [];
-
-    public IReadOnlyList<Hardware> HardwareResources
-    {
-        get
-        {
-            lock (_lock)
-            {
-                return _resources.OfType<Hardware>().ToList();
-            }
-        }
-    }
-
-    public IReadOnlyList<SystemResource> SystemResources
-    {
-        get
-        {
-            lock (_lock)
-            {
-                return _resources.OfType<SystemResource>().ToList();
-            }
-        }
-    }
-
-    public IReadOnlyList<Service> ServiceResources
-    {
-        get
-        {
-            lock (_lock)
-            {
-                return _resources.OfType<Service>().ToList();
-            }
-        }
-    }
-
-    public Task<bool> Exists(string name)
-    {
-        lock (_lock)
-        {
-            return Task.FromResult(_resources.Exists(r =>
-                r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
-        }
-    }
-
-    public Task<string?> GetKind(string name)
-    {
-        lock (_lock)
-        {
-            return Task.FromResult(_resources.FirstOrDefault(r =>
-                r.Name.Equals(name, StringComparison.OrdinalIgnoreCase))?.Kind);
-        }
-        
-    }
-
-    public Task LoadAsync()
-    {
-        return Task.CompletedTask;
-    }
-
-    public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
-    {
-        lock (_lock)
-        {
-            return Task.FromResult<IReadOnlyList<Resource>>(_resources.Where(r => r.Tags.Contains(name)).ToList());
-        }
-    }
-
-    public Task<Dictionary<string, int>> GetTagsAsync()
-    {
-        lock (_lock)
-        {
-            var result = _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<IReadOnlyList<(Resource, string)>> GetByLabelAsync(string name)
-    {
-        lock (_lock)
-        {
-            var result = _resources
-                .Select(r =>
-                {
-                    if (r.Labels != null && r.Labels.TryGetValue(name, out var value))
-                        return (found: true, pair: (r, value));
-
-                    return (found: false, pair: default);
-                })
-                .Where(x => x.found)
-                .Select(x => x.pair)
-                .ToList()
-                .AsReadOnly();
-
-            return Task.FromResult<IReadOnlyList<(Resource, string)>>(result);
-        }
-    }
-
-    public Task<Dictionary<string, int>> GetLabelsAsync()
-    {
-        lock (_lock)
-        {
-            var result = _resources
-                .SelectMany(r => r.Labels ?? Enumerable.Empty<KeyValuePair<string, string>>())
-                .Where(kvp => !string.IsNullOrWhiteSpace(kvp.Key))
-                .GroupBy(kvp => kvp.Key)
-                .ToDictionary(g => g.Key, g => g.Count());
-
-            return Task.FromResult(result);
-        }
-    }
-
-      public Task<IReadOnlyList<(Resource, string)>> GetResourceIpsAsync()
-    {        lock (_lock)
-        {
-        var result = new List<(Resource, string)>();
-
-        var allResources = _resources;
-
-        var systemsByName = allResources
-            .OfType<SystemResource>()
-            .ToDictionary(s => s.Name, StringComparer.OrdinalIgnoreCase);
-
-        // Cache resolved system IPs (prevents repeated recursion)
-        var resolvedSystemIps = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
-
-        foreach (var 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);
-        }
-    }
-    private string? ResolveSystemIp(
-        SystemResource system,
-        Dictionary<string, SystemResource> systemsByName,
-        Dictionary<string, string?> 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 var 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<string, SystemResource> systemsByName,
-        Dictionary<string, string?> 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 var parent))
-            return null;
-
-        return ResolveSystemIp(parent, systemsByName, cache);
-    }
-    
-    public Task<IReadOnlyList<T>> GetAllOfTypeAsync<T>()
-    {
-        lock (_lock)
-        {
-            return Task.FromResult<IReadOnlyList<T>>(_resources.OfType<T>().ToList());
-        }
-    }
-
-    public Task<IReadOnlyList<Resource>> GetDependantsAsync(string name)
-    {
-        lock (_lock)
-        {
-            return Task.FromResult<IReadOnlyList<Resource>>(_resources
-                .Where(r => r.RunsOn.Select(p => p.Equals(name, StringComparison.OrdinalIgnoreCase)).ToList().Count != 0).ToList());
-        }
-    }
-
-
-    public Task AddAsync(Resource resource)
-    {
-        lock (_lock)
-        {
-            if (_resources.Any(r =>
-                    r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase)))
-                throw new InvalidOperationException($"'{resource.Name}' already exists.");
-
-            resource.Kind = GetKind(resource);
-            _resources.Add(resource);
-        }
-
-        return Task.CompletedTask;
-    }
-
-    public Task UpdateAsync(Resource resource)
-    {
-        lock (_lock)
-        {
-            var index = _resources.FindIndex(r =>
-                r.Name.Equals(resource.Name, StringComparison.OrdinalIgnoreCase));
-
-            if (index == -1)
-                throw new InvalidOperationException("Not found.");
-
-            resource.Kind = GetKind(resource);
-            _resources[index] = resource;
-        }
-
-        return Task.CompletedTask;
-    }
-
-    public Task DeleteAsync(string name)
-    {
-        lock (_lock)
-        {
-            _resources.RemoveAll(r =>
-                r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
-        }
-
-        return Task.CompletedTask;
-    }
-
-    public Task<Resource?> GetByNameAsync(string name)
-    {
-        lock (_lock)
-        {
-            return Task.FromResult(_resources.FirstOrDefault(r =>
-                r.Name.Equals(name, StringComparison.OrdinalIgnoreCase)));
-        }
-    }
-
-    public Task<T?> GetByNameAsync<T>(string name) where T : Resource
-    {
-        lock (_lock)
-        {
-            var resource = _resources.FirstOrDefault(r => r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
-            return Task.FromResult<T?>(resource as T);
-        }
-    }
-
-    public Resource? GetByName(string name)
-    {
-        lock (_lock)
-        {
-            return _resources.FirstOrDefault(r =>
-                r.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
-        }
-    }
-
-    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}")
-        };
-    }
-}

+ 227 - 0
RackPeek.Domain/Persistence/ResourceCollectionMerger.cs

@@ -0,0 +1,227 @@
+using RackPeek.Domain.Resources;
+using System.Reflection;
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Text.Json.Serialization.Metadata;
+using RackPeek.Domain.Resources.AccessPoints;
+using RackPeek.Domain.Resources.Desktops;
+using RackPeek.Domain.Resources.Firewalls;
+using RackPeek.Domain.Resources.Laptops;
+using RackPeek.Domain.Resources.Routers;
+using RackPeek.Domain.Resources.Servers;
+using RackPeek.Domain.Resources.Services;
+using RackPeek.Domain.Resources.Switches;
+using RackPeek.Domain.Resources.SystemResources;
+using RackPeek.Domain.Resources.UpsUnits;
+
+namespace RackPeek.Domain.Persistence;
+
+public enum MergeMode
+{
+    Replace,
+    Merge
+}
+
+public static class ResourceCollectionMerger
+{
+    private static readonly JsonSerializerOptions CloneJsonOptions = new()
+    {
+        PropertyNameCaseInsensitive = true,
+        WriteIndented = false,
+        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
+        ReferenceHandler = ReferenceHandler.IgnoreCycles,
+        TypeInfoResolver = ResourcePolymorphismResolver.Create()
+    };
+
+    public static List<Resource> Merge(
+        IEnumerable<Resource> original,
+        IEnumerable<Resource> incoming,
+        MergeMode mode)
+    {
+        var originalClone = DeepCloneList(original);
+        var incomingClone = DeepCloneList(incoming);
+
+        var result = originalClone.ToDictionary(r => r.Name, r => r, StringComparer.OrdinalIgnoreCase);
+
+        foreach (var newResource in incomingClone)
+        {
+            if (!result.TryGetValue(newResource.Name, out var existing))
+            {
+                result[newResource.Name] = newResource;
+                continue;
+            }
+
+            if (mode == MergeMode.Replace ||
+                existing.GetType() != newResource.GetType())
+            {
+                result[newResource.Name] = newResource;
+                continue;
+            }
+
+            DeepMerge(existing, newResource, mode);
+            
+        }
+
+        return result.Values.ToList();
+    }
+
+    private static List<Resource> DeepCloneList(IEnumerable<Resource> resources)
+    {
+        var json = JsonSerializer.Serialize(resources, CloneJsonOptions);
+        return JsonSerializer.Deserialize<List<Resource>>(json, CloneJsonOptions) ?? new List<Resource>();
+    }
+    private static void DeepMerge(object target, object source, MergeMode mode)
+    {
+        var type = target.GetType();
+
+        foreach (var prop in type.GetProperties(BindingFlags.Public | BindingFlags.Instance))
+        {
+            if (!prop.CanRead || !prop.CanWrite)
+                continue;
+
+            var sourceValue = prop.GetValue(source);
+            if (sourceValue == null)
+                continue;
+
+            var targetValue = prop.GetValue(target);
+            var propType = prop.PropertyType;
+
+            // Simple types → overwrite
+            if (IsSimple(propType))
+            {
+                prop.SetValue(target, sourceValue);
+                continue;
+            }
+
+            // Dictionary
+            if (IsDictionary(propType))
+            {
+                if (mode == MergeMode.Merge && IsDictionaryEmpty(sourceValue))
+                    continue;
+
+                MergeDictionaries(targetValue, sourceValue);
+                continue;
+            }
+
+            // List / collection
+            if (IsEnumerable(propType))
+            {
+                if (mode == MergeMode.Merge && IsEnumerableEmpty(sourceValue))
+                    continue;
+
+                prop.SetValue(target, sourceValue);
+                continue;
+            }
+
+            // Complex object → recursive merge
+            if (targetValue == null)
+            {
+                prop.SetValue(target, sourceValue);
+            }
+            else
+            {
+                DeepMerge(targetValue, sourceValue, mode);
+            }
+        }
+    }
+
+    private static bool IsSimple(Type type)
+    {
+        return type.IsPrimitive
+               || type == typeof(string)
+               || type == typeof(decimal)
+               || type == typeof(DateTime)
+               || type == typeof(Guid)
+               || Nullable.GetUnderlyingType(type)?.IsPrimitive == true;
+    }
+
+    private static bool IsDictionary(Type type)
+    {
+        return type.IsGenericType &&
+               type.GetGenericTypeDefinition() == typeof(Dictionary<,>);
+    }
+
+    private static bool IsEnumerable(Type type)
+    {
+        return typeof(System.Collections.IEnumerable).IsAssignableFrom(type)
+               && type != typeof(string)
+               && !IsDictionary(type);
+    }
+    private static bool IsEnumerableEmpty(object value)
+    {
+        var enumerable = (System.Collections.IEnumerable)value;
+        return !enumerable.GetEnumerator().MoveNext();
+    }
+
+    private static bool IsDictionaryEmpty(object value)
+    {
+        var dict = (System.Collections.IDictionary)value;
+        return dict.Count == 0;
+    }
+
+    private static void MergeDictionaries(object? target, object source)
+    {
+        if (target == null) return;
+
+        var targetDict = (System.Collections.IDictionary)target;
+        var sourceDict = (System.Collections.IDictionary)source;
+
+        foreach (var key in sourceDict.Keys)
+        {
+            targetDict[key] = sourceDict[key];
+        }
+    }
+}
+
+internal static class ResourcePolymorphismResolver
+{
+    public static IJsonTypeInfoResolver Create()
+    {
+        var resolver = new DefaultJsonTypeInfoResolver();
+
+        resolver.Modifiers.Add(typeInfo =>
+        {
+            if (typeInfo.Type == typeof(Resource))
+            {
+                typeInfo.PolymorphismOptions = new JsonPolymorphismOptions
+                {
+                    TypeDiscriminatorPropertyName = "kind",
+                    IgnoreUnrecognizedTypeDiscriminators = false,
+                    UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization
+                };
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Server), Server.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Switch), Switch.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Firewall), Firewall.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Router), Router.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Desktop), Desktop.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Laptop), Laptop.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(AccessPoint), AccessPoint.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Ups), Ups.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(SystemResource), SystemResource.KindLabel));
+
+                typeInfo.PolymorphismOptions.DerivedTypes.Add(
+                    new JsonDerivedType(typeof(Service), Service.KindLabel));
+            }
+        });
+
+        return resolver;
+    }
+}

+ 56 - 0
RackPeek.Domain/Persistence/Yaml/ResourceYamlMigrationService.cs

@@ -0,0 +1,56 @@
+namespace RackPeek.Domain.Persistence.Yaml;
+
+using RackPeek.Domain.Resources;
+using YamlDotNet.Core;
+
+public interface IResourceYamlMigrationService
+{
+    Task<YamlRoot> DeserializeAsync(
+        string yaml,
+        Func<string, Task>? preMigrationAction = null,
+        Func<YamlRoot, Task>? postMigrationAction = null);
+}
+
+public sealed class ResourceYamlMigrationService( 
+    RackPeekConfigMigrationDeserializer deserializer)
+    : IResourceYamlMigrationService
+{
+    private static readonly int CurrentSchemaVersion =
+        RackPeekConfigMigrationDeserializer.ListOfMigrations.Count;
+
+    public async Task<YamlRoot> DeserializeAsync(
+        string yaml,
+        Func<string, Task>? preMigrationAction = null,
+        Func<YamlRoot, Task>? postMigrationAction = null)
+    {
+        if (string.IsNullOrWhiteSpace(yaml))
+            return new YamlRoot();
+
+        var version = deserializer.GetSchemaVersion(yaml);
+
+        if (version > CurrentSchemaVersion)
+        {
+            throw new InvalidOperationException(
+                $"Config schema version {version} is newer than this application supports ({CurrentSchemaVersion}).");
+        }
+
+        YamlRoot? root;
+
+        if (version < CurrentSchemaVersion)
+        {
+            if (preMigrationAction != null)
+                await preMigrationAction(yaml);
+
+            root = await deserializer.Deserialize(yaml) ?? new YamlRoot();
+
+            if (postMigrationAction != null)
+                await postMigrationAction(root);
+        }
+        else
+        {
+            root = await deserializer.Deserialize(yaml);
+        }
+
+        return root ?? new YamlRoot();
+    }
+}

+ 43 - 35
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -17,6 +17,7 @@ using YamlDotNet.Serialization.NamingConventions;
 
 namespace RackPeek.Domain.Persistence.Yaml;
 
+
 public class ResourceCollection
 {
     public readonly SemaphoreSlim FileLock = new(1, 1);
@@ -27,7 +28,7 @@ public sealed class YamlResourceCollection(
     string filePath,
     ITextFileStore fileStore,
     ResourceCollection resourceCollection,
-    RackPeekConfigMigrationDeserializer _deserializer)
+    IResourceYamlMigrationService migrationService)
     : IResourceCollection
 {
     // Bump this when your YAML schema changes, and add a migration step below.
@@ -184,6 +185,39 @@ public sealed class YamlResourceCollection(
         return Task.FromResult<IReadOnlyList<Resource>>(result);
     }
 
+    public async Task Merge(string incomingYaml, MergeMode mode)
+    {
+        if (string.IsNullOrWhiteSpace(incomingYaml))
+            return;
+
+        await resourceCollection.FileLock.WaitAsync();
+        try
+        {
+            var incomingRoot = await migrationService.DeserializeAsync(incomingYaml);
+
+            var incomingResources = incomingRoot.Resources ?? new List<Resource>();
+            var 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
+            };
+
+            await SaveRootAsync(rootToSave);
+        }
+        finally
+        {
+            resourceCollection.FileLock.Release();
+        }
+    }
+
     public Task<IReadOnlyList<Resource>> GetByTagAsync(string name)
     {
         return Task.FromResult<IReadOnlyList<Resource>>(
@@ -223,46 +257,20 @@ public sealed class YamlResourceCollection(
 
     public async Task LoadAsync()
     {
-        // Read raw YAML so we can back it up exactly before any migration writes.
         var yaml = await fileStore.ReadAllTextAsync(filePath);
-        if (string.IsNullOrWhiteSpace(yaml))
-        {
-            resourceCollection.Resources.Clear();
-            return;
-        }
 
-        var version = _deserializer.GetSchemaVersion(yaml); 
-        
-        // Guard: config is newer than this app understands.
-        if (version > CurrentSchemaVersion)
-        {
-            throw new InvalidOperationException(
-                $"Config schema version {version} is newer than this application supports ({CurrentSchemaVersion}).");
-        }
-
-        YamlRoot? root;
-        // If older, backup first, then migrate step-by-step, then save.
-        if (version < CurrentSchemaVersion)
-        {
-            await BackupOriginalAsync(yaml);
+        var root = await migrationService.DeserializeAsync(
+            yaml,
+            async originalYaml => await BackupOriginalAsync(originalYaml),
+            async migratedRoot => await SaveRootAsync(migratedRoot)
+        );
 
-            root = await _deserializer.Deserialize(yaml) ?? new YamlRoot();
-            
-            // Ensure we persist the migrated root (with updated version)
-            await SaveRootAsync(root);
-        }
-        else
-        {
-            root = await _deserializer.Deserialize(yaml);
-        }
-        
         resourceCollection.Resources.Clear();
-        if (root?.Resources != null)
-        {
+
+        if (root.Resources != null)
             resourceCollection.Resources.AddRange(root.Resources);
-        }
     }
-
+    
     public Task AddAsync(Resource resource)
     {
         return UpdateWithLockAsync(list =>

+ 3 - 2
RackPeek.Web.Viewer/Program.cs

@@ -39,13 +39,14 @@ public class Program
         var yamlDir = builder.Configuration.GetValue<string>("RPK_YAML_DIR") ?? "config";
         var yamlFilePath = $"{yamlDir}/config.yaml";
         builder.Services.AddScoped<RackPeekConfigMigrationDeserializer>();
-
+        builder.Services.AddScoped<IResourceYamlMigrationService, ResourceYamlMigrationService>();
+        
         builder.Services.AddScoped<IResourceCollection>(sp =>
             new YamlResourceCollection(
                 yamlFilePath,
                 sp.GetRequiredService<ITextFileStore>(),
                 sp.GetRequiredService<ResourceCollection>(),
-                sp.GetRequiredService<RackPeekConfigMigrationDeserializer>()));
+                sp.GetRequiredService<IResourceYamlMigrationService>()));
 
         builder.Services.AddYamlRepos();
         builder.Services.AddCommands();

+ 2 - 1
RackPeek.Web/Program.cs

@@ -62,13 +62,14 @@ public class Program
         var resources = new ResourceCollection();
         builder.Services.AddSingleton(resources);
         builder.Services.AddScoped<RackPeekConfigMigrationDeserializer>();
+        builder.Services.AddScoped<IResourceYamlMigrationService, ResourceYamlMigrationService>();
 
         builder.Services.AddScoped<IResourceCollection>(sp =>
             new YamlResourceCollection(
                 yamlFilePath,
                 sp.GetRequiredService<ITextFileStore>(),
                 sp.GetRequiredService<ResourceCollection>(),
-                sp.GetRequiredService<RackPeekConfigMigrationDeserializer>()));
+                sp.GetRequiredService<IResourceYamlMigrationService>()));
 
         // Infrastructure
         builder.Services.AddYamlRepos();

+ 5 - 2
Shared.Rcl/CliBootstrap.cs

@@ -85,13 +85,16 @@ public static class CliBootstrap
 
         services.AddLogging();
         services.AddScoped<RackPeekConfigMigrationDeserializer>();
+        services.AddScoped<IResourceYamlMigrationService, ResourceYamlMigrationService>();
 
+        var b = services.BuildServiceProvider();
+        
         var collection = new YamlResourceCollection(
             fullYamlPath,
             new PhysicalTextFileStore(),
             new ResourceCollection(),
-            // TODO: Is this right?
-            services.BuildServiceProvider().GetRequiredService<RackPeekConfigMigrationDeserializer>());
+                    b.GetRequiredService<IResourceYamlMigrationService>());
+            
 
         await collection.LoadAsync();
         services.AddSingleton<IResourceCollection>(collection);

+ 305 - 0
Shared.Rcl/YamlImportPage.razor

@@ -0,0 +1,305 @@
+@page "/yaml/import"
+<PageTitle>Yaml Import</PageTitle>
+
+@using RackPeek.Domain.Persistence
+@using RackPeek.Domain.Persistence.Yaml
+@using RackPeek.Domain.Resources
+@using YamlDotNet.Serialization
+@using YamlDotNet.Serialization.NamingConventions
+
+@inject IResourceCollection Resources
+@inject IResourceYamlMigrationService MigrationService
+
+<div class="border border-zinc-800 rounded p-4 bg-zinc-900 mt-6">
+
+    <div class="text-zinc-100 mb-3">
+        Import YAML
+    </div>
+
+    <!-- Mode selector -->
+    <div class="mb-4 flex gap-6 text-xs">
+
+        <label class="flex items-start gap-2 cursor-pointer">
+            <input type="radio"
+                   name="mergeMode"
+                   checked="@(_mode == MergeMode.Merge)"
+                   @onchange="() => OnModeChanged(MergeMode.Merge)" />
+
+            <div>
+                <div class="text-emerald-400">Merge</div>
+                <div class="text-zinc-500">
+                    Update matching resources. Properties not specified remain unchanged.
+                </div>
+            </div>
+        </label>
+
+        <label class="flex items-start gap-2 cursor-pointer">
+            <input type="radio"
+                   name="mergeMode"
+                   checked="@(_mode == MergeMode.Replace)"
+                   @onchange="() => OnModeChanged(MergeMode.Replace)" />
+
+            <div>
+                <div class="text-red-400">Replace</div>
+                <div class="text-zinc-500">
+                    Completely replace matching resources.
+                </div>
+            </div>
+        </label>
+
+    </div>
+
+    <!-- YAML Input -->
+    <textarea class="w-full input font-mono text-xs mb-3"
+              style="min-height: 18rem"
+              placeholder="Paste YAML here..."
+              @bind="_inputYaml"
+              @bind:event="oninput"
+              @bind:after="ComputeDiff">
+    </textarea>
+
+    @if (!string.IsNullOrEmpty(_validationError))
+    {
+        <div class="text-red-400 text-xs mb-3">
+            @_validationError
+        </div>
+    }
+
+    <!-- Apply -->
+    <div class="flex gap-3 text-xs mb-4">
+        <button class="text-amber-400 hover:text-amber-300 disabled:opacity-40"
+                disabled="@(!_isValid)"
+                @onclick="Apply">
+            Apply
+        </button>
+    </div>
+
+    <!-- Summary + Diff -->
+    @if (_isValid)
+    {
+        <div class="mt-4 text-xs">
+
+            <div class="mb-2 text-zinc-400">
+                Import Summary
+            </div>
+
+            @if (!_added.Any() && !_updated.Any() && !_replaced.Any())
+            {
+                <div class="text-zinc-500 italic mt-2">
+                    No changes detected.
+                </div>
+            }
+
+            @if (_added.Any())
+            {
+                <div class="text-emerald-400 mb-2">
+                    + @_added.Count added
+                </div>
+
+                @foreach (var name in _added)
+                {
+                    <div class="ml-4 text-zinc-300 mb-4">
+                        <div class="font-bold">+ @name</div>
+                        <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
+@_newYaml[name]
+                        </pre>
+                    </div>
+                }
+            }
+
+            @if (_updated.Any())
+            {
+                <div class="text-amber-400 mt-4 mb-2">
+                    ~ @_updated.Count updated
+                </div>
+
+                @foreach (var name in _updated)
+                {
+                    <div class="ml-4 mb-6">
+                        <div class="font-bold text-zinc-300 mb-2">~ @name</div>
+
+                        <div class="grid grid-cols-2 gap-4">
+                            <div>
+                                <div class="text-zinc-500 mb-1">Current</div>
+                                <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
+@_oldYaml[name]
+                                </pre>
+                            </div>
+
+                            <div>
+                                <div class="text-emerald-500 mb-1">Incoming (Merged)</div>
+                                <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
+@_newYaml[name]
+                                </pre>
+                            </div>
+                        </div>
+                    </div>
+                }
+            }
+
+            @if (_replaced.Any())
+            {
+                <div class="text-red-400 mt-4 mb-2">
+                    ! @_replaced.Count replaced
+                </div>
+
+                @foreach (var name in _replaced)
+                {
+                    <div class="ml-4 mb-6">
+                        <div class="font-bold text-zinc-300 mb-2">! @name</div>
+
+                        <div class="grid grid-cols-2 gap-4">
+                            <div>
+                                <div class="text-zinc-500 mb-1">Current</div>
+                                <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
+@_oldYaml[name]
+                                </pre>
+                            </div>
+
+                            <div>
+                                <div class="text-red-400 mb-1">Incoming (Replacement)</div>
+                                <pre class="bg-zinc-800 p-2 rounded text-xs overflow-x-auto">
+@_newYaml[name]
+                                </pre>
+                            </div>
+                        </div>
+                    </div>
+                }
+            }
+
+        </div>
+    }
+
+</div>
+
+@code {
+
+    private List<string> _added = new();
+    private List<string> _updated = new();
+    private List<string> _replaced = new();
+
+    private Dictionary<string, string> _oldYaml = new(StringComparer.OrdinalIgnoreCase);
+    private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
+
+    private string _inputYaml = "";
+    private string? _validationError;
+    private bool _isValid = false;
+
+    private MergeMode _mode = MergeMode.Merge;
+
+    async Task OnModeChanged(MergeMode mode)
+    {
+        _mode = mode;
+        await ComputeDiff();
+    }
+
+    async Task ComputeDiff()
+    {
+        _validationError = null;
+        _isValid = false;
+
+        _added.Clear();
+        _updated.Clear();
+        _replaced.Clear();
+        _oldYaml.Clear();
+        _newYaml.Clear();
+
+        if (string.IsNullOrWhiteSpace(_inputYaml))
+            return;
+
+        try
+        {
+            // Force deserialization to throw if invalid
+            var incomingRoot = await MigrationService.DeserializeAsync(_inputYaml);
+
+            if (incomingRoot?.Resources == null)
+                throw new Exception("YAML structure invalid or missing 'resources' section.");
+
+            var incomingResources = incomingRoot.Resources;
+            var currentResources = await Resources.GetAllOfTypeAsync<Resource>();
+
+            var currentDict = currentResources
+                .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
+
+            var serializer = new SerializerBuilder()
+                .WithNamingConvention(CamelCaseNamingConvention.Instance)
+                .ConfigureDefaultValuesHandling(
+                    DefaultValuesHandling.OmitNull |
+                    DefaultValuesHandling.OmitEmptyCollections)
+                .Build();
+
+            // SNAPSHOT current BEFORE merge
+            var oldSnapshots = currentResources
+                .ToDictionary(
+                    r => r.Name,
+                    r => serializer.Serialize(r),
+                    StringComparer.OrdinalIgnoreCase);
+
+            // Perform merge
+            var mergedResources = ResourceCollectionMerger.Merge(
+                currentResources,
+                incomingResources,
+                _mode);
+
+            var mergedDict = mergedResources
+                .ToDictionary(r => r.Name, StringComparer.OrdinalIgnoreCase);
+
+            foreach (var incoming in incomingResources)
+            {
+                if (!mergedDict.TryGetValue(incoming.Name, out var merged))
+                    continue;
+
+                var newYaml = serializer.Serialize(merged);
+                _newYaml[incoming.Name] = newYaml;
+
+                if (!currentDict.ContainsKey(incoming.Name))
+                {
+                    _added.Add(incoming.Name);
+                    continue;
+                }
+
+                var oldYaml = oldSnapshots[incoming.Name];
+                _oldYaml[incoming.Name] = oldYaml;
+
+                if (_mode == MergeMode.Replace)
+                {
+                    _replaced.Add(incoming.Name);
+                }
+                else if (oldYaml != newYaml)
+                {
+                    _updated.Add(incoming.Name);
+                }
+            }
+
+            _isValid = true;
+        }
+        catch (Exception ex)
+        {
+            _validationError = $"YAML invalid: {ex.Message}";
+        }
+    }
+
+    async Task Apply()
+    {
+        if (!_isValid)
+            return;
+
+        try
+        {
+            await Resources.Merge(_inputYaml, _mode);
+
+            _inputYaml = "";
+            _isValid = false;
+
+            _added.Clear();
+            _updated.Clear();
+            _replaced.Clear();
+            _oldYaml.Clear();
+            _newYaml.Clear();
+        }
+        catch (Exception ex)
+        {
+            _validationError = $"Apply failed: {ex.Message}";
+        }
+    }
+}