Răsfoiți Sursa

Merge pull request #323 from Timmoth/bugfix/308-import-connections

Merge connections from YAML imports instead of dropping them (#308)
Tim Jones 1 zi în urmă
părinte
comite
3e5c4092d8

+ 3 - 0
RackPeek.Domain/Api/InventoryResponse.cs

@@ -10,4 +10,7 @@ public class ImportYamlResponse {
 
 
     public Dictionary<string, string> NewYaml { get; set; }
     public Dictionary<string, string> NewYaml { get; set; }
         = new(StringComparer.OrdinalIgnoreCase);
         = new(StringComparer.OrdinalIgnoreCase);
+
+    public List<string> ConnectionsAdded { get; set; } = new();
+    public List<string> ConnectionsRemoved { get; set; } = new();
 }
 }

+ 18 - 0
RackPeek.Domain/Api/UpsertInventoryUseCase.cs

@@ -118,6 +118,24 @@ public class UpsertInventoryUseCase(
             else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
             else if (oldYaml != newYaml) response.Updated.Add(incoming.Name);
         }
         }
 
 
+        IReadOnlyList<Connection> currentConnections = await repo.GetConnectionsAsync();
+        List<Connection>? mergedConnections = ConnectionMerger.Merge(
+            currentConnections,
+            incomingRoot.Connections,
+            request.Mode);
+
+        if (mergedConnections != null) {
+            response.ConnectionsAdded = mergedConnections
+                .Select(ConnectionMerger.Describe)
+                .Except(currentConnections.Select(ConnectionMerger.Describe))
+                .ToList();
+
+            response.ConnectionsRemoved = currentConnections
+                .Select(ConnectionMerger.Describe)
+                .Except(mergedConnections.Select(ConnectionMerger.Describe))
+                .ToList();
+        }
+
         if (!request.DryRun) await repo.Merge(yamlInput, request.Mode);
         if (!request.DryRun) await repo.Merge(yamlInput, request.Mode);
 
 
         return response;
         return response;

+ 50 - 0
RackPeek.Domain/Persistence/ConnectionMerger.cs

@@ -0,0 +1,50 @@
+using RackPeek.Domain.Resources.Connections;
+
+namespace RackPeek.Domain.Persistence;
+
+public static class ConnectionMerger {
+    /// <summary>
+    ///     Merges an imported connections section into the existing set.
+    ///     Returns null when the import carries no connections section — the
+    ///     existing connections are kept untouched in that case (#308).
+    ///     Replace mode swaps the whole set; Merge mode applies the same
+    ///     overwrite rule as the UI (a port holds at most one connection, so
+    ///     each incoming connection evicts anything touching its endpoints).
+    /// </summary>
+    public static List<Connection>? Merge(
+        IReadOnlyList<Connection> existing,
+        List<Connection>? incoming,
+        MergeMode mode) {
+        if (incoming == null)
+            return null;
+
+        if (mode == MergeMode.Replace)
+            return incoming.ToList();
+
+        var merged = existing.ToList();
+
+        foreach (Connection connection in incoming) {
+            merged.RemoveAll(c =>
+                Touches(c, connection.A) || Touches(c, connection.B));
+
+            merged.Add(connection);
+        }
+
+        return merged;
+    }
+
+    public static string Describe(Connection c) =>
+        $"{Describe(c.A)} <-> {Describe(c.B)}";
+
+    private static string Describe(PortReference p) =>
+        $"{p.Resource}[{p.PortGroup}.{p.PortIndex}]";
+
+    private static bool Touches(Connection c, PortReference port) =>
+        PortsMatch(c.A, port) || PortsMatch(c.B, port);
+
+    private static bool PortsMatch(PortReference a, PortReference b) {
+        return a.Resource.Equals(b.Resource, StringComparison.OrdinalIgnoreCase)
+               && a.PortGroup == b.PortGroup
+               && a.PortIndex == b.PortIndex;
+    }
+}

+ 10 - 0
RackPeek.Domain/Persistence/Yaml/YamlResourceCollection.cs

