diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor index d5c23d24..b3ae1207 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Pages/Uns/GlobalUns.razor @@ -303,8 +303,19 @@ result = await Svc.DeleteEquipmentAsync(node.EntityId!, equipment.RowVersion); break; + case UnsNodeKind.Cluster: + // Cluster carries no RowVersion (Option B); EntityId mirrors the ClusterId. + result = await Svc.DeleteClusterAsync(node.EntityId!); + break; + + case UnsNodeKind.Enterprise: + // Enterprise is a tree label, not an entity — its name is the DisplayName. + // Deletes every cluster carrying that label (refuse-if-children, all-or-nothing). + result = await Svc.DeleteEnterpriseAsync(node.DisplayName); + break; + default: - // Enterprise/Cluster have no delete button, so this branch is unreachable in practice. + // Tag/VirtualTag are not deletable from the global tree. result = new UnsMutationResult(false, "Delete for this node kind is not yet available."); break; } diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor index c65b30cd..b0e1247d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Uns/UnsTree.razor @@ -19,7 +19,7 @@ /// Raised when the user edits a node (Area/Line). [Parameter] public EventCallback OnEdit { get; set; } - /// Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag). + /// Raised when the user deletes a node (Enterprise/Cluster/Area/Line/Equipment/Tag/VirtualTag). [Parameter] public EventCallback OnDelete { get; set; } /// Raised when the user toggles a node's expansion; the host owns the resulting state + lazy load. @@ -86,13 +86,17 @@ switch (node.Kind) { case UnsNodeKind.Enterprise: - // No actions on the enterprise root. + // Deletes every cluster carrying this enterprise label (refuse-if-children). + break; case UnsNodeKind.Cluster: ⚙ settings + break; case UnsNodeKind.Area: diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs index 74bda42f..f043645d 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/IUnsTreeService.cs @@ -332,6 +332,32 @@ public interface IUnsTreeService /// Success, a concurrency failure, or a delete-failed failure. Task DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default); + /// + /// Deletes a server cluster. A missing row is treated as success (already gone). The delete is + /// refuse-if-children: a cluster that still owns nodes, namespaces, UNS areas, or driver + /// instances is left intact and a guidance message is returned (the same convention as + /// Area/Line/Equipment, and matching the existing cluster-settings "Delete cluster" behaviour). + /// The entity carries no concurrency token, + /// so — unlike the Area/Line/Equipment deletes — this signature takes no rowVersion. + /// + /// The cluster to delete. + /// A token to cancel the operation. + /// Success, or a refuse-if-children failure naming what still references the cluster. + Task DeleteClusterAsync(string clusterId, CancellationToken ct = default); + + /// + /// Deletes every cluster carrying the given Enterprise label. The enterprise is a tree + /// label (a string value on cluster rows), not a DB entity, so deleting it means deleting all of + /// its clusters. The operation is all-or-nothing: it first checks every matching cluster for + /// children; if any cluster still owns nodes, namespaces, UNS areas, or driver instances + /// the whole delete refuses (naming the first blocking cluster) and nothing is removed — there is + /// never a partial enterprise deletion. An enterprise with no matching clusters is a no-op success. + /// + /// The Enterprise label whose clusters to delete. + /// A token to cancel the operation. + /// Success, or a refuse-if-children failure naming the blocking cluster. + Task DeleteEnterpriseAsync(string enterpriseName, CancellationToken ct = default); + /// /// Loads the drivers eligible to back a tag on the given equipment: drivers in the equipment's /// cluster (Equipment.UnsLine → UnsArea.ClusterId) whose namespace is Equipment-kind diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs index a92ae909..de8be809 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/UnsTreeService.cs @@ -710,6 +710,121 @@ public sealed class UnsTreeService(IDbContextFactory dbF } } + /// + public async Task DeleteClusterAsync(string clusterId, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var entity = await db.ServerClusters.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct); + if (entity is null) + { + // Already gone — idempotent success, matching the Area/Line/Equipment delete path. + return new UnsMutationResult(true, null); + } + + // Refuse-if-children. The DB FKs are Restrict, but we pre-check explicitly so the refusal is + // deterministic (the EF InMemory provider does not enforce Restrict) and so we can name the + // blocking child. LdapGroupRoleMapping.ClusterId is Cascade (auto-cleaned) and NodeAcl.ClusterId + // is nullable system-wide-or-cluster grants, so neither blocks a delete. + var blocker = await DescribeClusterChildrenAsync(db, clusterId, ct); + if (blocker is not null) + { + return new UnsMutationResult(false, ClusterBlockedMessage(clusterId, blocker)); + } + + db.ServerClusters.Remove(entity); + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (Exception ex) + { + // Defensive net for a real SQL Server where a child slipped past the pre-check (race). + return new UnsMutationResult(false, $"Delete failed: {ex.Message}. Likely because nodes, namespaces, areas, or drivers still reference cluster '{clusterId}' — remove them first."); + } + } + + /// + public async Task DeleteEnterpriseAsync(string enterpriseName, CancellationToken ct = default) + { + await using var db = await dbFactory.CreateDbContextAsync(ct); + + var clusters = await db.ServerClusters + .Where(c => c.Enterprise == enterpriseName) + .ToListAsync(ct); + + if (clusters.Count == 0) + { + // No clusters carry this label — nothing to delete (idempotent success). + return new UnsMutationResult(true, null); + } + + // All-or-nothing: pre-check EVERY cluster before removing any, so a mid-way FK failure can + // never leave half the enterprise deleted. Refuse on the first cluster that still has children. + foreach (var cluster in clusters) + { + var blocker = await DescribeClusterChildrenAsync(db, cluster.ClusterId, ct); + if (blocker is not null) + { + return new UnsMutationResult( + false, + $"Cannot delete enterprise '{enterpriseName}': {ClusterBlockedMessage(cluster.ClusterId, blocker)} Nothing was deleted."); + } + } + + db.ServerClusters.RemoveRange(clusters); + + try + { + await db.SaveChangesAsync(ct); + return new UnsMutationResult(true, null); + } + catch (Exception ex) + { + return new UnsMutationResult(false, $"Delete failed: {ex.Message}. A cluster under enterprise '{enterpriseName}' is still referenced — remove its children first. Nothing was deleted."); + } + } + + /// + /// Returns a short description of the first child kind that blocks deleting the cluster (one of + /// nodes / namespaces / UNS areas / driver instances — the FK-Restrict relationships), or + /// null when the cluster is child-free. Cascade (LdapGroupRoleMapping) and nullable + /// (NodeAcl) references are intentionally not treated as blockers. + /// + private static async Task DescribeClusterChildrenAsync( + OtOpcUaConfigDbContext db, + string clusterId, + CancellationToken ct) + { + if (await db.ClusterNodes.AnyAsync(n => n.ClusterId == clusterId, ct)) + { + return "cluster nodes"; + } + + if (await db.Namespaces.AnyAsync(ns => ns.ClusterId == clusterId, ct)) + { + return "namespaces"; + } + + if (await db.UnsAreas.AnyAsync(a => a.ClusterId == clusterId, ct)) + { + return "UNS areas"; + } + + if (await db.DriverInstances.AnyAsync(d => d.ClusterId == clusterId, ct)) + { + return "driver instances"; + } + + return null; + } + + /// Builds the refuse-if-children guidance message for a single blocked cluster. + private static string ClusterBlockedMessage(string clusterId, string blocker) => + $"Delete failed: {blocker} still reference cluster '{clusterId}' — remove them first."; + /// public async Task> LoadTagDriversForEquipmentAsync( string equipmentId, diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs new file mode 100644 index 00000000..304d4af4 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/UnsTreeServiceDeleteClusterTests.cs @@ -0,0 +1,190 @@ +using Microsoft.EntityFrameworkCore; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.AdminUI.Uns; +using ZB.MOM.WW.OtOpcUa.Configuration.Entities; +using ZB.MOM.WW.OtOpcUa.Configuration.Enums; + +namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns; + +/// +/// Verifies and +/// : refuse-if-children deletes that +/// mirror the Area/Line/Equipment convention but carry no RowVersion concurrency +/// token (the entity has none — Option B, no migration). +/// +/// +/// The EF InMemory provider does not enforce FK Restrict, so the refuse-if-children +/// behaviour is driven by an explicit child pre-check in the service (not the DB throwing), +/// which is what these tests exercise. +/// +[Trait("Category", "Unit")] +public sealed class UnsTreeServiceDeleteClusterTests +{ + private static (UnsTreeService Service, string DbName) Fresh() + { + var dbName = $"uns-delcluster-{Guid.NewGuid():N}"; + return (new UnsTreeService(UnsTreeTestDb.Factory(dbName)), dbName); + } + + private static ServerCluster NewCluster(string clusterId, string enterprise) => new() + { + ClusterId = clusterId, + Name = clusterId, + Enterprise = enterprise, + Site = "site", + RedundancyMode = RedundancyMode.None, + CreatedBy = "test", + }; + + // ----- DeleteCluster ----- + + /// Deleting a child-free cluster removes the row. + [Fact] + public async Task DeleteCluster_empty_removes_row() + { + var (service, dbName) = Fresh(); + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.ServerClusters.Add(NewCluster("CL-EMPTY", "zb")); + db.SaveChanges(); + } + + var result = await service.DeleteClusterAsync("CL-EMPTY"); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.ServerClusters.Any(c => c.ClusterId == "CL-EMPTY").ShouldBeFalse(); + } + + /// Deleting a cluster that is already gone is a no-op success (idempotent). + [Fact] + public async Task DeleteCluster_already_gone_returns_ok() + { + var (service, _) = Fresh(); + + var result = await service.DeleteClusterAsync("GHOST-CLUSTER"); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + + /// A cluster with a UNS area refuses deletion with a friendly message and stays put. + [Fact] + public async Task DeleteCluster_with_area_refuses_and_keeps_row() + { + var (service, dbName) = Fresh(); + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.ServerClusters.Add(NewCluster("CL-AREA", "zb")); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-CL", ClusterId = "CL-AREA", Name = "a" }); + db.SaveChanges(); + } + + var result = await service.DeleteClusterAsync("CL-AREA"); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("CL-AREA"); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.ServerClusters.Any(c => c.ClusterId == "CL-AREA").ShouldBeTrue(); + } + + /// A cluster with a driver instance refuses deletion and stays put. + [Fact] + public async Task DeleteCluster_with_driver_refuses_and_keeps_row() + { + var (service, dbName) = Fresh(); + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.ServerClusters.Add(NewCluster("CL-DRV", "zb")); + db.DriverInstances.Add(new DriverInstance + { + DriverInstanceId = "DRV-CL", + ClusterId = "CL-DRV", + NamespaceId = "NS-1", + Name = "drv", + DriverType = "ModbusTcp", + DriverConfig = "{}", + }); + db.SaveChanges(); + } + + var result = await service.DeleteClusterAsync("CL-DRV"); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.ServerClusters.Any(c => c.ClusterId == "CL-DRV").ShouldBeTrue(); + } + + // ----- DeleteEnterprise ----- + + /// Deleting an enterprise whose every cluster is child-free removes them all. + [Fact] + public async Task DeleteEnterprise_all_empty_removes_all_clusters() + { + var (service, dbName) = Fresh(); + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.ServerClusters.Add(NewCluster("ENT1-A", "ent-1")); + db.ServerClusters.Add(NewCluster("ENT1-B", "ent-1")); + // A cluster under a different enterprise must survive. + db.ServerClusters.Add(NewCluster("ENT2-A", "ent-2")); + db.SaveChanges(); + } + + var result = await service.DeleteEnterpriseAsync("ent-1"); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.ServerClusters.Any(c => c.Enterprise == "ent-1").ShouldBeFalse(); + verify.ServerClusters.Any(c => c.ClusterId == "ENT2-A").ShouldBeTrue(); + } + + /// Deleting an enterprise with no matching clusters is a no-op success. + [Fact] + public async Task DeleteEnterprise_no_matches_returns_ok() + { + var (service, _) = Fresh(); + + var result = await service.DeleteEnterpriseAsync("nonexistent-enterprise"); + + result.Ok.ShouldBeTrue(); + result.Error.ShouldBeNull(); + } + + /// + /// When one cluster under the enterprise has children, the whole delete refuses (naming the + /// blocking cluster) and nothing is deleted — no partial enterprise removal. + /// + [Fact] + public async Task DeleteEnterprise_one_cluster_with_children_refuses_and_deletes_nothing() + { + var (service, dbName) = Fresh(); + using (var db = UnsTreeTestDb.CreateNamed(dbName)) + { + db.ServerClusters.Add(NewCluster("ENT3-EMPTY", "ent-3")); + db.ServerClusters.Add(NewCluster("ENT3-FULL", "ent-3")); + db.UnsAreas.Add(new UnsArea { UnsAreaId = "AREA-ENT3", ClusterId = "ENT3-FULL", Name = "a" }); + db.SaveChanges(); + } + + var result = await service.DeleteEnterpriseAsync("ent-3"); + + result.Ok.ShouldBeFalse(); + result.Error.ShouldNotBeNull(); + result.Error.ShouldContain("ENT3-FULL"); + + // Nothing partial: BOTH clusters must still be present. + using var verify = UnsTreeTestDb.CreateNamed(dbName); + verify.ServerClusters.Any(c => c.ClusterId == "ENT3-EMPTY").ShouldBeTrue(); + verify.ServerClusters.Any(c => c.ClusterId == "ENT3-FULL").ShouldBeTrue(); + } +}