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
@@ -29,6 +29,48 @@ public readonly record struct RawTagEditDto(
string TagConfig,
byte[] RowVersion);
/// <summary>
/// Detached, editable projection of a <c>DriverInstance</c> — the read side of the Wave-B (WP3)
/// "Configure driver" modal. <c>DriverType</c> is included so the modal can dispatch to the correct
/// typed driver-config editor; it is immutable, so <see cref="IRawTreeService.UpdateDriverAsync"/> does
/// not change it.
/// </summary>
/// <param name="DriverInstanceId">The driver instance's stable logical id.</param>
/// <param name="Name">The driver instance name (a RawPath segment).</param>
/// <param name="DriverType">The immutable driver-type string (see <c>DriverTypeNames</c>).</param>
/// <param name="DriverConfig">The schemaless per-driver-type <c>DriverConfig</c> JSON blob.</param>
/// <param name="ResilienceConfig">Optional per-instance resilience-pipeline overrides JSON, or null.</param>
/// <param name="RowVersion">The optimistic-concurrency token.</param>
public sealed record RawDriverEditDto(
string DriverInstanceId,
string Name,
string DriverType,
string DriverConfig,
string? ResilienceConfig,
byte[] RowVersion);
/// <summary>
/// Detached, editable projection of a <c>Device</c> — the read side of the Wave-B (WP3) "Configure
/// device" modal. <c>DriverType</c> is joined in from the parent <c>DriverInstance</c> so the modal can
/// dispatch to the correct typed device-config editor (endpoint/connection settings now live in
/// <c>DeviceConfig</c>).
/// </summary>
/// <param name="DeviceId">The device's stable logical id.</param>
/// <param name="DriverInstanceId">The owning driver instance's logical id.</param>
/// <param name="Name">The device name (a RawPath segment).</param>
/// <param name="DeviceConfig">The schemaless per-driver-type <c>DeviceConfig</c> JSON blob (host/endpoint + per-device settings).</param>
/// <param name="Enabled">Whether the device is enabled.</param>
/// <param name="DriverType">The parent driver's type string, joined in for editor dispatch.</param>
/// <param name="RowVersion">The optimistic-concurrency token.</param>
public sealed record RawDeviceEditDto(
string DeviceId,
string DriverInstanceId,
string Name,
string DeviceConfig,
bool Enabled,
string DriverType,
byte[] RowVersion);
/// <summary>
/// Loads and mutates the v3 Raw project tree (<c>/raw</c>) — the cluster-rooted
/// Enterprise → Cluster → Folder → Driver → Device → TagGroup → Tag hierarchy — from the config
@@ -251,4 +293,105 @@ public interface IRawTreeService
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The tag's editable projection, or null when the tag does not exist.</returns>
Task<RawTagEditDto?> LoadTagForEditAsync(string tagId, CancellationToken ct = default);
/// <summary>
/// Creates a raw <c>Tag</c> under a device (<paramref name="tagGroupId"/> null) or a tag group. The
/// name is validated as a RawPath segment and must be unique among its siblings on the same
/// <c>(DeviceId, TagGroupId)</c>; the <see cref="RawTagInput.TagConfig"/> is validated for JSON
/// well-formedness. On success the result's <c>CreatedId</c> carries the new tag's logical id.
/// </summary>
/// <param name="deviceId">The owning device's logical id.</param>
/// <param name="tagGroupId">The containing tag group's logical id, or null for a device-root tag.</param>
/// <param name="input">The tag's editable fields (identity is the RawPath — no TagId here).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>TagId</c> on success.</returns>
Task<UnsMutationResult> CreateTagAsync(string deviceId, string? tagGroupId, RawTagInput input, CancellationToken ct = default);
/// <summary>
/// Updates a raw <c>Tag</c>'s editable fields. The new name is re-validated as a RawPath segment and
/// sibling-unique on the tag's existing <c>(DeviceId, TagGroupId)</c> excluding itself; the
/// <see cref="RawTagInput.TagConfig"/> is validated for JSON well-formedness. Last-write-wins on
/// <paramref name="rowVersion"/>. The tag's <c>DeviceId</c>/<c>TagGroupId</c> (its identity location)
/// are preserved — moving a tag is not this method's job.
/// </summary>
/// <param name="tagId">The tag's logical id.</param>
/// <param name="input">The tag's edited fields.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded tag.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The update outcome.</returns>
Task<UnsMutationResult> UpdateTagAsync(string tagId, RawTagInput input, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Commits a batch of raw tags under a device — the WP5 CSV-import path. Each row's
/// <c>TagGroupPath</c> is a <c>/</c>-separated tag-group path under the device whose segments are
/// auto-created as nested <c>TagGroup</c>s (reusing existing ones). Per-row validation errors (bad
/// name segment, malformed <c>TagConfig</c> JSON, a duplicate name within the device+group) are
/// collected, not thrown. The import is <b>all-or-nothing</b>: if any row errors, nothing is inserted
/// and the errors are returned; otherwise all tags (and any newly-needed groups) are committed in one
/// <c>SaveChanges</c>.
/// </summary>
/// <param name="deviceId">The target device's logical id.</param>
/// <param name="rows">The rows to import.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The import outcome: the inserted count on success, or the per-row errors with a 0 count.</returns>
Task<RawTagImportResult> ImportTagsAsync(string deviceId, IReadOnlyList<RawTagImportRow> rows, CancellationToken ct = default);
/// <summary>
/// Loads a driver instance's editable fields as a detached projection for the WP3 "Configure driver"
/// modal. <c>DriverType</c> is included for typed-editor dispatch (it is immutable).
/// </summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The driver's editable projection, or null when it does not exist.</returns>
Task<RawDriverEditDto?> LoadDriverForEditAsync(string driverInstanceId, CancellationToken ct = default);
/// <summary>
/// Updates a driver instance's name + config from the WP3 "Configure driver" modal. The name is
/// validated as a sibling-unique RawPath segment (same scope as <see cref="RenameDriverAsync"/>);
/// last-write-wins on <paramref name="rowVersion"/>. <c>DriverType</c> is immutable and is not changed.
/// </summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="name">The (possibly changed) driver name.</param>
/// <param name="driverConfigJson">The new <c>DriverConfig</c> JSON blob.</param>
/// <param name="resilienceConfig">The new resilience-config JSON, or null/blank to clear.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The update outcome.</returns>
Task<UnsMutationResult> UpdateDriverAsync(string driverInstanceId, string name, string driverConfigJson, string? resilienceConfig, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads a device's editable fields as a detached projection for the WP3 "Configure device" modal.
/// <c>DriverType</c> is joined in from the parent driver so the modal can dispatch to the correct typed
/// device-config editor.
/// </summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The device's editable projection, or null when it does not exist.</returns>
Task<RawDeviceEditDto?> LoadDeviceForEditAsync(string deviceId, CancellationToken ct = default);
/// <summary>
/// Updates a device's name + config + enabled state from the WP3 "Configure device" modal. The name
/// is validated as a RawPath segment unique among the driver's devices; last-write-wins on
/// <paramref name="rowVersion"/>.
/// </summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="name">The (possibly changed) device name.</param>
/// <param name="deviceConfigJson">The new <c>DeviceConfig</c> JSON blob (endpoint + per-device settings).</param>
/// <param name="enabled">The new enabled state.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded projection.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The update outcome.</returns>
Task<UnsMutationResult> UpdateDeviceAsync(string deviceId, string name, string deviceConfigJson, bool enabled, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Builds the single merged config blob the driver-probe (test-connect) path binds from for one device:
/// the parent driver's <c>DriverConfig</c> folded together with this device's <c>DeviceConfig</c> (via
/// <c>DriverDeviceConfigMerger</c>, endpoint keys merging up from <c>DeviceConfig</c>). Used by the
/// "Test connect" button inside the WP3 driver/device modals now that the endpoint lives in
/// <c>DeviceConfig</c>.
/// </summary>
/// <param name="deviceId">The device to probe.</param>
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The parent driver's type + the merged config JSON, or null when the device/driver cannot be resolved.</returns>
Task<(string DriverType, string MergedConfigJson)?> LoadMergedProbeConfigAsync(string deviceId, CancellationToken ct = default);
}
@@ -0,0 +1,44 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Parameter object carrying the operator-editable fields for a raw <c>Tag</c> create or update in the
/// v3 Raw tree (WP4 manual entry). Identity is the RawPath — the tag's device + optional tag-group +
/// name — so there is no <c>TagId</c> here: it is generated on create and never carried in the input.
/// Whitespace-only <see cref="PollGroupId"/> collapses to <c>null</c>; <see cref="TagConfig"/> is
/// validated for JSON well-formedness by the service (blank collapses to <c>{}</c>).
/// </summary>
/// <param name="Name">The tag name (a RawPath segment).</param>
/// <param name="DataType">OPC UA built-in type name (Boolean / Int32 / Float / etc.).</param>
/// <param name="AccessLevel">Tag-level OPC UA access baseline.</param>
/// <param name="WriteIdempotent">Whether writes to this tag are safe to retry.</param>
/// <param name="PollGroupId">Optional poll-group batching key; whitespace/empty collapses to <c>null</c>.</param>
/// <param name="TagConfig">Schemaless per-driver-type JSON config; validated for JSON well-formedness.</param>
public sealed record RawTagInput(
string Name,
string DataType,
TagAccessLevel AccessLevel,
bool WriteIdempotent,
string? PollGroupId,
string TagConfig);
/// <summary>
/// One row of the WP5 CSV import commit. <see cref="TagGroupPath"/> is a <c>/</c>-separated tag-group
/// path under the target device (its segments are auto-created as nested <c>TagGroup</c>s, reusing any
/// that already exist); <c>null</c>/blank places the tag directly under the device. <see cref="Tag"/>
/// carries the tag's editable fields.
/// </summary>
/// <param name="TagGroupPath">The <c>/</c>-separated group path under the device, or null for the device root.</param>
/// <param name="Tag">The tag's editable fields.</param>
public sealed record RawTagImportRow(string? TagGroupPath, RawTagInput Tag);
/// <summary>
/// Outcome of <see cref="IRawTreeService.ImportTagsAsync"/>. The import is all-or-nothing: on any
/// per-row validation error nothing is inserted, <see cref="Inserted"/> is <c>0</c>, and
/// <see cref="Errors"/> carries the operator-facing per-row messages. On success <see cref="Errors"/>
/// is empty and <see cref="Inserted"/> is the number of tags committed.
/// </summary>
/// <param name="Inserted">The number of tags inserted (0 when any row errored).</param>
/// <param name="Errors">The per-row validation errors; empty on success.</param>
public sealed record RawTagImportResult(int Inserted, IReadOnlyList<string> Errors);
@@ -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)
@@ -0,0 +1,428 @@
using Microsoft.EntityFrameworkCore;
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
using ZB.MOM.WW.OtOpcUa.Configuration;
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Tests.Uns;
/// <summary>
/// Covers the B2 Wave-B service prelude added to <see cref="RawTreeService"/> — the tag CRUD/import path
/// (WP4 manual entry + WP5 CSV import) and the driver/device config + merged-probe projections (WP3
/// modals). Reuses the shared <see cref="RawTreeTestDb"/> InMemory fixture.
/// <para>
/// As with the WP1 suite, the EF InMemory provider does not enforce <c>RowVersion</c> tokens, so the
/// optimistic-concurrency failure is exercised via the "row vanished under a stale handle" path.
/// </para>
/// </summary>
[Trait("Category", "Unit")]
public sealed class RawTreeServiceWaveBTests
{
private static (RawTreeService Service, string DbName) Seeded()
{
var name = RawTreeTestDb.SeedFresh();
return (new RawTreeService(RawTreeTestDb.Factory(name)), name);
}
private static byte[] RowVersionOf(string dbName, Func<OtOpcUaConfigDbContext, byte[]> selector)
{
using var db = RawTreeTestDb.CreateNamed(dbName);
return selector(db);
}
private static RawTagInput Tag(string name, string dataType = "Float", string tagConfig = "{}") =>
new(name, dataType, TagAccessLevel.ReadWrite, WriteIdempotent: false, PollGroupId: null, tagConfig);
// -------------------------------------------------------------------------------- CreateTag (WP4)
[Fact]
public async Task CreateTag_under_group_succeeds_and_returns_new_id()
{
var (service, dbName) = Seeded();
var result = await service.CreateTagAsync(
RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("torque"));
result.Ok.ShouldBeTrue();
result.CreatedId.ShouldNotBeNull();
using var db = RawTreeTestDb.CreateNamed(dbName);
var tag = db.Tags.Single(t => t.TagId == result.CreatedId);
tag.Name.ShouldBe("torque");
tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId);
tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId);
}
[Fact]
public async Task CreateTag_collapses_whitespace_pollgroup_to_null()
{
var (service, dbName) = Seeded();
var result = await service.CreateTagAsync(
RawTreeTestDb.FolderedDeviceId, null,
new RawTagInput("flow", "Float", TagAccessLevel.Read, false, " ", "{}"));
result.Ok.ShouldBeTrue();
using var db = RawTreeTestDb.CreateNamed(dbName);
db.Tags.Single(t => t.TagId == result.CreatedId).PollGroupId.ShouldBeNull();
}
[Fact]
public async Task CreateTag_rejects_sibling_name_collision()
{
var (service, _) = Seeded();
// "speed" already exists under RootGroup.
var dup = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, RawTreeTestDb.RootGroupId, Tag("speed"));
dup.Ok.ShouldBeFalse();
dup.Error!.ShouldContain("already exists");
}
[Fact]
public async Task CreateTag_rejects_malformed_tagconfig_json()
{
var (service, _) = Seeded();
var bad = await service.CreateTagAsync(
RawTreeTestDb.FolderedDeviceId, null, Tag("pressure", tagConfig: "{not json"));
bad.Ok.ShouldBeFalse();
bad.Error!.ShouldContain("JSON");
}
[Fact]
public async Task CreateTag_rejects_invalid_name_segment()
{
var (service, _) = Seeded();
var bad = await service.CreateTagAsync(RawTreeTestDb.FolderedDeviceId, null, Tag("a/b"));
bad.Ok.ShouldBeFalse();
bad.Error!.ShouldContain("/");
}
// -------------------------------------------------------------------------------- UpdateTag (WP4)
[Fact]
public async Task UpdateTag_edits_fields_last_write_wins()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);
var result = await service.UpdateTagAsync(
RawTreeTestDb.PlainTagId,
new RawTagInput("running", "Boolean", TagAccessLevel.ReadWrite, true, "pg1", "{\"scale\":2}"),
rv);
result.Ok.ShouldBeTrue();
using var db = RawTreeTestDb.CreateNamed(dbName);
var tag = db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId);
tag.Name.ShouldBe("running");
tag.AccessLevel.ShouldBe(TagAccessLevel.ReadWrite);
tag.WriteIdempotent.ShouldBeTrue();
tag.PollGroupId.ShouldBe("pg1");
tag.TagConfig.ShouldContain("scale");
// Identity location preserved.
tag.DeviceId.ShouldBe(RawTreeTestDb.FolderedDeviceId);
tag.TagGroupId.ShouldBe(RawTreeTestDb.RootGroupId);
}
[Fact]
public async Task UpdateTag_rejects_rename_collision_excluding_self()
{
var (service, dbName) = Seeded();
// Rename "state" (PlainTag) to "speed" — a sibling under the same group ⇒ collision.
var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);
var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("speed"), rv);
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("already exists");
}
[Fact]
public async Task UpdateTag_allows_renaming_to_same_name_excluding_self()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);
// Keeping its own name "state" must not trip the excl-self uniqueness check.
var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("state", "Boolean"), rv);
result.Ok.ShouldBeTrue();
}
[Fact]
public async Task UpdateTag_fails_when_row_vanished_concurrently()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, db => db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId).RowVersion);
using (var db = RawTreeTestDb.CreateNamed(dbName))
{
db.Tags.Remove(db.Tags.Single(t => t.TagId == RawTreeTestDb.PlainTagId));
db.SaveChanges();
}
var result = await service.UpdateTagAsync(RawTreeTestDb.PlainTagId, Tag("whatever", "Boolean"), rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
// ------------------------------------------------------------------------------ ImportTags (WP5)
[Fact]
public async Task ImportTags_autocreates_nested_groups_and_reuses_them()
{
var (service, dbName) = Seeded();
var rows = new[]
{
new RawTagImportRow("Line/A", Tag("t1")),
new RawTagImportRow("Line/A", Tag("t2")), // reuses Line + Line/A
new RawTagImportRow("Line/B", Tag("t3")), // reuses Line, new B
};
var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);
result.Errors.ShouldBeEmpty();
result.Inserted.ShouldBe(3);
using var db = RawTreeTestDb.CreateNamed(dbName);
var line = db.TagGroups.Single(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.ParentTagGroupId == null && g.Name == "Line");
var groupA = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "A");
var groupB = db.TagGroups.Single(g => g.ParentTagGroupId == line.TagGroupId && g.Name == "B");
db.Tags.Count(t => t.TagGroupId == groupA.TagGroupId).ShouldBe(2); // Line/A reused, not duplicated
db.Tags.Count(t => t.TagGroupId == groupB.TagGroupId).ShouldBe(1);
// Exactly one "Line" group created despite three references.
db.TagGroups.Count(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Line").ShouldBe(1);
}
[Fact]
public async Task ImportTags_places_tag_at_device_root_when_path_blank()
{
var (service, dbName) = Seeded();
var result = await service.ImportTagsAsync(
RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("rootTag")) });
result.Inserted.ShouldBe(1);
using var db = RawTreeTestDb.CreateNamed(dbName);
db.Tags.Single(t => t.Name == "rootTag").TagGroupId.ShouldBeNull();
}
[Fact]
public async Task ImportTags_all_or_nothing_on_a_bad_row_inserts_nothing()
{
var (service, dbName) = Seeded();
var rows = new[]
{
new RawTagImportRow("Grp", Tag("good1")),
new RawTagImportRow("Grp", Tag("bad", tagConfig: "{oops")), // malformed JSON ⇒ error
new RawTagImportRow("Grp", Tag("good2")),
};
var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);
result.Inserted.ShouldBe(0);
result.Errors.ShouldNotBeEmpty();
result.Errors[0].ShouldContain("Row 2");
// Nothing committed — not the two good tags, not the auto-created group.
using var db = RawTreeTestDb.CreateNamed(dbName);
db.Tags.Any(t => t.Name == "good1" || t.Name == "good2").ShouldBeFalse();
db.TagGroups.Any(g => g.DeviceId == RawTreeTestDb.RootDeviceId && g.Name == "Grp").ShouldBeFalse();
}
[Fact]
public async Task ImportTags_detects_intra_batch_duplicate()
{
var (service, _) = Seeded();
var rows = new[]
{
new RawTagImportRow("Grp", Tag("dup")),
new RawTagImportRow("Grp", Tag("dup")), // same path + name within the batch
};
var result = await service.ImportTagsAsync(RawTreeTestDb.RootDeviceId, rows);
result.Inserted.ShouldBe(0);
result.Errors.ShouldContain(e => e.Contains("Row 2") && e.Contains("already exists"));
}
[Fact]
public async Task ImportTags_detects_collision_with_existing_tag()
{
var (service, _) = Seeded();
// "temperature" already sits at the device root of RootDevice.
var result = await service.ImportTagsAsync(
RawTreeTestDb.RootDeviceId, new[] { new RawTagImportRow(null, Tag("temperature")) });
result.Inserted.ShouldBe(0);
result.Errors.ShouldContain(e => e.Contains("already exists"));
}
// ----------------------------------------------------------------------- Driver config (WP3)
[Fact]
public async Task LoadDriverForEdit_projects_fields()
{
var (service, _) = Seeded();
var dto = await service.LoadDriverForEditAsync(RawTreeTestDb.FolderedDriverId);
dto.ShouldNotBeNull();
dto!.Name.ShouldBe("modbus-1");
dto.DriverType.ShouldBe("Modbus");
dto.DriverConfig.ShouldBe("{}");
}
[Fact]
public async Task LoadDriverForEdit_returns_null_when_missing()
{
var (service, _) = Seeded();
(await service.LoadDriverForEditAsync("DRV-nope")).ShouldBeNull();
}
[Fact]
public async Task UpdateDriver_edits_name_and_config_preserving_type()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion);
var result = await service.UpdateDriverAsync(
RawTreeTestDb.FolderedDriverId, "modbus-main", "{\"Host\":\"10.0.0.1\"}", "{\"x\":1}", rv);
result.Ok.ShouldBeTrue();
using var db = RawTreeTestDb.CreateNamed(dbName);
var drv = db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId);
drv.Name.ShouldBe("modbus-main");
drv.DriverConfig.ShouldContain("Host");
drv.ResilienceConfig.ShouldBe("{\"x\":1}");
drv.DriverType.ShouldBe("Modbus"); // immutable
}
[Fact]
public async Task UpdateDriver_rejects_sibling_name_collision()
{
var (service, dbName) = Seeded();
// Two cluster-root vs foldered drivers differ in scope; create a sibling in the same folder to collide.
await service.CreateDriverAsync(RawTreeTestDb.ClusterId, RawTreeTestDb.RootFolderId, "modbus-2", "Modbus", "{}");
var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion);
var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "modbus-2", "{}", null, rv);
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("already exists");
}
[Fact]
public async Task UpdateDriver_fails_when_row_vanished_concurrently()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, db => db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId).RowVersion);
using (var db = RawTreeTestDb.CreateNamed(dbName))
{
db.Devices.RemoveRange(db.Devices.Where(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId));
db.Tags.RemoveRange(db.Tags.Where(t => t.DeviceId == RawTreeTestDb.FolderedDeviceId));
db.TagGroups.RemoveRange(db.TagGroups.Where(g => g.DeviceId == RawTreeTestDb.FolderedDeviceId));
db.DriverInstances.Remove(db.DriverInstances.Single(d => d.DriverInstanceId == RawTreeTestDb.FolderedDriverId));
db.SaveChanges();
}
var result = await service.UpdateDriverAsync(RawTreeTestDb.FolderedDriverId, "x", "{}", null, rv);
result.Ok.ShouldBeFalse();
result.Error.ShouldNotBeNull();
}
// ----------------------------------------------------------------------- Device config (WP3)
[Fact]
public async Task LoadDeviceForEdit_projects_fields_with_driver_type_join()
{
var (service, _) = Seeded();
var dto = await service.LoadDeviceForEditAsync(RawTreeTestDb.FolderedDeviceId);
dto.ShouldNotBeNull();
dto!.Name.ShouldBe("Device1");
dto.DriverInstanceId.ShouldBe(RawTreeTestDb.FolderedDriverId);
dto.Enabled.ShouldBeTrue();
dto.DriverType.ShouldBe("Modbus"); // joined from the parent driver
}
[Fact]
public async Task LoadDeviceForEdit_returns_null_when_missing()
{
var (service, _) = Seeded();
(await service.LoadDeviceForEditAsync("DEV-nope")).ShouldBeNull();
}
[Fact]
public async Task UpdateDevice_edits_name_config_and_enabled()
{
var (service, dbName) = Seeded();
var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion);
var result = await service.UpdateDeviceAsync(
RawTreeTestDb.FolderedDeviceId, "PLC-A", "{\"Host\":\"10.0.0.9\",\"Port\":502}", enabled: false, rv);
result.Ok.ShouldBeTrue();
using var db = RawTreeTestDb.CreateNamed(dbName);
var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId);
dev.Name.ShouldBe("PLC-A");
dev.DeviceConfig.ShouldContain("Host");
dev.Enabled.ShouldBeFalse();
}
[Fact]
public async Task UpdateDevice_rejects_sibling_name_collision()
{
var (service, dbName) = Seeded();
await service.CreateDeviceAsync(RawTreeTestDb.FolderedDriverId, "Device2", "{}");
var rv = RowVersionOf(dbName, db => db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId).RowVersion);
var result = await service.UpdateDeviceAsync(RawTreeTestDb.FolderedDeviceId, "Device2", "{}", true, rv);
result.Ok.ShouldBeFalse();
result.Error!.ShouldContain("already exists");
}
// ------------------------------------------------------------- Merged probe config (Test connect)
[Fact]
public async Task LoadMergedProbeConfig_merges_deviceconfig_endpoint_up()
{
var (service, dbName) = Seeded();
// Put an endpoint on the device config (endpoint lives in DeviceConfig in v3).
using (var db = RawTreeTestDb.CreateNamed(dbName))
{
var dev = db.Devices.Single(d => d.DeviceId == RawTreeTestDb.FolderedDeviceId);
dev.DeviceConfig = "{\"Host\":\"10.1.2.3\",\"Port\":5020}";
db.SaveChanges();
}
var probe = await service.LoadMergedProbeConfigAsync(RawTreeTestDb.FolderedDeviceId);
probe.ShouldNotBeNull();
probe!.Value.DriverType.ShouldBe("Modbus");
// Endpoint key from DeviceConfig appears merged up at the top level of the probe config.
probe.Value.MergedConfigJson.ShouldContain("Host");
probe.Value.MergedConfigJson.ShouldContain("10.1.2.3");
}
[Fact]
public async Task LoadMergedProbeConfig_returns_null_for_unknown_device()
{
var (service, _) = Seeded();
(await service.LoadMergedProbeConfigAsync("DEV-nope")).ShouldBeNull();
}
}