feat(uns): equipment-bound tag CRUD with namespace + cluster guards

This commit is contained in:
Joseph Doherty
2026-06-08 13:00:26 -04:00
parent ab0ff8aedf
commit 5a392c5db0
4 changed files with 679 additions and 0 deletions
@@ -136,4 +136,53 @@ public interface IUnsTreeService
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteEquipmentAsync(string equipmentId, byte[] rowVersion, 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
/// (decision #110 — tree tags are equipment-bound). Ordered by <c>DriverInstanceId</c>. Returns
/// an empty list when the equipment cannot be resolved to a cluster.
/// </summary>
/// <param name="equipmentId">The equipment whose candidate drivers to load.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The eligible drivers projected to <c>(DriverInstanceId, Display)</c> pairs.</returns>
Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(string equipmentId, CancellationToken ct = default);
/// <summary>
/// Creates a new equipment-bound tag. <c>FolderPath</c> is always <c>null</c> (decision #110 —
/// the tree only edits equipment-bound tags). Fails on a duplicate <c>TagId</c>, invalid
/// <c>TagConfig</c> JSON, an unknown driver, a driver whose namespace is not Equipment-kind, a
/// driver in a different cluster than the equipment (decision #122), or a name already used on
/// the equipment. Whitespace-only <c>PollGroupId</c> collapses to <c>null</c>.
/// </summary>
/// <param name="equipmentId">The owning equipment.</param>
/// <param name="input">The operator-editable tag fields.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, or one of the guard failures.</returns>
Task<UnsMutationResult> CreateTagAsync(string equipmentId, TagInput input, CancellationToken ct = default);
/// <summary>
/// Updates an equipment-bound tag's driver binding, name, data type, access level, write-retry
/// flag, poll group, and config. The owning <c>EquipmentId</c> and the <c>null</c>
/// <c>FolderPath</c> are preserved. Re-runs the JSON-validity, namespace-kind, and decision-#122
/// cluster guards against the tag's existing equipment, and enforces name uniqueness on that
/// equipment excluding this tag. Uses last-write-wins optimistic concurrency on
/// <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The tag to update.</param>
/// <param name="input">The new operator-editable tag fields.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a missing-row failure, a guard failure, or a concurrency failure.</returns>
Task<UnsMutationResult> UpdateTagAsync(string tagId, TagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a tag. A missing row is treated as success (already gone). Uses last-write-wins
/// optimistic concurrency on <see cref="Configuration.Entities.Tag.RowVersion"/>.
/// </summary>
/// <param name="tagId">The tag to delete.</param>
/// <param name="rowVersion">The concurrency token the caller last read.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>Success, a concurrency failure, or a delete-failed failure.</returns>
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
}
@@ -0,0 +1,28 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for an equipment-bound Tag create or
/// update via the UNS tree. Tree tags always bind to an equipment (<c>FolderPath</c> stays
/// <c>null</c>), so the SystemPlatform/FolderPath branch from the legacy cluster-scoped page does
/// not apply here. Optional string fields that arrive whitespace-only are collapsed to <c>null</c>
/// by the service.
/// </summary>
/// <param name="TagId">Stable unique tag id; only honoured on create (immutable thereafter).</param>
/// <param name="Name">Tag display name; unique within the owning equipment.</param>
/// <param name="DriverInstanceId">The bound driver; must resolve to an Equipment-kind namespace in the equipment's cluster.</param>
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
/// <param name="AccessLevel">Tag-level OPC UA access baseline.</param>
/// <param name="WriteIdempotent">Whether writes are safe to retry (decisions #4445).</param>
/// <param name="PollGroupId">Optional poll-group batching key; whitespace/empty collapses to <c>null</c>.</param>
/// <param name="TagConfig">Schemaless per-driver-type JSON config; validated for JSON well-formedness.</param>
public sealed record TagInput(
string TagId,
string Name,
string DriverInstanceId,
string DataType,
TagAccessLevel AccessLevel,
bool WriteIdempotent,
string? PollGroupId,
string TagConfig);
@@ -1,6 +1,7 @@
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Entities;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
@@ -474,6 +475,249 @@ public sealed class UnsTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
}
}
/// <inheritdoc />
public async Task<IReadOnlyList<(string DriverInstanceId, string Display)>> LoadTagDriversForEquipmentAsync(
string equipmentId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
{
return Array.Empty<(string, string)>();
}
// Drivers in the equipment's cluster whose namespace is Equipment-kind (decision #110).
var equipmentNamespaceIds = await db.Namespaces
.Where(n => n.ClusterId == equipmentCluster && n.Kind == NamespaceKind.Equipment)
.Select(n => n.NamespaceId)
.ToListAsync(ct);
var drivers = await db.DriverInstances
.Where(d => d.ClusterId == equipmentCluster && equipmentNamespaceIds.Contains(d.NamespaceId))
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name })
.ToListAsync(ct);
return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name}"))
.ToList();
}
/// <inheritdoc />
public async Task<UnsMutationResult> CreateTagAsync(
string equipmentId,
TagInput input,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (await db.Tags.AnyAsync(t => t.TagId == input.TagId, ct))
{
return new UnsMutationResult(false, $"Tag '{input.TagId}' already exists.");
}
if (!IsValidJson(input.TagConfig))
{
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
}
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(t => t.EquipmentId == equipmentId && t.Name == input.Name, ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
db.Tags.Add(new Tag
{
TagId = input.TagId,
DriverInstanceId = input.DriverInstanceId,
EquipmentId = equipmentId,
Name = input.Name,
FolderPath = null,
DataType = input.DataType,
AccessLevel = input.AccessLevel,
WriteIdempotent = input.WriteIdempotent,
PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId,
TagConfig = input.TagConfig,
});
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
/// <inheritdoc />
public async Task<UnsMutationResult> UpdateTagAsync(
string tagId,
TagInput input,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
if (entity is null)
{
return new UnsMutationResult(false, "Row no longer exists.");
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
if (!IsValidJson(input.TagConfig))
{
return new UnsMutationResult(false, "TagConfig is not valid JSON.");
}
var equipmentCluster = await ResolveEquipmentClusterAsync(db, entity.EquipmentId, ct);
var guard = await CheckTagDriverGuardAsync(db, input.DriverInstanceId, equipmentCluster, ct);
if (guard is not null)
{
return guard.Value;
}
if (await db.Tags.AnyAsync(
t => t.EquipmentId == entity.EquipmentId && t.Name == input.Name && t.TagId != tagId,
ct))
{
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists on this equipment.");
}
// EquipmentId and FolderPath (null) are preserved — tree tags are always equipment-bound.
entity.DriverInstanceId = input.DriverInstanceId;
entity.Name = input.Name;
entity.DataType = input.DataType;
entity.AccessLevel = input.AccessLevel;
entity.WriteIdempotent = input.WriteIdempotent;
entity.PollGroupId = string.IsNullOrWhiteSpace(input.PollGroupId) ? null : input.PollGroupId;
entity.TagConfig = input.TagConfig;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this tag while you were editing.");
}
}
/// <inheritdoc />
public async Task<UnsMutationResult> DeleteTagAsync(
string tagId,
byte[] rowVersion,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.Tags.FirstOrDefaultAsync(t => t.TagId == tagId, ct);
if (entity is null)
{
return new UnsMutationResult(true, null);
}
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.Tags.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this tag while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Delete failed: {ex.Message}.");
}
}
/// <summary>Returns <c>true</c> if <paramref name="json"/> parses as a well-formed JSON document.</summary>
private static bool IsValidJson(string json)
{
try
{
using var _ = System.Text.Json.JsonDocument.Parse(json);
return true;
}
catch
{
return false;
}
}
/// <summary>
/// Resolves an equipment to its cluster via <c>Equipment.UnsLineId → UnsLine.UnsAreaId →
/// UnsArea.ClusterId</c>. Returns <c>null</c> when the equipment, its line, or its area cannot be
/// resolved.
/// </summary>
private static async Task<string?> ResolveEquipmentClusterAsync(
OtOpcUaConfigDbContext db,
string? equipmentId,
CancellationToken ct)
{
if (string.IsNullOrEmpty(equipmentId))
{
return null;
}
var equipment = await db.Equipment.FirstOrDefaultAsync(e => e.EquipmentId == equipmentId, ct);
if (equipment is null)
{
return null;
}
var line = await db.UnsLines.FirstOrDefaultAsync(l => l.UnsLineId == equipment.UnsLineId, ct);
if (line is null)
{
return null;
}
var area = await db.UnsAreas.FirstOrDefaultAsync(a => a.UnsAreaId == line.UnsAreaId, ct);
return area?.ClusterId;
}
/// <summary>
/// Validates a tree tag's driver binding: the driver must exist, live in an Equipment-kind
/// namespace (decision #110), and be in the same cluster as the owning equipment (decision #122).
/// Returns <c>null</c> when the binding is allowed, or a populated failure otherwise.
/// </summary>
private static async Task<UnsMutationResult?> CheckTagDriverGuardAsync(
OtOpcUaConfigDbContext db,
string driverInstanceId,
string? equipmentCluster,
CancellationToken ct)
{
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
if (driver is null)
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
}
var ns = await db.Namespaces.FirstOrDefaultAsync(n => n.NamespaceId == driver.NamespaceId, ct);
if (ns?.Kind != NamespaceKind.Equipment)
{
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' is not in an Equipment-kind namespace.");
}
if (driver.ClusterId != equipmentCluster)
{
return new UnsMutationResult(
false,
$"Driver '{driverInstanceId}' is in cluster '{driver.ClusterId}' but the equipment is in cluster '{equipmentCluster}' (decision #122).");
}
return null;
}
/// <summary>
/// Decision #122: an equipment may only bind to a driver in the same cluster as its line.
/// Policy: