using System.Security.Cryptography;
using System.Text;
using Microsoft.EntityFrameworkCore;
using ZB.MOM.WW.OtOpcUa.Commons.Types;
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;
///
/// Default . Reads the structural rows with a handful of
/// untracked queries, computes per-equipment tag/virtual-tag counts, and hands the flat
/// rows to the pure to nest into the browse tree.
///
///
/// Each call creates and disposes its own context via the pooled factory — the same pattern
/// every AdminUI page uses — so the service is safe to register as Scoped and used per
/// Blazor circuit.
///
public sealed class UnsTreeService(
IDbContextFactory dbFactory,
IEffectiveNameGuard? guard = null) : IUnsTreeService
{
// The v3 Batch-3 effective-name uniqueness guard (references / VirtualTags / ScriptedAlarms share the
// equipment's UNS NodeId space). WP2 registers the real implementation; production DI always injects it.
// A null (unregistered) guard falls back to a no-op — collisions are then caught only at the deploy
// gate — which also lets unit tests that don't exercise the guard construct the service with just a
// db factory. Tests that DO exercise the guard pass a fake.
private readonly IEffectiveNameGuard _guard = guard ?? NoOpEffectiveNameGuard.Instance;
///
public async Task> LoadStructureAsync(CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var clusters = await db.ServerClusters
.AsNoTracking()
.Select(c => new ClusterRow(c.ClusterId, c.Enterprise, c.Site, c.Name))
.ToListAsync(ct);
var areas = await db.UnsAreas
.AsNoTracking()
.Select(a => new AreaRow(a.UnsAreaId, a.ClusterId, a.Name))
.ToListAsync(ct);
var lines = await db.UnsLines
.AsNoTracking()
.Select(l => new LineRow(l.UnsLineId, l.UnsAreaId, l.Name))
.ToListAsync(ct);
var equipmentRows = await db.Equipment
.AsNoTracking()
.Select(e => new
{
e.EquipmentId,
e.UnsLineId,
e.MachineCode,
e.Name,
})
.ToListAsync(ct);
// Per-equipment driver-tag counts: the equipment↔Tag binding was retired in v3
// (Tag is now Raw-only; authoring moved to the Raw tree in Batch 2/3). No binding
// exists to count against, so every equipment reports zero driver tags.
var tagCounts = new Dictionary(StringComparer.Ordinal);
// Per-equipment virtual-tag counts (virtual tags with no equipment are excluded).
var vtagCounts = (await db.VirtualTags
.AsNoTracking()
.Where(v => v.EquipmentId != null)
.GroupBy(v => v.EquipmentId)
.Select(g => new { EquipmentId = g.Key!, Count = g.Count() })
.ToListAsync(ct))
.ToDictionary(x => x.EquipmentId, x => x.Count, StringComparer.Ordinal);
var equipment = equipmentRows
.Select(e => new EquipmentRow(
e.EquipmentId,
e.UnsLineId,
e.MachineCode,
e.Name,
tagCounts.GetValueOrDefault(e.EquipmentId),
vtagCounts.GetValueOrDefault(e.EquipmentId)))
.ToList();
return UnsTreeAssembly.Build(clusters, areas, lines, equipment);
}
///
public Task> LoadTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
// The equipment↔Tag binding was retired in v3 (Tag is now Raw-only; authoring moved to
// the Raw tree in Batch 2/3). No equipment-bound tags exist to list.
return Task.FromResult>(Array.Empty());
}
///
public async Task> LoadVirtualTagsForEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.VirtualTags.AsNoTracking()
.Where(v => v.EquipmentId == equipmentId)
.OrderBy(v => v.Name)
.Select(v => new EquipmentVirtualTagRow(v.VirtualTagId, v.Name, v.DataType, v.ScriptId, v.Enabled))
.ToListAsync(ct);
}
// ---------------------------------------------------------------------------------------------
// v3 UNS reference-only equipment (Batch 3): the Tags tab lists UnsTagReference rows.
// ---------------------------------------------------------------------------------------------
///
public async Task> LoadReferencesForEquipmentAsync(
string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var refs = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.OrderBy(r => r.SortOrder).ThenBy(r => r.UnsTagReferenceId)
.Select(r => new
{
r.UnsTagReferenceId,
r.TagId,
r.DisplayNameOverride,
r.SortOrder,
r.RowVersion,
})
.ToListAsync(ct);
if (refs.Count == 0) return Array.Empty();
// Load the backing raw tags (name + inherited datatype/access), then compute each RawPath from the
// equipment cluster's raw topology through the shared RawPathResolver (identity authority).
var tagIds = refs.Select(r => r.TagId).Distinct().ToList();
var tags = (await db.Tags.AsNoTracking()
.Where(t => tagIds.Contains(t.TagId))
.Select(t => new { t.TagId, t.DeviceId, t.TagGroupId, t.Name, t.DataType, t.AccessLevel })
.ToListAsync(ct))
.ToDictionary(t => t.TagId, StringComparer.Ordinal);
var cluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
var resolver = await BuildClusterRawPathResolverAsync(db, cluster, ct);
var rows = new List(refs.Count);
foreach (var r in refs)
{
if (!tags.TryGetValue(r.TagId, out var tag))
{
// Backing tag missing (should not happen — raw-tag delete is reference-blocked); surface a
// clearly-degraded row rather than dropping it so the operator can remove the dangling ref.
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, r.DisplayNameOverride ?? "(missing tag)", "(missing tag)",
"", TagAccessLevel.Read, r.DisplayNameOverride, r.SortOrder, r.RowVersion));
continue;
}
var rawPath = resolver?.TryBuildTagPath(tag.DeviceId, tag.TagGroupId, tag.Name) ?? tag.Name;
var effectiveName = string.IsNullOrEmpty(r.DisplayNameOverride) ? tag.Name : r.DisplayNameOverride;
rows.Add(new EquipmentReferenceRow(
r.UnsTagReferenceId, effectiveName, rawPath, tag.DataType, tag.AccessLevel,
r.DisplayNameOverride, r.SortOrder, r.RowVersion));
}
return rows;
}
///
public async Task AddReferencesAsync(
string equipmentId, IReadOnlyList tagIds, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(tagIds);
var distinctTagIds = tagIds.Where(t => !string.IsNullOrEmpty(t)).Distinct(StringComparer.Ordinal).ToList();
if (distinctTagIds.Count == 0) return new UnsMutationResult(false, "Pick at least one raw tag to reference.");
await using var db = await dbFactory.CreateDbContextAsync(ct);
if (!await db.Equipment.AnyAsync(e => e.EquipmentId == equipmentId, ct))
return new UnsMutationResult(false, $"Equipment '{equipmentId}' not found.");
var equipmentCluster = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (equipmentCluster is null)
return new UnsMutationResult(false, $"Equipment '{equipmentId}' does not resolve to a cluster.");
// Load the backing tags + the cluster each lives in (Tag → Device → DriverInstance.ClusterId).
var tags = await db.Tags.AsNoTracking()
.Where(t => distinctTagIds.Contains(t.TagId))
.Join(db.Devices.AsNoTracking(), t => t.DeviceId, dev => dev.DeviceId, (t, dev) => new { t, dev.DriverInstanceId })
.Join(db.DriverInstances.AsNoTracking(), x => x.DriverInstanceId, d => d.DriverInstanceId,
(x, d) => new { x.t.TagId, x.t.Name, DriverCluster = d.ClusterId })
.ToListAsync(ct);
var tagById = tags.ToDictionary(t => t.TagId, StringComparer.Ordinal);
// Existing references on the equipment (to reject re-referencing the same tag).
var alreadyReferenced = (await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => r.TagId)
.ToListAsync(ct))
.ToHashSet(StringComparer.Ordinal);
var maxSort = await db.UnsTagReferences.AsNoTracking()
.Where(r => r.EquipmentId == equipmentId)
.Select(r => (int?)r.SortOrder)
.MaxAsync(ct) ?? -1;
// Track effective names proposed within THIS batch so two raw tags sharing a Name are caught (the
// guard sees only persisted rows; a same-batch collision would otherwise slip through).
var batchNames = new HashSet(StringComparer.Ordinal);
var pending = new List();
foreach (var tagId in distinctTagIds)
{
if (!tagById.TryGetValue(tagId, out var tag))
return new UnsMutationResult(false, $"Raw tag '{tagId}' not found.");
if (!string.Equals(tag.DriverCluster, equipmentCluster, StringComparison.Ordinal))
return new UnsMutationResult(false,
$"Raw tag '{tag.Name}' is in cluster '{tag.DriverCluster}' but the equipment is in cluster '{equipmentCluster}' — cross-cluster references are not allowed.");
if (alreadyReferenced.Contains(tagId))
return new UnsMutationResult(false, $"Raw tag '{tag.Name}' is already referenced by this equipment.");
// Effective name at add = the raw tag's Name (no override yet).
if (!batchNames.Add(tag.Name))
return new UnsMutationResult(false,
$"Two selected raw tags share the effective name '{tag.Name}'; set a display-name override so they stay unique within the equipment.");
var collision = await _guard.CheckAsync(equipmentId, tag.Name, EffectiveNameSourceKind.Reference, null, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
pending.Add(new UnsTagReference
{
UnsTagReferenceId = $"UTR-{Guid.NewGuid():N}"[..16],
EquipmentId = equipmentId,
TagId = tagId,
DisplayNameOverride = null,
SortOrder = ++maxSort,
});
}
db.UnsTagReferences.AddRange(pending);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateException)
{
// A concurrent add slipped a same-(equipment,tag) or same-name row past the pre-check.
return new UnsMutationResult(false, "Add failed — a reference at one of these tags was created concurrently. Reload and retry.");
}
}
///
public async Task RemoveReferenceAsync(
string unsTagReferenceId, byte[] rowVersion, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(true, null);
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
db.UnsTagReferences.Remove(entity);
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were viewing it.");
}
catch (Exception ex)
{
return new UnsMutationResult(false, $"Remove failed: {ex.Message}.");
}
}
///
public async Task SetReferenceOverrideAsync(
string unsTagReferenceId, string? displayNameOverride, byte[] rowVersion, CancellationToken ct = default)
{
var normalized = string.IsNullOrWhiteSpace(displayNameOverride) ? null : displayNameOverride.Trim();
await using var db = await dbFactory.CreateDbContextAsync(ct);
var entity = await db.UnsTagReferences.FirstOrDefaultAsync(r => r.UnsTagReferenceId == unsTagReferenceId, ct);
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
// Effective name = the override when set, else the backing raw tag's Name.
var rawName = await db.Tags.AsNoTracking()
.Where(t => t.TagId == entity.TagId)
.Select(t => t.Name)
.FirstOrDefaultAsync(ct);
var effectiveName = normalized ?? rawName ?? string.Empty;
var collision = await _guard.CheckAsync(
entity.EquipmentId, effectiveName, EffectiveNameSourceKind.Reference, entity.UnsTagReferenceId, ct);
if (collision is not null) return new UnsMutationResult(false, collision);
entity.DisplayNameOverride = normalized;
try
{
await db.SaveChangesAsync(ct);
return new UnsMutationResult(true, null);
}
catch (DbUpdateConcurrencyException)
{
return new UnsMutationResult(false, "Another user changed this reference while you were editing.");
}
}
///
public async Task LoadReferencePickerRootAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var clusterId = await ResolveEquipmentClusterAsync(db, equipmentId, ct);
if (clusterId is null) return null;
var cluster = await db.ServerClusters.AsNoTracking()
.FirstOrDefaultAsync(c => c.ClusterId == clusterId, ct);
if (cluster is null) return null;
// Root child count = cluster-root folders + cluster-root drivers (the RawTree lazily loads the rest
// via IRawTreeService.LoadChildrenAsync, keyed on this node's Cluster kind + EntityId).
var rootFolders = await db.RawFolders.AsNoTracking()
.CountAsync(f => f.ClusterId == clusterId && f.ParentRawFolderId == null, ct);
var rootDrivers = await db.DriverInstances.AsNoTracking()
.CountAsync(d => d.ClusterId == clusterId && d.RawFolderId == null, ct);
return new RawNode
{
Kind = RawNodeKind.Cluster,
Key = $"clu:{clusterId}",
DisplayName = cluster.Name,
ClusterId = clusterId,
EntityId = clusterId,
HasLazyChildren = true,
ChildCount = rootFolders + rootDrivers,
};
}
///
public async Task> LoadDescendantTagIdsAsync(RawNode node, CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(node);
if (node.EntityId is null) return Array.Empty();
await using var db = await dbFactory.CreateDbContextAsync(ct);
switch (node.Kind)
{
case RawNodeKind.Device:
// Every tag under the device (across all its tag groups).
return await db.Tags.AsNoTracking()
.Where(t => t.DeviceId == node.EntityId)
.Select(t => t.TagId)
.ToListAsync(ct);
case RawNodeKind.TagGroup:
{
// The group + all descendant groups, then every tag in that group set.
var group = await db.TagGroups.AsNoTracking()
.FirstOrDefaultAsync(g => g.TagGroupId == node.EntityId, ct);
if (group is null) return Array.Empty();
var deviceGroups = await db.TagGroups.AsNoTracking()
.Where(g => g.DeviceId == group.DeviceId)
.Select(g => new { g.TagGroupId, g.ParentTagGroupId })
.ToListAsync(ct);
var childrenByParent = deviceGroups
.Where(g => g.ParentTagGroupId is not null)
.GroupBy(g => g.ParentTagGroupId!, StringComparer.Ordinal)
.ToDictionary(g => g.Key, g => g.Select(x => x.TagGroupId).ToList(), StringComparer.Ordinal);
var groupIds = DescendantGroupsInclusive(node.EntityId, childrenByParent);
return await db.Tags.AsNoTracking()
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
.Select(t => t.TagId)
.ToListAsync(ct);
}
default:
// The picker only offers "select all" on Device / TagGroup containers.
return Array.Empty();
}
}
/// Returns plus all transitive descendant group ids (iterative walk).
private static HashSet DescendantGroupsInclusive(
string rootId, IReadOnlyDictionary> childrenByParent)
{
var result = new HashSet(StringComparer.Ordinal);
var stack = new Stack();
stack.Push(rootId);
while (stack.Count > 0)
{
var id = stack.Pop();
if (!result.Add(id)) continue;
if (childrenByParent.TryGetValue(id, out var kids))
{
foreach (var kid in kids) stack.Push(kid);
}
}
return result;
}
///
/// Builds a over a cluster's raw topology (folders / drivers / devices /
/// tag-groups), so a referenced tag's RawPath can be computed the same way the deploy validator does.
/// Returns when the cluster is unknown/null (callers then fall back to bare name).
///
private static async Task BuildClusterRawPathResolverAsync(
OtOpcUaConfigDbContext db, string? clusterId, CancellationToken ct)
{
if (string.IsNullOrEmpty(clusterId)) return null;
var folders = (await db.RawFolders.AsNoTracking()
.Where(f => f.ClusterId == clusterId)
.Select(f => new { f.RawFolderId, f.ParentRawFolderId, f.Name })
.ToListAsync(ct))
.ToDictionary(f => f.RawFolderId, f => ((string?)f.ParentRawFolderId, f.Name), StringComparer.Ordinal);
var drivers = (await db.DriverInstances.AsNoTracking()
.Where(d => d.ClusterId == clusterId)
.Select(d => new { d.DriverInstanceId, d.RawFolderId, d.Name })
.ToListAsync(ct))
.ToDictionary(d => d.DriverInstanceId, d => ((string?)d.RawFolderId, d.Name), StringComparer.Ordinal);
var driverIds = drivers.Keys.ToList();
var devices = (await db.Devices.AsNoTracking()
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
.Select(dev => new { dev.DeviceId, dev.DriverInstanceId, dev.Name })
.ToListAsync(ct))
.ToDictionary(dev => dev.DeviceId, dev => (dev.DriverInstanceId, dev.Name), StringComparer.Ordinal);
var deviceIds = devices.Keys.ToList();
var groups = (await db.TagGroups.AsNoTracking()
.Where(g => deviceIds.Contains(g.DeviceId))
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
.ToListAsync(ct))
.ToDictionary(g => g.TagGroupId, g => ((string?)g.ParentTagGroupId, g.Name), StringComparer.Ordinal);
return new RawPathResolver(folders, drivers, devices, groups);
}
///
/// A no-op used only when no guard was injected (unit tests that do
/// not exercise the guard; a mis-wired DI). It always reports "free" — the deploy gate remains the
/// backstop. Production always injects the real WP2 guard.
///
private sealed class NoOpEffectiveNameGuard : IEffectiveNameGuard
{
public static readonly NoOpEffectiveNameGuard Instance = new();
public Task CheckAsync(
string equipmentId, string proposedEffectiveName, EffectiveNameSourceKind kind,
string? excludeSourceId, CancellationToken ct = default) => Task.FromResult(null);
}
///
public async Task LoadAreaAsync(string unsAreaId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.UnsAreas
.AsNoTracking()
.Where(a => a.UnsAreaId == unsAreaId)
.Select(a => new AreaEditDto(a.UnsAreaId, a.Name, a.Notes, a.ClusterId, a.RowVersion))
.FirstOrDefaultAsync(ct);
}
///
public async Task LoadLineAsync(string unsLineId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.UnsLines
.AsNoTracking()
.Where(l => l.UnsLineId == unsLineId)
.Select(l => new LineEditDto(l.UnsLineId, l.UnsAreaId, l.Name, l.Notes, l.RowVersion))
.FirstOrDefaultAsync(ct);
}
///
public async Task LoadEquipmentAsync(string equipmentId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Equipment
.AsNoTracking()
.Where(e => e.EquipmentId == equipmentId)
.Select(e => new EquipmentEditDto(
e.EquipmentId,
e.Name,
e.MachineCode,
e.UnsLineId,
// Equipment↔driver binding retired in v3 — no DriverInstanceId column remains.
null,
e.ZTag,
e.SAPID,
e.Manufacturer,
e.Model,
e.SerialNumber,
e.HardwareRevision,
e.SoftwareRevision,
e.YearOfConstruction,
e.AssetLocation,
e.ManufacturerUri,
e.DeviceManualUri,
e.Enabled,
e.RowVersion))
.FirstOrDefaultAsync(ct);
}
///
public async Task LoadTagAsync(string tagId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.Tags
.AsNoTracking()
.Where(t => t.TagId == tagId)
.Select(t => new TagEditDto(
t.TagId,
// Tag is Raw-only in v3 — equipment binding + driver binding retired.
string.Empty,
t.Name,
string.Empty,
t.DataType,
t.AccessLevel,
t.WriteIdempotent,
t.PollGroupId,
t.TagConfig,
t.RowVersion))
.FirstOrDefaultAsync(ct);
}
///
public async Task LoadVirtualTagAsync(string virtualTagId, CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
return await db.VirtualTags
.AsNoTracking()
.Where(v => v.VirtualTagId == virtualTagId)
.Select(v => new VirtualTagEditDto(
v.VirtualTagId,
v.EquipmentId,
v.Name,
v.DataType,
v.ScriptId,
v.ChangeTriggered,
v.TimerIntervalMs,
v.Historize,
v.Enabled,
v.RowVersion))
.FirstOrDefaultAsync(ct);
}
///
public async Task> LoadDriversForClusterAsync(
string clusterId,
CancellationToken ct = default)
{
await using var db = await dbFactory.CreateDbContextAsync(ct);
var drivers = await db.DriverInstances
.AsNoTracking()
.Where(d => d.ClusterId == clusterId)
.OrderBy(d => d.DriverInstanceId)
.Select(d => new { d.DriverInstanceId, d.Name, d.DriverType })
.ToListAsync(ct);
return drivers
.Select(d => (d.DriverInstanceId, Display: $"{d.DriverInstanceId} — {d.Name} ({d.DriverType})"))
.ToList();
}
///
public async Task LoadEquipmentPickContextAsync(string? lineId, CancellationToken ct = default)
{
var empty = new EquipmentPickContext(Array.Empty<(string, string)>(), Array.Empty<(string, string)>());
if (string.IsNullOrEmpty(lineId)) return empty;
await using var db = await dbFactory.CreateDbContextAsync(ct);
// line -> area -> clusterId
var clusterId = await (from l in db.UnsLines.AsNoTracking()
join a in db.UnsAreas.AsNoTracking() on l.UnsAreaId equals a.UnsAreaId
where l.UnsLineId == lineId
select a.ClusterId).FirstOrDefaultAsync(ct);
if (string.IsNullOrEmpty(clusterId)) return empty;
// all lines in that cluster (for the line