@@ -136,6 +136,16 @@ public sealed class YamlResourceCollection(
             resourceCollection.Resources.Clear();
             resourceCollection.Resources.Clear();
             resourceCollection.Resources.AddRange(merged);
             resourceCollection.Resources.AddRange(merged);
 
 
+            List<Connection>? mergedConnections = ConnectionMerger.Merge(
+                resourceCollection.Connections,
+                incomingRoot.Connections,
+                mode);
+
+            if (mergedConnections != null) {
+                resourceCollection.Connections.Clear();
+                resourceCollection.Connections.AddRange(mergedConnections);
+            }
+
             var rootToSave = new YamlRoot {
             var rootToSave = new YamlRoot {
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
                 Resources = resourceCollection.Resources,
                 Resources = resourceCollection.Resources,

+ 31 - 1
Shared.Rcl/YamlImportPage.razor

@@ -110,7 +110,8 @@
                 Import Summary
                 Import Summary
             </div>
             </div>
 
 
-            @if (!_added.Any() && !_updated.Any() && !_replaced.Any())
+            @if (!_added.Any() && !_updated.Any() && !_replaced.Any()
+                 && !_connectionsAdded.Any() && !_connectionsRemoved.Any())
             {
             {
                 <div class="text-zinc-500 italic mt-2">
                 <div class="text-zinc-500 italic mt-2">
                     No changes detected.
                     No changes detected.
@@ -194,6 +195,27 @@
                 }
                 }
             }
             }
 
 
+            @if (_connectionsAdded.Any() || _connectionsRemoved.Any())
+            {
+                <div class="text-zinc-400 mt-4 mb-2" data-testid="import-connections-summary">
+                    Connections
+                </div>
+
+                @foreach (var connection in _connectionsAdded)
+                {
+                    <div class="ml-4 text-emerald-400" data-testid="import-connection-added">
+                        + @connection
+                    </div>
+                }
+
+                @foreach (var connection in _connectionsRemoved)
+                {
+                    <div class="ml-4 text-red-400" data-testid="import-connection-removed">
+                        - @connection
+                    </div>
+                }
+            }
+
         </div>
         </div>
     }
     }
 
 
@@ -204,6 +226,8 @@
     private List<string> _added = new();
     private List<string> _added = new();
     private List<string> _updated = new();
     private List<string> _updated = new();
     private List<string> _replaced = new();
     private List<string> _replaced = new();
+    private List<string> _connectionsAdded = new();
+    private List<string> _connectionsRemoved = new();
 
 
     private Dictionary<string, string> _oldYaml = new(StringComparer.OrdinalIgnoreCase);
     private Dictionary<string, string> _oldYaml = new(StringComparer.OrdinalIgnoreCase);
     private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
     private Dictionary<string, string> _newYaml = new(StringComparer.OrdinalIgnoreCase);
@@ -228,6 +252,8 @@
         _added.Clear();
         _added.Clear();
         _updated.Clear();
         _updated.Clear();
         _replaced.Clear();
         _replaced.Clear();
+        _connectionsAdded.Clear();
+        _connectionsRemoved.Clear();
         _oldYaml.Clear();
         _oldYaml.Clear();
         _newYaml.Clear();
         _newYaml.Clear();
 
 
@@ -246,6 +272,8 @@
             _added = result.Added;
             _added = result.Added;
             _updated = result.Updated;
             _updated = result.Updated;
             _replaced = result.Replaced;
             _replaced = result.Replaced;
+            _connectionsAdded = result.ConnectionsAdded;
+            _connectionsRemoved = result.ConnectionsRemoved;
 
 
             _oldYaml = result.OldYaml;
             _oldYaml = result.OldYaml;
             _newYaml = result.NewYaml;
             _newYaml = result.NewYaml;
@@ -278,6 +306,8 @@
             _added.Clear();
             _added.Clear();
             _updated.Clear();
             _updated.Clear();
             _replaced.Clear();
             _replaced.Clear();
