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:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user