feat(adminui): UNS-tree delete for Cluster + Enterprise (refuse-if-children, no rowversion)

This commit is contained in:
Joseph Doherty
2026-06-16 16:35:07 -04:00
parent 6a8020e7e7
commit 526eebb3bb
5 changed files with 349 additions and 3 deletions
@@ -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,