Kaynağa Gözat

Merge connections from YAML imports instead of dropping them (#308)

YamlResourceCollection.Merge merged incoming resources but always
saved the existing connection list, so connections in an imported
config were silently dropped and never shown in the import preview.

- ConnectionMerger applies an imported connections section: Replace
  swaps the set; Merge applies the UI's overwrite rule (a port holds
  at most one connection, so each incoming connection evicts anything
  touching its endpoints). An import with no connections section
  leaves existing connections untouched.
- The inventory API response and the YAML import preview now report
  connection additions/removals.
- API regression tests cover the reported scenario (import with
  connections, adjust a port, re-import idempotency) and the
  section-absent case — verified failing before the fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tim Jones 1 ay önce
ebeveyn
işleme
7e3b71924f

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

@@ -10,4 +10,7 @@ public class ImportYamlResponse {
 
     public Dictionary<string, string> NewYaml { get; set; }
         = 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);
         }
 
+        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);
 
         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.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 {
                 Version = RackPeekConfigMigrationDeserializer.ListOfMigrations.Count,
                 Resources = resourceCollection.Resources,

+ 31 - 1
Shared.Rcl/YamlImportPage.razor

@@ -110,7 +110,8 @@
                 Import Summary
             </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">
                     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>
     }
 
@@ -204,6 +226,8 @@
     private List<string> _added = new();
     private List<string> _updated = 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> _newYaml = new(StringComparer.OrdinalIgnoreCase);
@@ -228,6 +252,8 @@
         _added.Clear();
         _updated.Clear();
         _replaced.Clear();
+        _connectionsAdded.Clear();
+        _connectionsRemoved.Clear();
         _oldYaml.Clear();
         _newYaml.Clear();
 
@@ -246,6 +272,8 @@
             _added = result.Added;
             _updated = result.Updated;
             _replaced = result.Replaced;
+            _connectionsAdded = result.ConnectionsAdded;
+            _connectionsRemoved = result.ConnectionsRemoved;
 
             _oldYaml = result.OldYaml;
             _newYaml = result.NewYaml;
@@ -278,6 +306,8 @@
             _added.Clear();
             _updated.Clear();
             _replaced.Clear();
+            _connectionsAdded.Clear();
+            _connectionsRemoved.Clear();
         }
         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);
     }
 
+    [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]
     public async Task Merge_Other_Hardware_Persists() {
         HttpClient client = CreateClient(true);