feat(adminui): B2 Wave-B service prelude — tag CRUD/import + driver/device config + merged probe

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:58:13 -04:00
parent 928e06dd01
commit 76cffe1f49
4 changed files with 1033 additions and 0 deletions
@@ -1,7 +1,9 @@
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;
@@ -801,6 +803,371 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
.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
// ---------------------------------------------------------------------------------------------
@@ -808,6 +1175,57 @@ public sealed class RawTreeService(IDbContextFactory<OtOpcUaConfigDbContext> dbF
/// <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)