feat(galaxy.browser): add lazy browse session with attribute fetch
This commit is contained in:
@@ -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.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user