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();
+ }
+}