feat(adminui): UNS-tree delete for Cluster + Enterprise (refuse-if-children, no rowversion)
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user