feat(adminui): UNS-tree delete for Cluster + Enterprise (refuse-if-children, no rowversion)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
/// <summary>Raised when the user edits a node (Area/Line).</summary>
|
||||
[Parameter] public EventCallback<UnsNode> OnEdit { get; set; }
|
||||
|
||||
/// <summary>Raised when the user deletes a node (Area/Line/Equipment/Tag/VirtualTag).</summary>
|
||||
/// <summary>Raised when the user deletes a node (Enterprise/Cluster/Area/Line/Equipment/Tag/VirtualTag).</summary>
|
||||
[Parameter] public EventCallback<UnsNode> OnDelete { get; set; }
|
||||
|
||||
/// <summary>Raised when the user toggles a node's expansion; the host owns the resulting state + lazy load.</summary>
|
||||
@@ -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).
|
||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
|
||||
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Cluster:
|
||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2"
|
||||
@onclick="@(() => OnAddChild.InvokeAsync(node))">+ Area</button>
|
||||
<a class="btn btn-sm btn-link p-0 ms-2" href="@($"/clusters/{node.ClusterId}")">⚙ settings</a>
|
||||
<button type="button" class="btn btn-sm btn-link p-0 ms-2 text-danger"
|
||||
@onclick="@(() => OnDelete.InvokeAsync(node))">Delete</button>
|
||||
break;
|
||||
|
||||
case UnsNodeKind.Area:
|
||||
|
||||
@@ -332,6 +332,32 @@ public interface IUnsTreeService
|
||||
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
|
||||
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// 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 <see cref="Configuration.Entities.ServerCluster"/> entity carries no concurrency token,
|
||||
/// so — unlike the Area/Line/Equipment deletes — this signature takes no <c>rowVersion</c>.
|
||||
/// </summary>
|
||||
/// <param name="clusterId">The cluster to delete.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or a refuse-if-children failure naming what still references the cluster.</returns>
|
||||
Task<UnsMutationResult> DeleteClusterAsync(string clusterId, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Deletes every cluster carrying the given <c>Enterprise</c> 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 <em>any</em> 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.
|
||||
/// </summary>
|
||||
/// <param name="enterpriseName">The <c>Enterprise</c> label whose clusters to delete.</param>
|
||||
/// <param name="ct">A token to cancel the operation.</param>
|
||||
/// <returns>Success, or a refuse-if-children failure naming the blocking cluster.</returns>
|
||||
Task<UnsMutationResult> DeleteEnterpriseAsync(string enterpriseName, CancellationToken ct = default);
|
||||
|
||||
/// <summary>
|
||||
/// Loads the drivers eligible to back a tag on the given equipment: drivers in the equipment's
|
||||
/// cluster (<c>Equipment.UnsLine → UnsArea.ClusterId</c>) whose namespace is Equipment-kind
|
||||
|
||||
@@ -710,6 +710,121 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<UnsMutationResult> 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.");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a short description of the first child kind that blocks deleting the cluster (one of
|
||||
/// nodes / namespaces / UNS areas / driver instances — the FK-<c>Restrict</c> relationships), or
|
||||
/// <c>null</c> when the cluster is child-free. Cascade (<c>LdapGroupRoleMapping</c>) and nullable
|
||||
/// (<c>NodeAcl</c>) references are intentionally not treated as blockers.
|
||||
/// </summary>
|
||||
private static async Task<string?> 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;
|
||||
}
|
||||
|
||||
/// <summary>Builds the refuse-if-children guidance message for a single blocked cluster.</summary>
|
||||
private static string ClusterBlockedMessage(string clusterId, string blocker) =>
|
||||
$"Delete failed: {blocker} still reference cluster '{clusterId}' — remove them first.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<IReadOnlyList<(string DriverInstanceId, string Display, string DriverType, string DriverConfig)>> LoadTagDriversForEquipmentAsync(
|
||||
string equipmentId,
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="UnsTreeService.DeleteClusterAsync"/> and
|
||||
/// <see cref="UnsTreeService.DeleteEnterpriseAsync"/>: refuse-if-children deletes that
|
||||
/// mirror the Area/Line/Equipment convention but carry no <c>RowVersion</c> concurrency
|
||||
/// token (the <see cref="ServerCluster"/> entity has none — Option B, no migration).
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The EF InMemory provider does not enforce FK <c>Restrict</c>, 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.
|
||||
/// </remarks>
|
||||
[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 -----
|
||||
|
||||
/// <summary>Deleting a child-free cluster removes the row.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Deleting a cluster that is already gone is a no-op success (idempotent).</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>A cluster with a UNS area refuses deletion with a friendly message and stays put.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>A cluster with a driver instance refuses deletion and stays put.</summary>
|
||||
[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 -----
|
||||
|
||||
/// <summary>Deleting an enterprise whose every cluster is child-free removes them all.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>Deleting an enterprise with no matching clusters is a no-op success.</summary>
|
||||
[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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// When one cluster under the enterprise has children, the whole delete refuses (naming the
|
||||
/// blocking cluster) and nothing is deleted — no partial enterprise removal.
|
||||
/// </summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user