feat(galaxy.browser): add lazy browse session with attribute fetch

This commit is contained in:
Joseph Doherty
2026-05-28 15:42:19 -04:00
parent 85676db3a5
commit d605d0b20d
@@ -0,0 +1,191 @@
using System.Collections.Concurrent;
using MxGateway.Client;
using MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
namespace ZB.MOM.WW.OtOpcUa.Driver.Galaxy.Browser;
/// <summary>
/// Galaxy browse over the gateway's <see cref="GalaxyRepositoryClient"/>.
/// The gateway returns the deployed hierarchy as a flat <see cref="GalaxyObject"/>
/// list, so this session fetches the full set once on <see cref="RootAsync"/>,
/// caches it by <c>TagName</c> (and <c>GobjectId</c> for parent lookup), and serves
/// subsequent <see cref="ExpandAsync"/> calls in-memory. Attribute fetches are
/// per-object via <c>DiscoverHierarchyAsync(MaxDepth=0, IncludeAttributes=true)</c>.
/// Owns the supplied <see cref="MxGatewaySession"/> and disposes it best-effort.
/// </summary>
internal sealed class GalaxyBrowseSession : IBrowseSession
{
private readonly MxGatewaySession _session;
private readonly GalaxyRepositoryClient _client;
private readonly ConcurrentDictionary<string, GalaxyObject> _byTagName = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<int, GalaxyObject> _byGobjectId = new();
private bool _disposed;
/// <summary>Opaque token identifying this session in the AdminUI registry.</summary>
public Guid Token { get; } = Guid.NewGuid();
/// <summary>Wall-clock time of the most recent successful Root/Expand/Attributes call.</summary>
public DateTime LastUsedUtc { get; private set; } = DateTime.UtcNow;
/// <summary>
/// Initializes a new session wrapping a connected gateway client. The factory
/// in <c>GalaxyDriverBrowser</c> (Task 9) constructs both the session and the
/// repository client and hands them off here for the session's lifetime.
/// </summary>
/// <param name="session">Gateway session to dispose when the browse closes.</param>
/// <param name="client">Galaxy repository client to query for hierarchy and attributes.</param>
internal GalaxyBrowseSession(MxGatewaySession session, GalaxyRepositoryClient client)
{
_session = session ?? throw new ArgumentNullException(nameof(session));
_client = client ?? throw new ArgumentNullException(nameof(client));
}
/// <summary>
/// Fetches the full Galaxy hierarchy from the gateway, populates the cache,
/// and returns the top-level objects (those with no parent in the deployed model).
/// </summary>
public async Task<IReadOnlyList<BrowseNode>> RootAsync(CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var all = await _client.DiscoverHierarchyAsync(
new DiscoverHierarchyOptions { IncludeAttributes = false }, cancellationToken)
.ConfigureAwait(false);
// Populate caches so later ExpandAsync calls can resolve children in-memory.
foreach (var obj in all)
{
_byTagName[obj.TagName] = obj;
_byGobjectId[obj.GobjectId] = obj;
}
// Roots are objects whose parent isn't part of the returned set (typically
// ParentGobjectId == 0 for WinPlatforms / top-level Areas).
var roots = all
.Where(o => o.ParentGobjectId == 0 || !_byGobjectId.ContainsKey(o.ParentGobjectId))
.ToList();
LastUsedUtc = DateTime.UtcNow;
return Project(roots);
}
/// <summary>
/// Returns the direct children of the cached Galaxy object identified by
/// <paramref name="nodeId"/> (the object's <c>TagName</c>). Throws
/// <see cref="ArgumentException"/> if the tag hasn't been handed out by a
/// prior Root/Expand call (i.e. it's not in the cache).
/// </summary>
public Task<IReadOnlyList<BrowseNode>> ExpandAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
if (!_byTagName.TryGetValue(nodeId, out var parent))
{
throw new ArgumentException(
$"Galaxy object '{nodeId}' is not in the current browse-session cache. " +
"Re-open the browser or expand its parent first.", nameof(nodeId));
}
var children = _byGobjectId.Values
.Where(o => o.ParentGobjectId == parent.GobjectId)
.ToList();
LastUsedUtc = DateTime.UtcNow;
return Task.FromResult(Project(children));
}
/// <summary>
/// Fetches the attributes of the Galaxy object identified by <paramref name="nodeId"/>
/// via <c>DiscoverHierarchyAsync(MaxDepth=0, RootTagName=nodeId, IncludeAttributes=true)</c>.
/// Returns an empty list if the gateway has no matching object.
/// </summary>
public async Task<IReadOnlyList<AttributeInfo>> AttributesAsync(string nodeId, CancellationToken cancellationToken)
{
ObjectDisposedException.ThrowIf(_disposed, this);
var rows = await _client.DiscoverHierarchyAsync(
new DiscoverHierarchyOptions
{
RootTagName = nodeId,
MaxDepth = 0,
IncludeAttributes = true,
}, cancellationToken).ConfigureAwait(false);
LastUsedUtc = DateTime.UtcNow;
var obj = rows.FirstOrDefault();
if (obj is null) return Array.Empty<AttributeInfo>();
var result = new List<AttributeInfo>(obj.Attributes.Count);
foreach (var attr in obj.Attributes)
{
var driverType = !string.IsNullOrEmpty(attr.DataTypeName)
? attr.DataTypeName
: attr.MxDataType.ToString(System.Globalization.CultureInfo.InvariantCulture);
result.Add(new AttributeInfo(
Name: attr.AttributeName,
DriverDataType: driverType,
IsArray: attr.IsArray,
SecurityClass: MapSecurityClass(attr.SecurityClassification)));
}
return result;
}
/// <summary>
/// Projects <see cref="GalaxyObject"/>s to <see cref="BrowseNode"/>s, ensuring
/// every projected object is also written to the by-tag cache so later
/// <see cref="ExpandAsync"/> calls can find it. Galaxy nodes are always
/// <see cref="BrowseNodeKind.Folder"/> — leaves only appear in the attribute
/// side-panel, never in the tree.
/// </summary>
private IReadOnlyList<BrowseNode> Project(IReadOnlyList<GalaxyObject> nodes)
{
var result = new List<BrowseNode>(nodes.Count);
foreach (var obj in nodes)
{
// Belt-and-braces: ensure the cache holds every node we hand back so
// ExpandAsync can resolve it on the next round-trip.
_byTagName[obj.TagName] = obj;
_byGobjectId[obj.GobjectId] = obj;
var displayName = !string.IsNullOrEmpty(obj.ContainedName) ? obj.ContainedName : obj.TagName;
var hasChildrenHint = _byGobjectId.Values.Any(o => o.ParentGobjectId == obj.GobjectId);
result.Add(new BrowseNode(
NodeId: obj.TagName,
DisplayName: displayName,
Kind: BrowseNodeKind.Folder,
HasChildrenHint: hasChildrenHint));
}
return result;
}
/// <summary>
/// Maps the Galaxy raw security-classification integer to a display string.
/// Buckets: 0=FreeAccess, 1=Operate, 2=Tune, 3=Configure, 4=ViewOnly;
/// anything else surfaces as <c>Unknown(N)</c>.
/// </summary>
private static string MapSecurityClass(int raw) => raw switch
{
0 => "FreeAccess",
1 => "Operate",
2 => "Tune",
3 => "Configure",
4 => "ViewOnly",
_ => $"Unknown({raw})",
};
/// <summary>
/// Idempotently tears down the underlying gateway session. Swallows exceptions
/// on shutdown — the registry's reaper may be racing a client-initiated close.
/// </summary>
public async ValueTask DisposeAsync()
{
if (_disposed) return;
_disposed = true;
try
{
await _session.DisposeAsync().ConfigureAwait(false);
}
catch
{
// Best-effort: a gateway-side close that hits a torn-down channel is normal.
}
}
}