feat(adminui): B2-WP1 RawTreeService + lazy tree data layer + mutations

Claude-Session: https://claude.ai/code/session_01LVneM3eh1UtJxEisFXgmox
This commit is contained in:
Joseph Doherty
2026-07-16 02:34:36 -04:00
parent fbf3c26c2a
commit 76b8325b84
6 changed files with 1807 additions and 0 deletions
@@ -48,6 +48,7 @@ public static class EndpointRouteBuilderExtensions
services.AddHostedService<Browsing.BrowseSessionReaper>();
services.AddScoped<Browsing.IBrowserSessionService, Browsing.BrowserSessionService>();
services.AddScoped<IUnsTreeService, UnsTreeService>();
services.AddScoped<IRawTreeService, RawTreeService>();
services.AddSingleton<IDriverBrowser, OpcUaClientDriverBrowser>();
services.AddSingleton<IDriverBrowser, GalaxyDriverBrowser>();
// Universal Discover-backed fallback browser (Wave 0). IDriverFactory is bound by the
@@ -1,5 +1,34 @@
using ZB.MOM.WW.OtOpcUa.Configuration.Enums;
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Detached, editable projection of a raw <c>Tag</c> — the read side of tag editing. WP1 exposes this
/// loader; WP4 owns the create/update authoring surface and should align its input model with these
/// fields (identity is the RawPath, formed from the tag's device + optional group + name).
/// </summary>
/// <param name="TagId">The tag's stable logical id.</param>
/// <param name="DeviceId">The owning device's logical id.</param>
/// <param name="TagGroupId">The containing tag group's logical id, or null when directly under the device.</param>
/// <param name="Name">The tag name (a RawPath segment).</param>
/// <param name="DataType">The OPC UA built-in type name.</param>
/// <param name="AccessLevel">The tag's access-level baseline.</param>
/// <param name="WriteIdempotent">Whether writes to this tag are retry-eligible.</param>
/// <param name="PollGroupId">The optional poll-group id.</param>
/// <param name="TagConfig">The schemaless per-driver TagConfig JSON blob.</param>
/// <param name="RowVersion">The optimistic-concurrency token.</param>
public readonly record struct RawTagEditDto(
string TagId,
string DeviceId,
string? TagGroupId,
string Name,
string DataType,
TagAccessLevel AccessLevel,
bool WriteIdempotent,
string? PollGroupId,
string TagConfig,
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
@@ -41,4 +70,185 @@ public interface IRawTreeService
/// <param name="ct">A token to cancel the load.</param>
/// <returns>The parent's direct child nodes; empty for a leaf or an empty container.</returns>
Task<IReadOnlyList<RawNode>> LoadChildrenAsync(RawNode parent, CancellationToken ct = default);
// --- Folder mutations ---
/// <summary>
/// Creates a new <c>RawFolder</c> under a cluster (<paramref name="parentFolderId"/> null) or under
/// another folder. The name is validated as a RawPath segment and must be unique among its siblings
/// (case-sensitive ordinal). On success the result's <c>CreatedId</c> carries the new folder's
/// logical id.
/// </summary>
/// <param name="clusterId">The owning cluster id.</param>
/// <param name="parentFolderId">The parent folder's logical id, or null for a cluster-root folder.</param>
/// <param name="name">The folder name (a RawPath segment).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>RawFolderId</c> on success.</returns>
Task<UnsMutationResult> CreateFolderAsync(string clusterId, string? parentFolderId, string name, CancellationToken ct = default);
/// <summary>
/// Renames a <c>RawFolder</c>. Validates the new name as a RawPath segment and sibling-unique.
/// Because a folder rename changes the RawPath of every tag beneath it, the returned
/// <see cref="RawRenameResult"/> carries advisory warnings for historized and equipment-referenced
/// tags in the folder's subtree.
/// </summary>
/// <param name="rawFolderId">The folder's logical id.</param>
/// <param name="newName">The new folder name.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
Task<RawRenameResult> RenameFolderAsync(string rawFolderId, string newName, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes an empty <c>RawFolder</c>. Blocked (with the blocker named) while it still contains child
/// folders or driver instances.
/// </summary>
/// <param name="rawFolderId">The folder's logical id.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The delete outcome.</returns>
Task<UnsMutationResult> DeleteFolderAsync(string rawFolderId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Moves a driver instance into a folder (<paramref name="targetFolderId"/>) or to the cluster root
/// (null target). The target folder must be in the driver's cluster and the driver's name must be
/// unique among the target's driver siblings.
/// </summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="targetFolderId">The destination folder's logical id, or null for the cluster root.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The move outcome.</returns>
Task<UnsMutationResult> MoveDriverToFolderAsync(string driverInstanceId, string? targetFolderId, byte[] rowVersion, CancellationToken ct = default);
// --- Driver mutations ---
/// <summary>
/// Creates a minimal driver instance and its auto-created default <c>Device</c> so the tree has a
/// child to show. Full typed driver/device config authoring is Wave B (WP3) — this create takes the
/// driver config JSON verbatim and seeds an empty (<c>{}</c>) default device. The driver name is a
/// RawPath segment, unique among its cluster/folder siblings.
/// </summary>
/// <param name="clusterId">The owning cluster id.</param>
/// <param name="folderId">The containing folder's logical id, or null for a cluster-root driver.</param>
/// <param name="name">The driver instance name (a RawPath segment).</param>
/// <param name="driverType">The driver-type string (see <c>DriverTypeNames</c>).</param>
/// <param name="driverConfigJson">The driver config JSON blob (opaque here; validated in Wave B).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DriverInstanceId</c> on success.</returns>
Task<UnsMutationResult> CreateDriverAsync(string clusterId, string? folderId, string name, string driverType, string driverConfigJson, CancellationToken ct = default);
/// <summary>
/// Renames a driver instance. Validates the new name as a sibling-unique RawPath segment and returns
/// downstream-impact warnings for historized/equipment-referenced tags beneath the driver.
/// </summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="newName">The new driver name.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
Task<RawRenameResult> RenameDriverAsync(string driverInstanceId, string newName, byte[] rowVersion, CancellationToken ct = default);
/// <summary>Enables or disables a driver instance (last-write-wins on <paramref name="rowVersion"/>).</summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="enabled">The new enabled state.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The update outcome.</returns>
Task<UnsMutationResult> SetDriverEnabledAsync(string driverInstanceId, bool enabled, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Deletes a driver instance, cascading its auto-created empty devices. Blocked (with the blocking
/// device named) when any of its devices still contains tag groups or tags — remove those first.
/// </summary>
/// <param name="driverInstanceId">The driver instance's logical id.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The delete outcome.</returns>
Task<UnsMutationResult> DeleteDriverAsync(string driverInstanceId, byte[] rowVersion, CancellationToken ct = default);
// --- Device mutations ---
/// <summary>
/// Creates a minimal <c>Device</c> under a driver. Full typed device-config authoring is Wave B
/// (WP3) — this create takes the device config JSON verbatim. The device name must be unique among
/// the driver's devices.
/// </summary>
/// <param name="driverInstanceId">The owning driver instance's logical id.</param>
/// <param name="name">The device name (a RawPath segment).</param>
/// <param name="deviceConfigJson">The device config JSON blob (opaque here; validated in Wave B).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>DeviceId</c> on success.</returns>
Task<UnsMutationResult> CreateDeviceAsync(string driverInstanceId, string name, string deviceConfigJson, CancellationToken ct = default);
/// <summary>
/// Renames a <c>Device</c>. Validates the new name as a driver-unique RawPath segment and returns
/// downstream-impact warnings for historized/equipment-referenced tags beneath the device.
/// </summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="newName">The new device name.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
Task<RawRenameResult> RenameDeviceAsync(string deviceId, string newName, byte[] rowVersion, CancellationToken ct = default);
/// <summary>Deletes a <c>Device</c>. Blocked (with the blocker named) while it holds tag groups or tags.</summary>
/// <param name="deviceId">The device's logical id.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The delete outcome.</returns>
Task<UnsMutationResult> DeleteDeviceAsync(string deviceId, byte[] rowVersion, CancellationToken ct = default);
// --- TagGroup mutations ---
/// <summary>
/// Creates a new <c>TagGroup</c> under a device (<paramref name="parentGroupId"/> null) or under
/// another group. The name is validated as a RawPath segment, unique among its siblings.
/// </summary>
/// <param name="deviceId">The owning device's logical id.</param>
/// <param name="parentGroupId">The parent group's logical id, or null for a device-root group.</param>
/// <param name="name">The group name (a RawPath segment).</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The create outcome; <c>CreatedId</c> is the new <c>TagGroupId</c> on success.</returns>
Task<UnsMutationResult> CreateTagGroupAsync(string deviceId, string? parentGroupId, string name, CancellationToken ct = default);
/// <summary>
/// Renames a <c>TagGroup</c>. Validates the new name as a sibling-unique RawPath segment and returns
/// downstream-impact warnings for historized/equipment-referenced tags beneath the group.
/// </summary>
/// <param name="tagGroupId">The group's logical id.</param>
/// <param name="newName">The new group name.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The rename outcome with any downstream-impact warnings.</returns>
Task<RawRenameResult> RenameTagGroupAsync(string tagGroupId, string newName, byte[] rowVersion, CancellationToken ct = default);
/// <summary>Deletes a <c>TagGroup</c>. Blocked (with the blocker named) while it holds child groups or tags.</summary>
/// <param name="tagGroupId">The group's logical id.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The delete outcome.</returns>
Task<UnsMutationResult> DeleteTagGroupAsync(string tagGroupId, byte[] rowVersion, CancellationToken ct = default);
// --- Tag mutations / projections (create + update are WP4) ---
/// <summary>
/// Deletes a raw <c>Tag</c>. Blocked while any <c>UnsTagReference</c> points at it; the failure
/// message names the referencing equipment(s) so the operator can remove the reference(s) first.
/// </summary>
/// <param name="tagId">The tag's logical id.</param>
/// <param name="rowVersion">The optimistic-concurrency token echoed from the loaded node.</param>
/// <param name="ct">A token to cancel the operation.</param>
/// <returns>The delete outcome.</returns>
Task<UnsMutationResult> DeleteTagAsync(string tagId, byte[] rowVersion, CancellationToken ct = default);
/// <summary>
/// Loads a raw tag's editable fields as a detached projection. WP1 provides the read-only projection;
/// the create/update authoring surface is WP4 (which should align its input shape with
/// <see cref="RawTagEditDto"/>).
/// </summary>
/// <param name="tagId">The tag's logical id.</param>
/// <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);
}
@@ -0,0 +1,61 @@
namespace ZB.MOM.WW.OtOpcUa.AdminUI.Uns;
/// <summary>
/// Pure, EF-free assembly of the eager top of the Raw project tree (<c>/raw</c>) — Enterprise → Cluster —
/// from flat cluster rows. The peer of <see cref="UnsTreeAssembly"/> for the Raw subtree. Everything below
/// a cluster loads lazily via <see cref="IRawTreeService.LoadChildrenAsync"/>, so this helper only nests
/// clusters under their enterprise label and stamps each cluster with its root-level child count and the
/// lazy-load flag. Empty clusters are retained so they stay visible and authorable.
/// </summary>
public static class RawTreeAssembly
{
/// <summary>
/// Builds the Enterprise→Cluster roots. Ordering is deterministic and ordinal at both levels. Each
/// cluster is marked <see cref="RawNode.HasLazyChildren"/> and carries the supplied root child count
/// (its root folders + cluster-root drivers).
/// </summary>
/// <param name="clusters">The flat cluster rows to nest under their enterprise label.</param>
/// <param name="rootChildCounts">Per-cluster count of root folders + cluster-root drivers (badge).</param>
/// <returns>The assembled enterprise root nodes, populated down to (but not through) their clusters.</returns>
public static IReadOnlyList<RawNode> BuildRoots(
IReadOnlyList<ClusterRow> clusters,
IReadOnlyDictionary<string, int> rootChildCounts)
{
return clusters
.GroupBy(c => c.Enterprise, StringComparer.Ordinal)
.OrderBy(g => g.Key, StringComparer.Ordinal)
.Select(entGroup =>
{
var ent = new RawNode
{
Kind = RawNodeKind.Enterprise,
Key = $"ent:{entGroup.Key}",
DisplayName = entGroup.Key,
ClusterId = null,
EntityId = null,
HasLazyChildren = false,
};
var clusterNodes = entGroup
.OrderBy(c => c.Name, StringComparer.Ordinal)
.ThenBy(c => c.ClusterId, StringComparer.Ordinal)
.Select(c => new RawNode
{
Kind = RawNodeKind.Cluster,
Key = $"clu:{c.ClusterId}",
DisplayName = string.IsNullOrEmpty(c.Site) ? c.Name : $"{c.Site} ({c.Name})",
ClusterId = c.ClusterId,
// A cluster's own logical id IS its ClusterId — EntityId mirrors it for uniform navigation.
EntityId = c.ClusterId,
HasLazyChildren = true,
ChildCount = rootChildCounts.GetValueOrDefault(c.ClusterId),
})
.ToList();
ent.Children.AddRange(clusterNodes);
ent.ChildCount = ent.Children.Count;
return ent;
})
.ToList();
}
}
File diff suppressed because it is too large Load Diff