76cffe1f49
Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
1454 lines
65 KiB
C#
1454 lines
65 KiB
C#
using System.Text.Json;
|
|
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.Core.Abstractions;
|
|
|
|
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
|
|
|
|
/// <summary>
|
|
/// Default <see cref="IRawTreeService"/>. Reads the Raw-tree structural rows with untracked queries,
|
|
/// lazily materialising one level per expand, and applies structural mutations (create/rename/delete/
|
|
/// move) with last-write-wins optimistic concurrency on <c>RowVersion</c> — the same pattern
|
|
/// <see cref="UnsTreeService"/> uses for the UNS subtree. Integrity rules (sibling-name uniqueness,
|
|
/// non-empty-container delete blocks, referenced-tag delete blocks) are pre-checked with user-readable
|
|
/// errors so the refusal is deterministic even under the EF InMemory provider (which does not enforce
|
|
/// the FK <c>Restrict</c> / filtered-unique indexes the real SQL schema carries).
|
|
/// </summary>
|
|
/// <remarks>
|
|
/// Each call creates and disposes its own context via the pooled factory, so the service is safe to
|
|
/// register as Scoped and used per Blazor circuit.
|
|
/// </remarks>
|
|
public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbFactory) : IRawTreeService
|
|
{
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Read surface
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<RawNode>> LoadRootsAsync(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);
|
|
|
|
// Per-cluster root child count = root folders (ParentRawFolderId == null) + cluster-root drivers
|
|
// (RawFolderId == null), for the cluster badge.
|
|
var rootFolderCounts = (await db.RawFolders.AsNoTracking()
|
|
.Where(f => f.ParentRawFolderId == null)
|
|
.GroupBy(f => f.ClusterId)
|
|
.Select(g => new { ClusterId = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal);
|
|
|
|
var rootDriverCounts = (await db.DriverInstances.AsNoTracking()
|
|
.Where(d => d.RawFolderId == null)
|
|
.GroupBy(d => d.ClusterId)
|
|
.Select(g => new { ClusterId = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.ClusterId, x => x.Count, StringComparer.Ordinal);
|
|
|
|
var rootChildCounts = new Dictionary<string, int>(StringComparer.Ordinal);
|
|
foreach (var c in clusters)
|
|
{
|
|
rootChildCounts[c.ClusterId] =
|
|
rootFolderCounts.GetValueOrDefault(c.ClusterId) + rootDriverCounts.GetValueOrDefault(c.ClusterId);
|
|
}
|
|
|
|
return RawTreeAssembly.BuildRoots(clusters, rootChildCounts);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IReadOnlyList<RawNode>> LoadChildrenAsync(RawNode parent, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(parent);
|
|
if (parent.EntityId is null)
|
|
{
|
|
return Array.Empty<RawNode>();
|
|
}
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
return parent.Kind switch
|
|
{
|
|
RawNodeKind.Cluster => await LoadClusterChildrenAsync(db, parent.EntityId, ct),
|
|
RawNodeKind.Folder => await LoadFolderChildrenAsync(db, parent.ClusterId, parent.EntityId, ct),
|
|
RawNodeKind.Driver => await LoadDriverChildrenAsync(db, parent, ct),
|
|
RawNodeKind.Device => await LoadDeviceChildrenAsync(db, parent, ct),
|
|
RawNodeKind.TagGroup => await LoadTagGroupChildrenAsync(db, parent, ct),
|
|
_ => Array.Empty<RawNode>(), // Tag (leaf) / Enterprise
|
|
};
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<RawNode>> LoadClusterChildrenAsync(
|
|
OtOpcUaConfigDbContext db, string clusterId, CancellationToken ct)
|
|
{
|
|
var folders = await db.RawFolders.AsNoTracking()
|
|
.Where(f => f.ClusterId == clusterId && f.ParentRawFolderId == null)
|
|
.OrderBy(f => f.SortOrder).ThenBy(f => f.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var drivers = await db.DriverInstances.AsNoTracking()
|
|
.Where(d => d.ClusterId == clusterId && d.RawFolderId == null)
|
|
.OrderBy(d => d.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var nodes = new List<RawNode>(folders.Count + drivers.Count);
|
|
nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, folders, ct));
|
|
nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct));
|
|
return nodes;
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<RawNode>> LoadFolderChildrenAsync(
|
|
OtOpcUaConfigDbContext db, string? clusterId, string folderId, CancellationToken ct)
|
|
{
|
|
// Resolve the cluster from the folder itself when the parent node did not carry it.
|
|
clusterId ??= await db.RawFolders.AsNoTracking()
|
|
.Where(f => f.RawFolderId == folderId).Select(f => f.ClusterId).FirstOrDefaultAsync(ct);
|
|
|
|
var subFolders = await db.RawFolders.AsNoTracking()
|
|
.Where(f => f.ParentRawFolderId == folderId)
|
|
.OrderBy(f => f.SortOrder).ThenBy(f => f.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var drivers = await db.DriverInstances.AsNoTracking()
|
|
.Where(d => d.RawFolderId == folderId)
|
|
.OrderBy(d => d.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var nodes = new List<RawNode>(subFolders.Count + drivers.Count);
|
|
nodes.AddRange(await BuildFolderNodesAsync(db, clusterId, subFolders, ct));
|
|
nodes.AddRange(await BuildDriverNodesAsync(db, clusterId, drivers, ct));
|
|
return nodes;
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<RawNode>> LoadDriverChildrenAsync(
|
|
OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct)
|
|
{
|
|
var driverId = parent.EntityId!;
|
|
// The driver node carries its own DriverType; fall back to a lookup if absent.
|
|
var driverType = parent.DriverType
|
|
?? await db.DriverInstances.AsNoTracking()
|
|
.Where(d => d.DriverInstanceId == driverId).Select(d => d.DriverType).FirstOrDefaultAsync(ct);
|
|
|
|
var devices = await db.Devices.AsNoTracking()
|
|
.Where(dev => dev.DriverInstanceId == driverId)
|
|
.OrderBy(dev => dev.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var deviceIds = devices.Select(d => d.DeviceId).ToList();
|
|
var groupCounts = await CountRootGroupsByDeviceAsync(db, deviceIds, ct);
|
|
var tagCounts = await CountRootTagsByDeviceAsync(db, deviceIds, ct);
|
|
|
|
return devices.Select(dev => new RawNode
|
|
{
|
|
Kind = RawNodeKind.Device,
|
|
Key = $"dev:{dev.DeviceId}",
|
|
DisplayName = dev.Name,
|
|
ClusterId = parent.ClusterId,
|
|
EntityId = dev.DeviceId,
|
|
DriverType = driverType,
|
|
Enabled = dev.Enabled,
|
|
HasLazyChildren = true,
|
|
ChildCount = groupCounts.GetValueOrDefault(dev.DeviceId) + tagCounts.GetValueOrDefault(dev.DeviceId),
|
|
RowVersion = dev.RowVersion,
|
|
}).ToList();
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<RawNode>> LoadDeviceChildrenAsync(
|
|
OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct)
|
|
{
|
|
var deviceId = parent.EntityId!;
|
|
|
|
var groups = await db.TagGroups.AsNoTracking()
|
|
.Where(g => g.DeviceId == deviceId && g.ParentTagGroupId == null)
|
|
.OrderBy(g => g.SortOrder).ThenBy(g => g.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var tags = await db.Tags.AsNoTracking()
|
|
.Where(t => t.DeviceId == deviceId && t.TagGroupId == null)
|
|
.OrderBy(t => t.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var nodes = new List<RawNode>(groups.Count + tags.Count);
|
|
nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, groups, ct));
|
|
nodes.AddRange(tags.Select(t => BuildTagNode(parent, t)));
|
|
return nodes;
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<RawNode>> LoadTagGroupChildrenAsync(
|
|
OtOpcUaConfigDbContext db, RawNode parent, CancellationToken ct)
|
|
{
|
|
var groupId = parent.EntityId!;
|
|
|
|
var subGroups = await db.TagGroups.AsNoTracking()
|
|
.Where(g => g.ParentTagGroupId == groupId)
|
|
.OrderBy(g => g.SortOrder).ThenBy(g => g.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var tags = await db.Tags.AsNoTracking()
|
|
.Where(t => t.TagGroupId == groupId)
|
|
.OrderBy(t => t.Name)
|
|
.ToListAsync(ct);
|
|
|
|
var nodes = new List<RawNode>(subGroups.Count + tags.Count);
|
|
nodes.AddRange(await BuildTagGroupNodesAsync(db, parent, subGroups, ct));
|
|
nodes.AddRange(tags.Select(t => BuildTagNode(parent, t)));
|
|
return nodes;
|
|
}
|
|
|
|
// --- Node builders (compute per-node child counts in bulk) ---
|
|
|
|
private static async Task<IEnumerable<RawNode>> BuildFolderNodesAsync(
|
|
OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList<RawFolder> folders, CancellationToken ct)
|
|
{
|
|
if (folders.Count == 0) return Array.Empty<RawNode>();
|
|
var ids = folders.Select(f => f.RawFolderId).ToList();
|
|
|
|
var subFolderCounts = (await db.RawFolders.AsNoTracking()
|
|
.Where(f => f.ParentRawFolderId != null && ids.Contains(f.ParentRawFolderId))
|
|
.GroupBy(f => f.ParentRawFolderId!)
|
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
|
|
|
|
var driverCounts = (await db.DriverInstances.AsNoTracking()
|
|
.Where(d => d.RawFolderId != null && ids.Contains(d.RawFolderId))
|
|
.GroupBy(d => d.RawFolderId!)
|
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
|
|
|
|
return folders.Select(f => new RawNode
|
|
{
|
|
Kind = RawNodeKind.Folder,
|
|
Key = $"fld:{f.RawFolderId}",
|
|
DisplayName = f.Name,
|
|
ClusterId = clusterId,
|
|
EntityId = f.RawFolderId,
|
|
HasLazyChildren = true,
|
|
ChildCount = subFolderCounts.GetValueOrDefault(f.RawFolderId) + driverCounts.GetValueOrDefault(f.RawFolderId),
|
|
RowVersion = f.RowVersion,
|
|
}).ToList();
|
|
}
|
|
|
|
private static async Task<IEnumerable<RawNode>> BuildDriverNodesAsync(
|
|
OtOpcUaConfigDbContext db, string? clusterId, IReadOnlyList<DriverInstance> drivers, CancellationToken ct)
|
|
{
|
|
if (drivers.Count == 0) return Array.Empty<RawNode>();
|
|
var ids = drivers.Select(d => d.DriverInstanceId).ToList();
|
|
|
|
var deviceCounts = (await db.Devices.AsNoTracking()
|
|
.Where(dev => ids.Contains(dev.DriverInstanceId))
|
|
.GroupBy(dev => dev.DriverInstanceId)
|
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
|
|
|
|
return drivers.Select(d => new RawNode
|
|
{
|
|
Kind = RawNodeKind.Driver,
|
|
Key = $"drv:{d.DriverInstanceId}",
|
|
DisplayName = d.Name,
|
|
ClusterId = clusterId,
|
|
EntityId = d.DriverInstanceId,
|
|
DriverType = d.DriverType,
|
|
Enabled = d.Enabled,
|
|
HasLazyChildren = true,
|
|
ChildCount = deviceCounts.GetValueOrDefault(d.DriverInstanceId),
|
|
RowVersion = d.RowVersion,
|
|
}).ToList();
|
|
}
|
|
|
|
private static async Task<IEnumerable<RawNode>> BuildTagGroupNodesAsync(
|
|
OtOpcUaConfigDbContext db, RawNode parent, IReadOnlyList<TagGroup> groups, CancellationToken ct)
|
|
{
|
|
if (groups.Count == 0) return Array.Empty<RawNode>();
|
|
var ids = groups.Select(g => g.TagGroupId).ToList();
|
|
|
|
var subGroupCounts = (await db.TagGroups.AsNoTracking()
|
|
.Where(g => g.ParentTagGroupId != null && ids.Contains(g.ParentTagGroupId))
|
|
.GroupBy(g => g.ParentTagGroupId!)
|
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
|
|
|
|
var tagCounts = (await db.Tags.AsNoTracking()
|
|
.Where(t => t.TagGroupId != null && ids.Contains(t.TagGroupId))
|
|
.GroupBy(t => t.TagGroupId!)
|
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
|
|
|
|
return groups.Select(g => new RawNode
|
|
{
|
|
Kind = RawNodeKind.TagGroup,
|
|
Key = $"grp:{g.TagGroupId}",
|
|
DisplayName = g.Name,
|
|
ClusterId = parent.ClusterId,
|
|
EntityId = g.TagGroupId,
|
|
DriverType = parent.DriverType,
|
|
HasLazyChildren = true,
|
|
ChildCount = subGroupCounts.GetValueOrDefault(g.TagGroupId) + tagCounts.GetValueOrDefault(g.TagGroupId),
|
|
RowVersion = g.RowVersion,
|
|
}).ToList();
|
|
}
|
|
|
|
private static RawNode BuildTagNode(RawNode parent, Tag t) => new()
|
|
{
|
|
Kind = RawNodeKind.Tag,
|
|
Key = $"tag:{t.TagId}",
|
|
DisplayName = t.Name,
|
|
ClusterId = parent.ClusterId,
|
|
EntityId = t.TagId,
|
|
DriverType = parent.DriverType,
|
|
HasLazyChildren = false,
|
|
ChildCount = 0,
|
|
RowVersion = t.RowVersion,
|
|
};
|
|
|
|
private static async Task<Dictionary<string, int>> CountRootGroupsByDeviceAsync(
|
|
OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
|
|
{
|
|
if (deviceIds.Count == 0) return new Dictionary<string, int>(StringComparer.Ordinal);
|
|
return (await db.TagGroups.AsNoTracking()
|
|
.Where(g => g.ParentTagGroupId == null && deviceIds.Contains(g.DeviceId))
|
|
.GroupBy(g => g.DeviceId)
|
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
|
|
}
|
|
|
|
private static async Task<Dictionary<string, int>> CountRootTagsByDeviceAsync(
|
|
OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
|
|
{
|
|
if (deviceIds.Count == 0) return new Dictionary<string, int>(StringComparer.Ordinal);
|
|
return (await db.Tags.AsNoTracking()
|
|
.Where(t => t.TagGroupId == null && deviceIds.Contains(t.DeviceId))
|
|
.GroupBy(t => t.DeviceId)
|
|
.Select(g => new { Id = g.Key, Count = g.Count() })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.Id, x => x.Count, StringComparer.Ordinal);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Folder mutations
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> CreateFolderAsync(
|
|
string clusterId, string? parentFolderId, string name, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct))
|
|
return new UnsMutationResult(false, $"Cluster '{clusterId}' not found.");
|
|
|
|
if (parentFolderId is not null)
|
|
{
|
|
var parent = await db.RawFolders.AsNoTracking()
|
|
.FirstOrDefaultAsync(f => f.RawFolderId == parentFolderId, ct);
|
|
if (parent is null) return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' not found.");
|
|
if (parent.ClusterId != clusterId)
|
|
return new UnsMutationResult(false, $"Parent folder '{parentFolderId}' is in a different cluster.");
|
|
}
|
|
|
|
if (await db.RawFolders.AnyAsync(
|
|
f => f.ClusterId == clusterId && f.ParentRawFolderId == parentFolderId && f.Name == name, ct))
|
|
return new UnsMutationResult(false, $"A folder named '{name}' already exists here.");
|
|
|
|
var id = NewId("RF");
|
|
db.RawFolders.Add(new RawFolder
|
|
{
|
|
RawFolderId = id,
|
|
ClusterId = clusterId,
|
|
ParentRawFolderId = parentFolderId,
|
|
Name = name,
|
|
});
|
|
return await SaveCreateAsync(db, "folder", id, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawRenameResult> RenameFolderAsync(
|
|
string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(newName);
|
|
if (nameError is not null) return RawRenameResult.Failure(nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
|
|
if (entity is null) return RawRenameResult.Failure("Row no longer exists.");
|
|
|
|
if (await db.RawFolders.AnyAsync(
|
|
f => f.ClusterId == entity.ClusterId && f.ParentRawFolderId == entity.ParentRawFolderId
|
|
&& f.Name == newName && f.RawFolderId != rawFolderId, ct))
|
|
return RawRenameResult.Failure($"A folder named '{newName}' already exists here.");
|
|
|
|
var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathFolderAsync(db, rawFolderId, ct), ct);
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Name = newName;
|
|
return await SaveRenameAsync(db, warnings, "folder", ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> DeleteFolderAsync(
|
|
string rawFolderId, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.RawFolders.FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
|
|
if (entity is null) return new UnsMutationResult(true, null);
|
|
|
|
if (await db.RawFolders.AnyAsync(f => f.ParentRawFolderId == rawFolderId, ct))
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete folder '{entity.Name}': it still contains sub-folders — remove or move them first.");
|
|
|
|
if (await db.DriverInstances.AnyAsync(d => d.RawFolderId == rawFolderId, ct))
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete folder '{entity.Name}': it still contains driver instances — remove or move them first.");
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
db.RawFolders.Remove(entity);
|
|
return await SaveDeleteAsync(db, "folder", ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> MoveDriverToFolderAsync(
|
|
string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
|
if (driver is null) return new UnsMutationResult(false, "Row no longer exists.");
|
|
|
|
if (targetFolderId is not null)
|
|
{
|
|
var folder = await db.RawFolders.AsNoTracking()
|
|
.FirstOrDefaultAsync(f => f.RawFolderId == targetFolderId, ct);
|
|
if (folder is null) return new UnsMutationResult(false, $"Target folder '{targetFolderId}' not found.");
|
|
if (folder.ClusterId != driver.ClusterId)
|
|
return new UnsMutationResult(false,
|
|
$"Target folder '{folder.Name}' is in cluster '{folder.ClusterId}' but the driver is in cluster '{driver.ClusterId}'.");
|
|
}
|
|
|
|
if (await db.DriverInstances.AnyAsync(
|
|
d => d.ClusterId == driver.ClusterId && d.RawFolderId == targetFolderId
|
|
&& d.Name == driver.Name && d.DriverInstanceId != driverInstanceId, ct))
|
|
return new UnsMutationResult(false,
|
|
$"A driver named '{driver.Name}' already exists in the target location.");
|
|
|
|
db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
driver.RawFolderId = targetFolderId;
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return new UnsMutationResult(false, "Another user changed this driver while you were editing. Reload to see the latest values.");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Driver mutations
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> CreateDriverAsync(
|
|
string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
if (string.IsNullOrWhiteSpace(driverType))
|
|
return new UnsMutationResult(false, "Pick a driver type.");
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
if (!await db.ServerClusters.AnyAsync(c => c.ClusterId == clusterId, ct))
|
|
return new UnsMutationResult(false, $"Cluster '{clusterId}' not found.");
|
|
|
|
if (folderId is not null)
|
|
{
|
|
var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == folderId, ct);
|
|
if (folder is null) return new UnsMutationResult(false, $"Folder '{folderId}' not found.");
|
|
if (folder.ClusterId != clusterId)
|
|
return new UnsMutationResult(false, $"Folder '{folderId}' is in a different cluster.");
|
|
}
|
|
|
|
if (await db.DriverInstances.AnyAsync(
|
|
d => d.ClusterId == clusterId && d.RawFolderId == folderId && d.Name == name, ct))
|
|
return new UnsMutationResult(false, $"A driver named '{name}' already exists here.");
|
|
|
|
var driverId = NewId("DRV");
|
|
db.DriverInstances.Add(new DriverInstance
|
|
{
|
|
DriverInstanceId = driverId,
|
|
ClusterId = clusterId,
|
|
RawFolderId = folderId,
|
|
Name = name,
|
|
DriverType = driverType,
|
|
DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson,
|
|
});
|
|
|
|
// Auto-create the default device so a freshly-created driver has a child to show. Wave B (WP3)
|
|
// refines device/driver config authoring; here the default device carries an empty config.
|
|
db.Devices.Add(new Device
|
|
{
|
|
DeviceId = NewId("DEV"),
|
|
DriverInstanceId = driverId,
|
|
Name = "Device1",
|
|
DeviceConfig = "{}",
|
|
});
|
|
|
|
return await SaveCreateAsync(db, "driver", driverId, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawRenameResult> RenameDriverAsync(
|
|
string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(newName);
|
|
if (nameError is not null) return RawRenameResult.Failure(nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
|
if (entity is null) return RawRenameResult.Failure("Row no longer exists.");
|
|
|
|
if (await db.DriverInstances.AnyAsync(
|
|
d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId
|
|
&& d.Name == newName && d.DriverInstanceId != driverInstanceId, ct))
|
|
return RawRenameResult.Failure($"A driver named '{newName}' already exists here.");
|
|
|
|
var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDriverAsync(db, driverInstanceId, ct), ct);
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Name = newName;
|
|
return await SaveRenameAsync(db, warnings, "driver", ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> SetDriverEnabledAsync(
|
|
string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
|
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Enabled = enabled;
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return new UnsMutationResult(false, "Another user changed this driver while you were editing.");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> DeleteDriverAsync(
|
|
string driverInstanceId, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var driver = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
|
if (driver is null) return new UnsMutationResult(true, null);
|
|
|
|
var devices = await db.Devices.Where(dev => dev.DriverInstanceId == driverInstanceId).ToListAsync(ct);
|
|
|
|
// A driver always has ≥1 device, so "has devices" cannot be the block. Instead: cascade the
|
|
// (empty) auto-devices, but refuse — naming the offending device — when any device still holds
|
|
// tag groups or tags, so tag authoring is never silently discarded.
|
|
foreach (var dev in devices)
|
|
{
|
|
if (await db.TagGroups.AnyAsync(g => g.DeviceId == dev.DeviceId, ct)
|
|
|| await db.Tags.AnyAsync(t => t.DeviceId == dev.DeviceId, ct))
|
|
{
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete driver '{driver.Name}': device '{dev.Name}' still contains tag groups or tags — remove them first.");
|
|
}
|
|
}
|
|
|
|
db.Devices.RemoveRange(devices);
|
|
db.Entry(driver).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
db.DriverInstances.Remove(driver);
|
|
return await SaveDeleteAsync(db, "driver", ct);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Device mutations
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> CreateDeviceAsync(
|
|
string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
if (!await db.DriverInstances.AnyAsync(d => d.DriverInstanceId == driverInstanceId, ct))
|
|
return new UnsMutationResult(false, $"Driver '{driverInstanceId}' not found.");
|
|
|
|
if (await db.Devices.AnyAsync(dev => dev.DriverInstanceId == driverInstanceId && dev.Name == name, ct))
|
|
return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");
|
|
|
|
var id = NewId("DEV");
|
|
db.Devices.Add(new Device
|
|
{
|
|
DeviceId = id,
|
|
DriverInstanceId = driverInstanceId,
|
|
Name = name,
|
|
DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson,
|
|
});
|
|
return await SaveCreateAsync(db, "device", id, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawRenameResult> RenameDeviceAsync(
|
|
string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(newName);
|
|
if (nameError is not null) return RawRenameResult.Failure(nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
|
|
if (entity is null) return RawRenameResult.Failure("Row no longer exists.");
|
|
|
|
if (await db.Devices.AnyAsync(
|
|
dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == newName && dev.DeviceId != deviceId, ct))
|
|
return RawRenameResult.Failure($"A device named '{newName}' already exists on this driver.");
|
|
|
|
var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathDeviceAsync(db, deviceId, ct), ct);
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Name = newName;
|
|
return await SaveRenameAsync(db, warnings, "device", ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> DeleteDeviceAsync(
|
|
string deviceId, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
|
|
if (entity is null) return new UnsMutationResult(true, null);
|
|
|
|
if (await db.TagGroups.AnyAsync(g => g.DeviceId == deviceId, ct))
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete device '{entity.Name}': it still contains tag groups — remove them first.");
|
|
|
|
if (await db.Tags.AnyAsync(t => t.DeviceId == deviceId, ct))
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete device '{entity.Name}': it still contains tags — remove them first.");
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
db.Devices.Remove(entity);
|
|
return await SaveDeleteAsync(db, "device", ct);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// TagGroup mutations
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> CreateTagGroupAsync(
|
|
string deviceId, string? parentGroupId, string name, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
|
|
return new UnsMutationResult(false, $"Device '{deviceId}' not found.");
|
|
|
|
if (parentGroupId is not null)
|
|
{
|
|
var parent = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == parentGroupId, ct);
|
|
if (parent is null) return new UnsMutationResult(false, $"Parent group '{parentGroupId}' not found.");
|
|
if (parent.DeviceId != deviceId)
|
|
return new UnsMutationResult(false, $"Parent group '{parentGroupId}' is on a different device.");
|
|
}
|
|
|
|
if (await db.TagGroups.AnyAsync(
|
|
g => g.DeviceId == deviceId && g.ParentTagGroupId == parentGroupId && g.Name == name, ct))
|
|
return new UnsMutationResult(false, $"A tag group named '{name}' already exists here.");
|
|
|
|
var id = NewId("TG");
|
|
db.TagGroups.Add(new TagGroup
|
|
{
|
|
TagGroupId = id,
|
|
DeviceId = deviceId,
|
|
ParentTagGroupId = parentGroupId,
|
|
Name = name,
|
|
});
|
|
return await SaveCreateAsync(db, "tag group", id, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawRenameResult> RenameTagGroupAsync(
|
|
string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(newName);
|
|
if (nameError is not null) return RawRenameResult.Failure(nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
|
|
if (entity is null) return RawRenameResult.Failure("Row no longer exists.");
|
|
|
|
if (await db.TagGroups.AnyAsync(
|
|
g => g.DeviceId == entity.DeviceId && g.ParentTagGroupId == entity.ParentTagGroupId
|
|
&& g.Name == newName && g.TagGroupId != tagGroupId, ct))
|
|
return RawRenameResult.Failure($"A tag group named '{newName}' already exists here.");
|
|
|
|
var warnings = await BuildRenameWarningsAsync(db, await CollectTagsBeneathGroupAsync(db, tagGroupId, ct), ct);
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Name = newName;
|
|
return await SaveRenameAsync(db, warnings, "tag group", ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> DeleteTagGroupAsync(
|
|
string tagGroupId, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.TagGroups.FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
|
|
if (entity is null) return new UnsMutationResult(true, null);
|
|
|
|
if (await db.TagGroups.AnyAsync(g => g.ParentTagGroupId == tagGroupId, ct))
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete tag group '{entity.Name}': it still contains sub-groups — remove them first.");
|
|
|
|
if (await db.Tags.AnyAsync(t => t.TagGroupId == tagGroupId, ct))
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete tag group '{entity.Name}': it still contains tags — remove them first.");
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
db.TagGroups.Remove(entity);
|
|
return await SaveDeleteAsync(db, "tag group", ct);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Tag delete + edit projection (create/update are WP4)
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <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);
|
|
|
|
// Blocked while any UNS equipment references this raw tag — name the referencing equipment(s).
|
|
var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
|
|
.Where(r => r.TagId == tagId)
|
|
.Select(r => r.EquipmentId)
|
|
.Distinct()
|
|
.ToListAsync(ct);
|
|
|
|
if (referencingEquipmentIds.Count > 0)
|
|
{
|
|
var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct);
|
|
return new UnsMutationResult(false,
|
|
$"Cannot delete tag '{entity.Name}': it is referenced by equipment {display} — remove the UNS reference(s) first.");
|
|
}
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
db.Tags.Remove(entity);
|
|
return await SaveDeleteAsync(db, "tag", ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawTagEditDto?> LoadTagForEditAsync(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 RawTagEditDto(
|
|
t.TagId,
|
|
t.DeviceId,
|
|
t.TagGroupId,
|
|
t.Name,
|
|
t.DataType,
|
|
t.AccessLevel,
|
|
t.WriteIdempotent,
|
|
t.PollGroupId,
|
|
t.TagConfig,
|
|
t.RowVersion))
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Tag create / update (WP4) + CSV import (WP5)
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> CreateTagAsync(
|
|
string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(input.Name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
|
|
var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig);
|
|
if (jsonError is not null) return new UnsMutationResult(false, jsonError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
|
|
return new UnsMutationResult(false, $"Device '{deviceId}' not found.");
|
|
|
|
if (tagGroupId is not null)
|
|
{
|
|
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
|
|
if (group is null) return new UnsMutationResult(false, $"Tag group '{tagGroupId}' not found.");
|
|
if (group.DeviceId != deviceId)
|
|
return new UnsMutationResult(false, $"Tag group '{tagGroupId}' is on a different device.");
|
|
}
|
|
|
|
if (await db.Tags.AnyAsync(
|
|
t => t.DeviceId == deviceId && t.TagGroupId == tagGroupId && t.Name == input.Name, ct))
|
|
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
|
|
|
|
var id = NewId("TAG");
|
|
db.Tags.Add(new Tag
|
|
{
|
|
TagId = id,
|
|
DeviceId = deviceId,
|
|
TagGroupId = tagGroupId,
|
|
Name = input.Name,
|
|
DataType = input.DataType,
|
|
AccessLevel = input.AccessLevel,
|
|
WriteIdempotent = input.WriteIdempotent,
|
|
PollGroupId = NullIfBlank(input.PollGroupId),
|
|
TagConfig = tagConfig,
|
|
});
|
|
return await SaveCreateAsync(db, "tag", id, ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> UpdateTagAsync(
|
|
string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(input.Name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
|
|
var jsonError = TryNormalizeTagConfig(input.TagConfig, out var tagConfig);
|
|
if (jsonError is not null) return new UnsMutationResult(false, jsonError);
|
|
|
|
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.");
|
|
|
|
// Re-validate name uniqueness on the tag's EXISTING (DeviceId, TagGroupId), excluding self. An
|
|
// identity move (changing device/group) is not this method's job — those fields are preserved.
|
|
if (await db.Tags.AnyAsync(
|
|
t => t.DeviceId == entity.DeviceId && t.TagGroupId == entity.TagGroupId
|
|
&& t.Name == input.Name && t.TagId != tagId, ct))
|
|
return new UnsMutationResult(false, $"A tag named '{input.Name}' already exists here.");
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Name = input.Name;
|
|
entity.DataType = input.DataType;
|
|
entity.AccessLevel = input.AccessLevel;
|
|
entity.WriteIdempotent = input.WriteIdempotent;
|
|
entity.PollGroupId = NullIfBlank(input.PollGroupId);
|
|
entity.TagConfig = 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. Reload to see the latest values.");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawTagImportResult> ImportTagsAsync(
|
|
string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(rows);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
if (!await db.Devices.AnyAsync(dev => dev.DeviceId == deviceId, ct))
|
|
return new RawTagImportResult(0, new[] { $"Device '{deviceId}' not found." });
|
|
|
|
// Existing groups on the device: a (parent-group-id | null, name) → group-id lookup we extend with
|
|
// pending (auto-created) groups so a repeated path within the batch resolves to one group.
|
|
var groupLookup = (await db.TagGroups.AsNoTracking()
|
|
.Where(g => g.DeviceId == deviceId)
|
|
.Select(g => new { g.TagGroupId, g.ParentTagGroupId, g.Name })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(g => (g.ParentTagGroupId, g.Name), g => g.TagGroupId, GroupKeyComparer.Instance);
|
|
|
|
// Existing tag identities on the device: (group-id | null, name). Extended with pending inserts so
|
|
// an intra-batch duplicate is caught too.
|
|
var takenNames = (await db.Tags.AsNoTracking()
|
|
.Where(t => t.DeviceId == deviceId)
|
|
.Select(t => new { t.TagGroupId, t.Name })
|
|
.ToListAsync(ct))
|
|
.Select(t => (t.TagGroupId, t.Name))
|
|
.ToHashSet(TagKeyComparer.Instance);
|
|
|
|
var pendingGroups = new List<TagGroup>();
|
|
var pendingTags = new List<Tag>();
|
|
var errors = new List<string>();
|
|
|
|
for (var i = 0; i < rows.Count; i++)
|
|
{
|
|
var row = rows[i];
|
|
var rowLabel = $"Row {i + 1}";
|
|
|
|
// Resolve (auto-creating) the tag-group path under the device.
|
|
string? groupId;
|
|
var groupError = ResolvePendingGroupPath(deviceId, row.TagGroupPath, groupLookup, pendingGroups, out groupId);
|
|
if (groupError is not null)
|
|
{
|
|
errors.Add($"{rowLabel}: {groupError}");
|
|
continue;
|
|
}
|
|
|
|
var nameError = RawPaths.ValidateSegment(row.Tag.Name);
|
|
if (nameError is not null)
|
|
{
|
|
errors.Add($"{rowLabel}: {nameError}");
|
|
continue;
|
|
}
|
|
|
|
var jsonError = TryNormalizeTagConfig(row.Tag.TagConfig, out var tagConfig);
|
|
if (jsonError is not null)
|
|
{
|
|
errors.Add($"{rowLabel}: {jsonError}");
|
|
continue;
|
|
}
|
|
|
|
if (!takenNames.Add((groupId, row.Tag.Name)))
|
|
{
|
|
errors.Add($"{rowLabel}: a tag named '{row.Tag.Name}' already exists at that path.");
|
|
continue;
|
|
}
|
|
|
|
pendingTags.Add(new Tag
|
|
{
|
|
TagId = NewId("TAG"),
|
|
DeviceId = deviceId,
|
|
TagGroupId = groupId,
|
|
Name = row.Tag.Name,
|
|
DataType = row.Tag.DataType,
|
|
AccessLevel = row.Tag.AccessLevel,
|
|
WriteIdempotent = row.Tag.WriteIdempotent,
|
|
PollGroupId = NullIfBlank(row.Tag.PollGroupId),
|
|
TagConfig = tagConfig,
|
|
});
|
|
}
|
|
|
|
// All-or-nothing: any row error inserts nothing (the review-grid pre-validates; this is the backstop).
|
|
if (errors.Count > 0) return new RawTagImportResult(0, errors);
|
|
|
|
db.TagGroups.AddRange(pendingGroups);
|
|
db.Tags.AddRange(pendingTags);
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new RawTagImportResult(pendingTags.Count, Array.Empty<string>());
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
// A concurrent create slipped a same-path sibling past the pre-check (the DB filtered-unique
|
|
// index is the backstop). Surface an operator-friendly all-or-nothing failure.
|
|
return new RawTagImportResult(0, new[] { "Import failed — a tag or group at one of these paths was created concurrently. Reload and retry." });
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Resolves a <c>/</c>-separated tag-group path under a device to a group id, auto-creating any
|
|
/// missing nested <see cref="TagGroup"/> (added to <paramref name="pendingGroups"/> and
|
|
/// <paramref name="groupLookup"/> so a repeated path reuses the same group). Returns null on success
|
|
/// with <paramref name="groupId"/> set (null = the device root for a blank path), or a friendly error.
|
|
/// </summary>
|
|
private static string? ResolvePendingGroupPath(
|
|
string deviceId, string? groupPath,
|
|
Dictionary<(string? Parent, string Name), string> groupLookup,
|
|
List<TagGroup> pendingGroups,
|
|
out string? groupId)
|
|
{
|
|
groupId = null;
|
|
if (string.IsNullOrWhiteSpace(groupPath)) return null;
|
|
|
|
string? parent = null;
|
|
foreach (var segment in groupPath.Split(RawPaths.Separator))
|
|
{
|
|
var segError = RawPaths.ValidateSegment(segment);
|
|
if (segError is not null) return $"tag-group path segment '{segment}': {segError}";
|
|
|
|
var key = (parent, segment);
|
|
if (!groupLookup.TryGetValue(key, out var id))
|
|
{
|
|
id = NewId("TG");
|
|
pendingGroups.Add(new TagGroup
|
|
{
|
|
TagGroupId = id,
|
|
DeviceId = deviceId,
|
|
ParentTagGroupId = parent,
|
|
Name = segment,
|
|
});
|
|
groupLookup[key] = id;
|
|
}
|
|
|
|
parent = id;
|
|
}
|
|
|
|
groupId = parent;
|
|
return null;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Driver config (WP3 "Configure driver")
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawDriverEditDto?> LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
return await db.DriverInstances.AsNoTracking()
|
|
.Where(d => d.DriverInstanceId == driverInstanceId)
|
|
.Select(d => new RawDriverEditDto(
|
|
d.DriverInstanceId,
|
|
d.Name,
|
|
d.DriverType,
|
|
d.DriverConfig,
|
|
d.ResilienceConfig,
|
|
d.RowVersion))
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> UpdateDriverAsync(
|
|
string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.DriverInstances.FirstOrDefaultAsync(d => d.DriverInstanceId == driverInstanceId, ct);
|
|
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
|
|
|
// Sibling uniqueness in the same scope RenameDriverAsync uses (cluster + folder).
|
|
if (await db.DriverInstances.AnyAsync(
|
|
d => d.ClusterId == entity.ClusterId && d.RawFolderId == entity.RawFolderId
|
|
&& d.Name == name && d.DriverInstanceId != driverInstanceId, ct))
|
|
return new UnsMutationResult(false, $"A driver named '{name}' already exists here.");
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Name = name;
|
|
entity.DriverConfig = string.IsNullOrWhiteSpace(driverConfigJson) ? "{}" : driverConfigJson;
|
|
entity.ResilienceConfig = NullIfBlank(resilienceConfig); // DriverType is immutable — not touched.
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return new UnsMutationResult(false, "Another user changed this driver while you were editing. Reload to see the latest values.");
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Device config (WP3 "Configure device") + merged probe config (Test connect)
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <inheritdoc />
|
|
public async Task<RawDeviceEditDto?> LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
// DriverType is joined in from the parent driver — the device forms dispatch the typed editor on it.
|
|
return await db.Devices.AsNoTracking()
|
|
.Where(dev => dev.DeviceId == deviceId)
|
|
.Join(db.DriverInstances.AsNoTracking(),
|
|
dev => dev.DriverInstanceId,
|
|
drv => drv.DriverInstanceId,
|
|
(dev, drv) => new RawDeviceEditDto(
|
|
dev.DeviceId,
|
|
dev.DriverInstanceId,
|
|
dev.Name,
|
|
dev.DeviceConfig,
|
|
dev.Enabled,
|
|
drv.DriverType,
|
|
dev.RowVersion))
|
|
.FirstOrDefaultAsync(ct);
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<UnsMutationResult> UpdateDeviceAsync(
|
|
string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default)
|
|
{
|
|
var nameError = RawPaths.ValidateSegment(name);
|
|
if (nameError is not null) return new UnsMutationResult(false, nameError);
|
|
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var entity = await db.Devices.FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
|
|
if (entity is null) return new UnsMutationResult(false, "Row no longer exists.");
|
|
|
|
if (await db.Devices.AnyAsync(
|
|
dev => dev.DriverInstanceId == entity.DriverInstanceId && dev.Name == name && dev.DeviceId != deviceId, ct))
|
|
return new UnsMutationResult(false, $"A device named '{name}' already exists on this driver.");
|
|
|
|
db.Entry(entity).Property(e => e.RowVersion).OriginalValue = rowVersion;
|
|
entity.Name = name;
|
|
entity.DeviceConfig = string.IsNullOrWhiteSpace(deviceConfigJson) ? "{}" : deviceConfigJson;
|
|
entity.Enabled = enabled;
|
|
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return new UnsMutationResult(false, "Another user changed this device while you were editing. Reload to see the latest values.");
|
|
}
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(
|
|
string deviceId, CancellationToken ct = default)
|
|
{
|
|
await using var db = await dbFactory.CreateDbContextAsync(ct);
|
|
|
|
var device = await db.Devices.AsNoTracking().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId, ct);
|
|
if (device is null) return null;
|
|
|
|
var driver = await db.DriverInstances.AsNoTracking()
|
|
.FirstOrDefaultAsync(d => d.DriverInstanceId == device.DriverInstanceId, ct);
|
|
if (driver is null) return null;
|
|
|
|
// Endpoint now lives in DeviceConfig — merge it up (single device ⇒ DeviceConfig keys win) into the
|
|
// one config blob the IDriverProbe path binds from. No tags feed a connect probe.
|
|
var merged = DriverDeviceConfigMerger.Merge(
|
|
driver.DriverConfig,
|
|
new[] { new DriverDeviceConfigMerger.DeviceRow(device.Name, device.DeviceConfig) },
|
|
Array.Empty<RawTagEntry>());
|
|
|
|
return (driver.DriverType, merged);
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------------------------
|
|
// Shared helpers
|
|
// ---------------------------------------------------------------------------------------------
|
|
|
|
/// <summary>Generates a system id of the form <c>{prefix}-{12 hex}</c> (≤ the 64-char id limit).</summary>
|
|
private static string NewId(string prefix) => $"{prefix}-{Guid.NewGuid().ToString("N")[..12]}";
|
|
|
|
/// <summary>Collapses a whitespace-only optional string to <c>null</c>.</summary>
|
|
private static string? NullIfBlank(string? value) => string.IsNullOrWhiteSpace(value) ? null : value;
|
|
|
|
/// <summary>
|
|
/// Validates a <c>TagConfig</c> blob for JSON well-formedness (blank ⇒ <c>{}</c>). Returns a friendly
|
|
/// error when the blob is not parseable JSON; otherwise null with <paramref name="normalized"/> set.
|
|
/// </summary>
|
|
private static string? TryNormalizeTagConfig(string? tagConfig, out string normalized)
|
|
{
|
|
normalized = string.IsNullOrWhiteSpace(tagConfig) ? "{}" : tagConfig;
|
|
try
|
|
{
|
|
using var _ = JsonDocument.Parse(normalized);
|
|
return null;
|
|
}
|
|
catch (JsonException)
|
|
{
|
|
return "TagConfig is not well-formed JSON.";
|
|
}
|
|
}
|
|
|
|
/// <summary>Ordinal (case-sensitive) equality for a nullable-parent + name tag-group key.</summary>
|
|
private sealed class GroupKeyComparer : IEqualityComparer<(string? Parent, string Name)>
|
|
{
|
|
public static readonly GroupKeyComparer Instance = new();
|
|
|
|
public bool Equals((string? Parent, string Name) x, (string? Parent, string Name) y) =>
|
|
string.Equals(x.Parent, y.Parent, StringComparison.Ordinal)
|
|
&& string.Equals(x.Name, y.Name, StringComparison.Ordinal);
|
|
|
|
public int GetHashCode((string? Parent, string Name) obj) =>
|
|
HashCode.Combine(
|
|
obj.Parent is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.Parent),
|
|
StringComparer.Ordinal.GetHashCode(obj.Name));
|
|
}
|
|
|
|
/// <summary>Ordinal (case-sensitive) equality for a nullable-group + name tag identity key.</summary>
|
|
private sealed class TagKeyComparer : IEqualityComparer<(string? GroupId, string Name)>
|
|
{
|
|
public static readonly TagKeyComparer Instance = new();
|
|
|
|
public bool Equals((string? GroupId, string Name) x, (string? GroupId, string Name) y) =>
|
|
string.Equals(x.GroupId, y.GroupId, StringComparison.Ordinal)
|
|
&& string.Equals(x.Name, y.Name, StringComparison.Ordinal);
|
|
|
|
public int GetHashCode((string? GroupId, string Name) obj) =>
|
|
HashCode.Combine(
|
|
obj.GroupId is null ? 0 : StringComparer.Ordinal.GetHashCode(obj.GroupId),
|
|
StringComparer.Ordinal.GetHashCode(obj.Name));
|
|
}
|
|
|
|
/// <summary>Commits a rename, translating a concurrency clash into a friendly failure.</summary>
|
|
private static async Task<RawRenameResult> SaveRenameAsync(
|
|
OtOpcUaConfigDbContext db, IReadOnlyList<string> warnings, string noun, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return RawRenameResult.Success(warnings);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return RawRenameResult.Failure($"Another user changed this {noun} while you were editing. Reload to see the latest values.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Commits a delete, translating a concurrency clash / stray FK block into a friendly failure.</summary>
|
|
private static async Task<UnsMutationResult> SaveDeleteAsync(
|
|
OtOpcUaConfigDbContext db, string noun, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null);
|
|
}
|
|
catch (DbUpdateConcurrencyException)
|
|
{
|
|
return new UnsMutationResult(false, $"Another user changed this {noun} while you were viewing it.");
|
|
}
|
|
catch (Exception)
|
|
{
|
|
// Defensive net for a real SQL Server where a child slipped past the pre-check (race).
|
|
// Don't surface the raw EF/SQL text (leaks schema detail); give operator-friendly guidance.
|
|
return new UnsMutationResult(false,
|
|
$"Delete failed — a related record still references this {noun}. Reload and retry.");
|
|
}
|
|
}
|
|
|
|
/// <summary>Commits a create, translating a lost sibling-name race (filtered-unique-index clash) into a friendly failure.</summary>
|
|
private static async Task<UnsMutationResult> SaveCreateAsync(
|
|
OtOpcUaConfigDbContext db, string noun, string createdId, CancellationToken ct)
|
|
{
|
|
try
|
|
{
|
|
await db.SaveChangesAsync(ct);
|
|
return new UnsMutationResult(true, null, createdId);
|
|
}
|
|
catch (DbUpdateException)
|
|
{
|
|
// The name-uniqueness pre-check narrows but doesn't close the race against a concurrent
|
|
// create of the same-name sibling; the DB's filtered-unique index is the backstop.
|
|
return new UnsMutationResult(false,
|
|
$"A {noun} with that name already exists here (created concurrently). Reload and retry.");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Builds the non-blocking rename warnings answerable from this batch's schema: historized tags and
|
|
/// equipment-referenced tags beneath the renamed node (whose RawPath — and thus historian tagname /
|
|
/// UNS projection path — changes with the rename). Batch 3 appends the script-substring scan here.
|
|
/// </summary>
|
|
private static async Task<IReadOnlyList<string>> BuildRenameWarningsAsync(
|
|
OtOpcUaConfigDbContext db, IReadOnlyList<(string TagId, string TagConfig)> tags, CancellationToken ct)
|
|
{
|
|
var warnings = new List<string>();
|
|
if (tags.Count == 0) return warnings;
|
|
|
|
var historized = tags.Count(t => TagConfigIntent.Parse(t.TagConfig).IsHistorized);
|
|
if (historized > 0)
|
|
{
|
|
warnings.Add(historized == 1
|
|
? "1 historized tag beneath this node will change its RawPath (historian tagname). Update the historian if it keys on the old path."
|
|
: $"{historized} historized tags beneath this node will change their RawPath (historian tagname). Update the historian if it keys on the old paths.");
|
|
}
|
|
|
|
var tagIds = tags.Select(t => t.TagId).ToList();
|
|
var referencingEquipmentIds = await db.UnsTagReferences.AsNoTracking()
|
|
.Where(r => tagIds.Contains(r.TagId))
|
|
.Select(r => r.EquipmentId)
|
|
.Distinct()
|
|
.ToListAsync(ct);
|
|
|
|
if (referencingEquipmentIds.Count > 0)
|
|
{
|
|
var display = await DescribeEquipmentAsync(db, referencingEquipmentIds, ct);
|
|
warnings.Add($"Tags beneath this node are referenced by equipment {display}; the UNS projection path will change with the rename.");
|
|
}
|
|
|
|
// Batch 3 extension point: scan scripts for substrings of the affected tags' RawPaths and append
|
|
// a "N script(s) reference tags beneath this node" warning to this same list.
|
|
|
|
return warnings;
|
|
}
|
|
|
|
/// <summary>Renders a friendly "<c>Name (Id)</c>, …" list for the referencing equipment ids.</summary>
|
|
private static async Task<string> DescribeEquipmentAsync(
|
|
OtOpcUaConfigDbContext db, IReadOnlyList<string> equipmentIds, CancellationToken ct)
|
|
{
|
|
var names = (await db.Equipment.AsNoTracking()
|
|
.Where(e => equipmentIds.Contains(e.EquipmentId))
|
|
.Select(e => new { e.EquipmentId, e.Name })
|
|
.ToListAsync(ct))
|
|
.ToDictionary(x => x.EquipmentId, x => x.Name, StringComparer.Ordinal);
|
|
|
|
return string.Join(", ", equipmentIds
|
|
.OrderBy(id => id, StringComparer.Ordinal)
|
|
.Select(id => names.TryGetValue(id, out var n) ? $"'{n}' ({id})" : $"'{id}'"));
|
|
}
|
|
|
|
// --- Subtree tag collectors (walk the in-DB subtree for rename impact) ---
|
|
|
|
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathFolderAsync(
|
|
OtOpcUaConfigDbContext db, string rawFolderId, CancellationToken ct)
|
|
{
|
|
var folder = await db.RawFolders.AsNoTracking().FirstOrDefaultAsync(f => f.RawFolderId == rawFolderId, ct);
|
|
if (folder is null) return Array.Empty<(string, string)>();
|
|
|
|
// All folders in the cluster, walked in-memory to the descendant set (incl. self) via ParentRawFolderId.
|
|
var clusterFolders = await db.RawFolders.AsNoTracking()
|
|
.Where(f => f.ClusterId == folder.ClusterId)
|
|
.Select(f => new { f.RawFolderId, f.ParentRawFolderId })
|
|
.ToListAsync(ct);
|
|
|
|
var childrenByParent = clusterFolders
|
|
.Where(f => f.ParentRawFolderId is not null)
|
|
.GroupBy(f => f.ParentRawFolderId!, StringComparer.Ordinal)
|
|
.ToDictionary(g => g.Key, g => g.Select(x => x.RawFolderId).ToList(), StringComparer.Ordinal);
|
|
|
|
var folderIds = DescendantsInclusive(rawFolderId, childrenByParent);
|
|
|
|
var driverIds = await db.DriverInstances.AsNoTracking()
|
|
.Where(d => d.RawFolderId != null && folderIds.Contains(d.RawFolderId))
|
|
.Select(d => d.DriverInstanceId)
|
|
.ToListAsync(ct);
|
|
if (driverIds.Count == 0) return Array.Empty<(string, string)>();
|
|
|
|
var deviceIds = await db.Devices.AsNoTracking()
|
|
.Where(dev => driverIds.Contains(dev.DriverInstanceId))
|
|
.Select(dev => dev.DeviceId)
|
|
.ToListAsync(ct);
|
|
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
|
|
|
|
return await TagsForDevicesAsync(db, deviceIds, ct);
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDriverAsync(
|
|
OtOpcUaConfigDbContext db, string driverInstanceId, CancellationToken ct)
|
|
{
|
|
var deviceIds = await db.Devices.AsNoTracking()
|
|
.Where(dev => dev.DriverInstanceId == driverInstanceId)
|
|
.Select(dev => dev.DeviceId)
|
|
.ToListAsync(ct);
|
|
if (deviceIds.Count == 0) return Array.Empty<(string, string)>();
|
|
return await TagsForDevicesAsync(db, deviceIds, ct);
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathDeviceAsync(
|
|
OtOpcUaConfigDbContext db, string deviceId, CancellationToken ct)
|
|
{
|
|
return (await db.Tags.AsNoTracking()
|
|
.Where(t => t.DeviceId == deviceId)
|
|
.Select(t => new { t.TagId, t.TagConfig })
|
|
.ToListAsync(ct))
|
|
.Select(t => (t.TagId, t.TagConfig))
|
|
.ToList();
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> CollectTagsBeneathGroupAsync(
|
|
OtOpcUaConfigDbContext db, string tagGroupId, CancellationToken ct)
|
|
{
|
|
var group = await db.TagGroups.AsNoTracking().FirstOrDefaultAsync(g => g.TagGroupId == tagGroupId, ct);
|
|
if (group is null) return Array.Empty<(string, string)>();
|
|
|
|
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 = DescendantsInclusive(tagGroupId, childrenByParent);
|
|
|
|
return (await db.Tags.AsNoTracking()
|
|
.Where(t => t.TagGroupId != null && groupIds.Contains(t.TagGroupId))
|
|
.Select(t => new { t.TagId, t.TagConfig })
|
|
.ToListAsync(ct))
|
|
.Select(t => (t.TagId, t.TagConfig))
|
|
.ToList();
|
|
}
|
|
|
|
private static async Task<IReadOnlyList<(string TagId, string TagConfig)>> TagsForDevicesAsync(
|
|
OtOpcUaConfigDbContext db, IReadOnlyList<string> deviceIds, CancellationToken ct)
|
|
{
|
|
return (await db.Tags.AsNoTracking()
|
|
.Where(t => deviceIds.Contains(t.DeviceId))
|
|
.Select(t => new { t.TagId, t.TagConfig })
|
|
.ToListAsync(ct))
|
|
.Select(t => (t.TagId, t.TagConfig))
|
|
.ToList();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Returns <paramref name="rootId"/> plus all its transitive descendants, using a
|
|
/// parent→children adjacency map. Iterative (stack-based) so deep trees can't blow the call stack.
|
|
/// </summary>
|
|
private static HashSet<string> DescendantsInclusive(
|
|
string rootId, IReadOnlyDictionary<string, List<string>> childrenByParent)
|
|
{
|
|
var result = new HashSet<string>(StringComparer.Ordinal);
|
|
var stack = new Stack<string>();
|
|
stack.Push(rootId);
|
|
while (stack.Count > 0)
|
|
{
|
|
var id = stack.Pop();
|
|
if (!result.Add(id)) continue; // guard against cycles / double-visits
|
|
if (childrenByParent.TryGetValue(id, out var kids))
|
|
{
|
|
foreach (var kid in kids) stack.Push(kid);
|
|
}
|
|
}
|
|
return result;
|
|
}
|
|
}
|