+            _connectionsAdded.Clear();
+            _connectionsRemoved.Clear();
         }
         }
         catch (Exception ex)
         catch (Exception ex)
         {
         {

+ 126 - 0
Tests/Api/InventoryEndpointTests.cs

@@ -849,6 +849,132 @@ public class InventoryEndpointTests(ITestOutputHelper output) : ApiTestBase(outp
         Assert.DoesNotContain("env: production", newYaml);
         Assert.DoesNotContain("env: production", newYaml);
     }
     }
 
 
+    [Fact]
+    public async Task Import_Persists_And_Updates_Connections() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      version: 3
+                      resources:
+                      - kind: Switch
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 8
+                        name: switch1
+                      - kind: Server
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 1
+                        name: server1
+                      connections:
+                      - a:
+                          resource: server1
+                          portGroup: 0
+                          portIndex: 0
+                        b:
+                          resource: switch1
+                          portGroup: 0
+                          portIndex: 0
+                      """;
+
+        HttpResponseMessage response = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = initial, mode = "Merge" });
+
+        Assert.Equal(HttpStatusCode.OK, response.StatusCode);
+        ImportYamlResponse? result = await response.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Single(result!.ConnectionsAdded);
+        Assert.Empty(result.ConnectionsRemoved);
+
+        // Re-importing the same config is idempotent
+        HttpResponseMessage response2 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = initial, dryRun = true, mode = "Merge" });
+        ImportYamlResponse? result2 = await response2.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Empty(result2!.ConnectionsAdded);
+        Assert.Empty(result2.ConnectionsRemoved);
+
+        // Adjusting the switch port replaces the old connection (#308)
+        var adjusted = initial.Replace(
+            """
+                resource: switch1
+                portGroup: 0
+                portIndex: 0
+            """,
+            """
+                resource: switch1
+                portGroup: 0
+                portIndex: 3
+            """);
+
+        HttpResponseMessage response3 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = adjusted, mode = "Merge" });
+        ImportYamlResponse? result3 = await response3.Content.ReadFromJsonAsync<ImportYamlResponse>();
+
+        Assert.Single(result3!.ConnectionsAdded);
+        Assert.Contains("switch1[0.3]", result3.ConnectionsAdded[0]);
+        Assert.Single(result3.ConnectionsRemoved);
+        Assert.Contains("switch1[0.0]", result3.ConnectionsRemoved[0]);
+
+        // And the adjusted state is now stable
+        HttpResponseMessage response4 = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = adjusted, dryRun = true, mode = "Merge" });
+        ImportYamlResponse? result4 = await response4.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Empty(result4!.ConnectionsAdded);
+        Assert.Empty(result4.ConnectionsRemoved);
+    }
+
+    [Fact]
+    public async Task Import_Without_Connections_Section_Keeps_Existing_Connections() {
+        HttpClient client = CreateClient(true);
+
+        var initial = """
+                      version: 3
+                      resources:
+                      - kind: Switch
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 8
+                        name: sw-keep
+                      - kind: Server
+                        ports:
+                        - type: rj45
+                          speed: 1
+                          count: 1
+                        name: srv-keep
+                      connections:
+                      - a:
+                          resource: srv-keep
+                          portGroup: 0
+                          portIndex: 0
+                        b:
+                          resource: sw-keep
+                          portGroup: 0
+                          portIndex: 0
+                      """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = initial, mode = "Merge" });
+
+        // An import that says nothing about connections must not drop them
+        var resourcesOnly = """
+                            resources:
+                            - kind: Server
+                              name: srv-keep
+                              notes: updated
+                            """;
+
+        await client.PostAsJsonAsync("/api/inventory", new { yaml = resourcesOnly, mode = "Merge" });
+
+        // The original connection still exists: re-importing the initial
+        // config reports no connection changes.
+        HttpResponseMessage check = await client.PostAsJsonAsync("/api/inventory",
+            new { yaml = initial, dryRun = true, mode = "Merge" });
+        ImportYamlResponse? result = await check.Content.ReadFromJsonAsync<ImportYamlResponse>();
+        Assert.Empty(result!.ConnectionsAdded);
+        Assert.Empty(result.ConnectionsRemoved);
+    }
+
     [Fact]
     [Fact]
     public async Task Merge_Other_Hardware_Persists() {
     public async Task Merge_Other_Hardware_Persists() {
         HttpClient client = CreateClient(true);
         HttpClient client = CreateClient(true);