Compare commits

...

21 Commits

Author SHA1 Message Date
Joseph Doherty 4a19854eb9 docs: per-client High-level walker example using LazyBrowseNode
Add a "High-level walker" subsection under each client's "Browsing
lazily" section showing idiomatic use of LazyBrowseNode (browse +
expand, idempotency note, redeploy refresh pattern).
2026-05-28 14:34:19 -04:00
Joseph Doherty a4467e23ef client/python: make LazyBrowseNode.expand concurrency-safe 2026-05-28 14:32:35 -04:00
Joseph Doherty eacfeff9fb client/dotnet: make LazyBrowseNode.ExpandAsync thread-safe 2026-05-28 14:28:36 -04:00
Joseph Doherty b4bc2df015 client/java: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:29:15 -04:00
Joseph Doherty fd2a0ac4c7 client/go: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:26:41 -04:00
Joseph Doherty 555e4be51f client/rust: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:26:05 -04:00
Joseph Doherty 1d8c0d83c4 client/python: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:24:23 -04:00
Joseph Doherty 6600f2a7bd client/dotnet: LazyBrowseNode walker for lazy hierarchy browse 2026-05-28 14:24:17 -04:00
Joseph Doherty 803a207ad2 client/java: regenerate protos for BrowseChildren
Regen'd from galaxy_repository.proto after BrowseChildren RPC was added.
GalaxyRepositoryGrpc and GalaxyRepositoryOuterClass now include the
BrowseChildrenRequest/BrowseChildrenReply types and stub methods.
2026-05-28 14:21:56 -04:00
Joseph Doherty 97e583e96b docs: implementation plan for per-language LazyBrowseNode walker
9 tasks: Java toolchain install (Homebrew), 5 parallel per-language
walker implementations, README updates, final verification. Java
walker is gated on toolchain bootstrap success; other languages
proceed independently if Java fails.
2026-05-28 14:17:52 -04:00
Joseph Doherty eaf479349d docs: design for client-side LazyBrowseNode walker + per-language tests
Adds one high-level walker per client (.NET/Python/Rust/Go/Java) plus
six unit tests each against existing fake transports. One-shot idempotent
Expand semantics; pagination hidden inside the helper. Includes Java
toolchain bootstrap (Homebrew Temurin + Gradle) so the Java client can
build locally on the macOS dev host.
2026-05-28 14:12:03 -04:00
Joseph Doherty 83a4d41fce docs: align design doc test-plan with InvalidArgument error mapping 2026-05-28 13:30:19 -04:00
Joseph Doherty 0d6193cdc4 docs: note BrowseChildren in gateway overview and client READMEs 2026-05-28 13:25:46 -04:00
Joseph Doherty 8cd3e1c20e client/go: regenerate protos for BrowseChildren 2026-05-28 13:22:06 -04:00
Joseph Doherty 5c28458624 client/rust: regenerate protos for BrowseChildren 2026-05-28 13:19:54 -04:00
Joseph Doherty 0b389f5a97 docs: document BrowseChildren RPC and lazy browse architecture 2026-05-28 13:19:08 -04:00
Joseph Doherty 108c4bb118 client/python: regenerate protos for BrowseChildren 2026-05-28 13:18:25 -04:00
Joseph Doherty cf54a278e1 docs: record lazy-browse stays wire-only; align error mapping 2026-05-28 13:18:23 -04:00
Joseph Doherty 81b2aacfe2 client/dotnet: live smoke for BrowseChildren 2026-05-28 13:17:29 -04:00
Joseph Doherty 5932fe2fd3 dashboard: surface lazy-load errors via BrowseLoadState.Error 2026-05-28 13:15:26 -04:00
Joseph Doherty 310dfab8b4 dashboard: lazy-load BrowsePage via DashboardBrowseService 2026-05-28 13:10:10 -04:00
47 changed files with 9235 additions and 80 deletions
+48
View File
@@ -196,6 +196,54 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-las
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
``` ```
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildrenAsync` to walk one level at a
time instead of paging the full hierarchy. Pass an empty request for root objects;
subsequent calls supply `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Each child's `ChildHasChildren[i]` tells you whether to
draw an expand triangle. Filter fields match `DiscoverHierarchy`. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```csharp
BrowseChildrenReply roots = await repository.BrowseChildrenAsync(
new BrowseChildrenRequest());
for (int i = 0; i < roots.Children.Count; i++)
{
GalaxyObject child = roots.Children[i];
bool hasChildren = roots.ChildHasChildren[i];
Console.WriteLine($"{child.TagName} expand={hasChildren}");
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```csharp
await using GalaxyRepositoryClient repository = GalaxyRepositoryClient.Create(
new MxGatewayClientOptions { Endpoint = new Uri("http://localhost:5000"), ApiKey = apiKey });
IReadOnlyList<LazyBrowseNode> roots = await repository.BrowseAsync();
foreach (LazyBrowseNode root in roots)
{
if (root.HasChildrenHint)
{
await root.ExpandAsync();
}
foreach (LazyBrowseNode child in root.Children)
{
Console.WriteLine($"{child.Object.TagName} ({(child.HasChildrenHint ? "has children" : "leaf")})");
}
}
```
`ExpandAsync` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`BrowseAsync` again from the root.
### Watching deploy events ### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The `WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
@@ -0,0 +1,34 @@
using Grpc.Core;
using Grpc.Net.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
/// </summary>
public sealed class BrowseChildrenSmokeTests
{
/// <summary>
/// Verifies that BrowseChildren returns a non-zero cache sequence and
/// a consistent children/child-has-children count from a live gateway.
/// </summary>
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
{
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
Assert.True(reply.CacheSequence > 0UL);
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}
}
@@ -123,6 +123,39 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
: DiscoverHierarchyReply); : DiscoverHierarchyReply);
} }
/// <summary>Records BrowseChildren RPC calls made by the client.</summary>
public List<(BrowseChildrenRequest Request, CallOptions CallOptions)> BrowseChildrenCalls { get; } = [];
/// <summary>Default reply returned from BrowseChildren when the queue is empty.</summary>
public BrowseChildrenReply BrowseChildrenReply { get; set; } = new();
/// <summary>Queue of replies returned from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<BrowseChildrenReply> BrowseChildrenReplies { get; } = new();
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
/// <summary>
/// Records the request and either throws a queued exception or returns the configured reply.
/// </summary>
/// <param name="request">The BrowseChildrenRequest to process.</param>
/// <param name="callOptions">Call options specifying RPC behavior.</param>
public Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
BrowseChildrenCalls.Add((request, callOptions));
if (BrowseChildrenExceptions.TryDequeue(out Exception? exception))
{
return Task.FromException<BrowseChildrenReply>(exception);
}
return Task.FromResult(
BrowseChildrenReplies.TryDequeue(out BrowseChildrenReply? reply)
? reply
: BrowseChildrenReply);
}
/// <summary> /// <summary>
/// Gets the list of WatchDeployEvents RPC calls made by the client. /// Gets the list of WatchDeployEvents RPC calls made by the client.
/// </summary> /// </summary>
@@ -0,0 +1,219 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Tests for the <see cref="LazyBrowseNode"/> walker over the BrowseChildren RPC.
/// </summary>
public sealed class LazyBrowseNodeTests
{
/// <summary>
/// Verifies that calling BrowseAsync with no parent returns the root nodes
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
/// </summary>
[Fact]
public async Task Browse_NoParent_ReturnsRoots()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true), BuildObject(2, "Other")],
childHasChildren: [true, false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
Assert.Equal(2, roots.Count);
Assert.Equal("Plant", roots[0].Object.TagName);
Assert.True(roots[0].HasChildrenHint);
Assert.False(roots[0].IsExpanded);
Assert.Equal("Other", roots[1].Object.TagName);
Assert.False(roots[1].HasChildrenHint);
Assert.False(roots[1].IsExpanded);
}
/// <summary>
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
/// </summary>
[Fact]
public async Task Expand_PopulatesChildrenAndMarksExpanded()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
Assert.Equal("Line1", roots[0].Children[0].Object.TagName);
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
/// </summary>
[Fact]
public async Task Expand_CalledTwice_NoSecondRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(10, "Line1")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
await roots[0].ExpandAsync();
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
/// </summary>
[Fact]
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Queue the failure for the upcoming ExpandAsync call so it consumes
// the exception on its first RPC rather than the BrowseAsync above.
transport.BrowseChildrenExceptions.Enqueue(
new MxGatewayException(
"Parent not found",
new RpcException(new Status(StatusCode.NotFound, "Parent not found"))));
await Assert.ThrowsAsync<MxGatewayException>(async () => await roots[0].ExpandAsync());
}
/// <summary>
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
/// </summary>
[Fact]
public async Task Expand_MultiPageSiblings_GathersAllPages()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
// Roots
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(7, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 1));
// First child page (2 children) with a next token
BrowseChildrenReply childPage1 = BuildReply(
children: [BuildObject(70, "ChildA"), BuildObject(71, "ChildB")],
childHasChildren: [false, false],
cacheSequence: 1);
childPage1.NextPageToken = "7:abc:2";
transport.BrowseChildrenReplies.Enqueue(childPage1);
// Second child page (1 child) with no next token
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(72, "ChildC")],
childHasChildren: [false],
cacheSequence: 1));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
await roots[0].ExpandAsync();
Assert.Equal(3, roots[0].Children.Count);
Assert.Equal(3, transport.BrowseChildrenCalls.Count);
Assert.Equal("7:abc:2", transport.BrowseChildrenCalls[2].Request.PageToken);
}
/// <summary>
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
/// </summary>
[Fact]
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(1, "Plant", isArea: true)],
childHasChildren: [true],
cacheSequence: 7));
transport.BrowseChildrenReplies.Enqueue(BuildReply(
children: [BuildObject(2, "Mixer_001")],
childHasChildren: [false],
cacheSequence: 7));
await using GalaxyRepositoryClient client = CreateClient(transport);
IReadOnlyList<LazyBrowseNode> roots = await client.BrowseAsync();
// Fire ten concurrent expands of the same node.
Task[] tasks = Enumerable.Range(0, 10)
.Select(_ => roots[0].ExpandAsync())
.ToArray();
await Task.WhenAll(tasks);
Assert.True(roots[0].IsExpanded);
Assert.Single(roots[0].Children);
// 1 roots fetch + exactly 1 expand fetch = 2 total
Assert.Equal(2, transport.BrowseChildrenCalls.Count);
}
/// <summary>
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
/// </summary>
[Fact]
public async Task Browse_WithFilter_ForwardsToRequest()
{
FakeGalaxyRepositoryTransport transport = CreateTransport();
await using GalaxyRepositoryClient client = CreateClient(transport);
await client.BrowseAsync(new BrowseChildrenOptions
{
TagNameGlob = "Mixer*",
AlarmBearingOnly = true,
});
BrowseChildrenRequest request = Assert.Single(transport.BrowseChildrenCalls).Request;
Assert.Equal("Mixer*", request.TagNameGlob);
Assert.True(request.AlarmBearingOnly);
}
private static GalaxyObject BuildObject(int id, string tag, bool isArea = false)
=> new() { GobjectId = id, TagName = tag, BrowseName = tag, IsArea = isArea };
private static BrowseChildrenReply BuildReply(
IReadOnlyList<GalaxyObject> children,
IReadOnlyList<bool> childHasChildren,
ulong cacheSequence)
{
BrowseChildrenReply reply = new() { TotalChildCount = children.Count, CacheSequence = cacheSequence };
reply.Children.AddRange(children);
reply.ChildHasChildren.AddRange(childHasChildren);
return reply;
}
private static GalaxyRepositoryClient CreateClient(FakeGalaxyRepositoryTransport transport)
=> new(transport.Options, transport);
private static FakeGalaxyRepositoryTransport CreateTransport()
=> new(new MxGatewayClientOptions
{
Endpoint = new Uri("http://localhost:5000"),
ApiKey = "test-api-key",
});
}
@@ -0,0 +1,26 @@
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// Filters and shape options for <see cref="GalaxyRepositoryClient.BrowseAsync(BrowseChildrenOptions, System.Threading.CancellationToken)"/>.
/// Mirror of <see cref="DiscoverHierarchyOptions"/> for the lazy-browse path.
/// </summary>
public sealed class BrowseChildrenOptions
{
/// <summary>Restrict to children whose Galaxy category is in this set.</summary>
public IReadOnlyList<int> CategoryIds { get; init; } = [];
/// <summary>Restrict to children whose template chain contains any of these tokens.</summary>
public IReadOnlyList<string> TemplateChainContains { get; init; } = [];
/// <summary>Optional glob-style filter on <c>tag_name</c>.</summary>
public string? TagNameGlob { get; init; }
/// <summary>Whether to populate each <c>GalaxyObject.Attributes</c>. Null leaves the server default.</summary>
public bool? IncludeAttributes { get; init; }
/// <summary>Restrict to children that bear at least one alarm attribute.</summary>
public bool AlarmBearingOnly { get; init; }
/// <summary>Restrict to children that have at least one historized attribute.</summary>
public bool HistorizedOnly { get; init; }
}
@@ -19,6 +19,7 @@ namespace ZB.MOM.WW.MxGateway.Client;
public sealed class GalaxyRepositoryClient : IAsyncDisposable public sealed class GalaxyRepositoryClient : IAsyncDisposable
{ {
private const int DiscoverHierarchyPageSize = 5000; private const int DiscoverHierarchyPageSize = 5000;
private const int BrowseChildrenPageSize = 500;
private readonly GrpcChannel? _channel; private readonly GrpcChannel? _channel;
private readonly IGalaxyRepositoryClientTransport _transport; private readonly IGalaxyRepositoryClientTransport _transport;
@@ -278,6 +279,89 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
cancellationToken); cancellationToken);
} }
/// <summary>Returns root-level browse nodes (objects with no parent).</summary>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(CancellationToken cancellationToken = default)
=> BrowseAsync(null, cancellationToken);
/// <summary>Returns root-level browse nodes filtered by the given options.</summary>
/// <param name="options">Browse filter options. Null applies no filter.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The list of root <see cref="LazyBrowseNode"/> instances.</returns>
public async Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
BrowseChildrenOptions? options,
CancellationToken cancellationToken = default)
{
BrowseChildrenOptions effective = options ?? new BrowseChildrenOptions();
List<LazyBrowseNode> roots = [];
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = BuildBrowseChildrenRequest(effective);
request.PageToken = pageToken;
BrowseChildrenReply reply = await BrowseChildrenRawAsync(request, cancellationToken).ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
roots.Add(new LazyBrowseNode(this, reply.Children[i], hint, effective));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
return roots;
}
/// <summary>Issues a raw BrowseChildren RPC without result wrapping.</summary>
/// <param name="request">The browse-children request.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The raw server reply.</returns>
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
BrowseChildrenRequest request,
CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(request);
ThrowIfDisposed();
return ExecuteSafeUnaryAsync(
token => _transport.BrowseChildrenAsync(request, CreateCallOptions(token)),
cancellationToken);
}
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
{
ArgumentNullException.ThrowIfNull(options);
BrowseChildrenRequest request = new()
{
PageSize = BrowseChildrenPageSize,
AlarmBearingOnly = options.AlarmBearingOnly,
HistorizedOnly = options.HistorizedOnly,
};
request.CategoryIds.Add(options.CategoryIds);
request.TemplateChainContains.Add(options.TemplateChainContains);
if (!string.IsNullOrWhiteSpace(options.TagNameGlob))
{
request.TagNameGlob = options.TagNameGlob;
}
if (options.IncludeAttributes.HasValue)
{
request.IncludeAttributes = options.IncludeAttributes.Value;
}
return request;
}
/// <summary> /// <summary>
/// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the /// Subscribes to Galaxy deploy events. The server emits a bootstrap event with the
/// current state on subscribe so callers can prime their cache, then emits one event /// current state on subscribe so callers can prime their cache, then emits one event
@@ -74,6 +74,23 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
} }
} }
/// <inheritdoc />
public async Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions)
{
try
{
return await RawClient.BrowseChildrenAsync(request, callOptions)
.ResponseAsync
.ConfigureAwait(false);
}
catch (RpcException exception)
{
throw MapRpcException(exception, callOptions.CancellationToken);
}
}
/// <inheritdoc /> /// <inheritdoc />
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync( public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
WatchDeployEventsRequest request, WatchDeployEventsRequest request,
@@ -33,6 +33,13 @@ internal interface IGalaxyRepositoryClientTransport
DiscoverHierarchyRequest request, DiscoverHierarchyRequest request,
CallOptions callOptions); CallOptions callOptions);
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
/// <param name="request">The browse children request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
Task<BrowseChildrenReply> BrowseChildrenAsync(
BrowseChildrenRequest request,
CallOptions callOptions);
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary> /// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
/// <param name="request">The watch deploy events request.</param> /// <param name="request">The watch deploy events request.</param>
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param> /// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
@@ -0,0 +1,101 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client;
/// <summary>
/// One node in a lazy-loaded Galaxy browse tree. Holds the underlying
/// <see cref="GalaxyObject"/> and exposes <see cref="ExpandAsync"/> to fetch
/// its direct children on demand. Expansion is one-shot: a second call is a
/// no-op. Pagination of large sibling sets is handled internally.
/// </summary>
public sealed class LazyBrowseNode
{
private readonly GalaxyRepositoryClient _client;
private readonly BrowseChildrenOptions _options;
private readonly List<LazyBrowseNode> _children = [];
private readonly SemaphoreSlim _expandLock = new(1, 1);
private bool _isExpanded;
internal LazyBrowseNode(
GalaxyRepositoryClient client,
GalaxyObject @object,
bool hasChildrenHint,
BrowseChildrenOptions options)
{
_client = client;
Object = @object;
HasChildrenHint = hasChildrenHint;
_options = options;
}
/// <summary>The underlying Galaxy object for this node.</summary>
public GalaxyObject Object { get; }
/// <summary>True when the server reports this node has at least one matching descendant.</summary>
public bool HasChildrenHint { get; }
/// <summary>Direct children loaded by <see cref="ExpandAsync"/>; empty until then.</summary>
public IReadOnlyList<LazyBrowseNode> Children => _children;
/// <summary>True after the first <see cref="ExpandAsync"/> call completes.</summary>
public bool IsExpanded => _isExpanded;
/// <summary>
/// Fetches direct children from the gateway and populates <see cref="Children"/>.
/// Idempotent: subsequent calls are no-ops.
/// </summary>
/// <remarks>
/// Thread-safe: concurrent callers see exactly one fetch; subsequent callers
/// (after the first completes) return immediately.
/// </remarks>
/// <param name="cancellationToken">Token to observe for cancellation.</param>
public async Task ExpandAsync(CancellationToken cancellationToken = default)
{
if (_isExpanded)
{
return;
}
await _expandLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
if (_isExpanded)
{
return;
}
string pageToken = string.Empty;
HashSet<string> seenPageTokens = new(StringComparer.Ordinal);
do
{
BrowseChildrenRequest request = GalaxyRepositoryClient.BuildBrowseChildrenRequest(_options);
request.ParentGobjectId = Object.GobjectId;
request.PageToken = pageToken;
BrowseChildrenReply reply = await _client
.BrowseChildrenRawAsync(request, cancellationToken)
.ConfigureAwait(false);
for (int i = 0; i < reply.Children.Count; i++)
{
bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i];
_children.Add(new LazyBrowseNode(_client, reply.Children[i], hint, _options));
}
pageToken = reply.NextPageToken;
if (!string.IsNullOrWhiteSpace(pageToken) && !seenPageTokens.Add(pageToken))
{
throw new MxGatewayException(
$"Galaxy BrowseChildren returned a repeated page token '{pageToken}'.");
}
}
while (!string.IsNullOrWhiteSpace(pageToken));
_isExpanded = true;
}
finally
{
_expandLock.Release();
}
}
}
+62
View File
@@ -121,6 +121,68 @@ reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access. populated for direct contract access.
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildren` to walk one level at a
time instead of loading the full hierarchy. Pass an empty request for root
objects; subsequent calls set `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Filter fields match `DiscoverHierarchy`. Each response
pairs `Children` with `ChildHasChildren` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```go
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated/galaxy_repository/v1"
reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
if err != nil {
return err
}
for i, child := range reply.GetChildren() {
fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```go
galaxy, err := mxgateway.DialGalaxy(ctx, mxgateway.Options{
Endpoint: "localhost:5000",
APIKey: os.Getenv("MXGATEWAY_API_KEY"),
Plaintext: true,
})
if err != nil {
log.Fatal(err)
}
defer galaxy.Close()
roots, err := galaxy.Browse(ctx, nil)
if err != nil {
log.Fatal(err)
}
for _, root := range roots {
if root.HasChildrenHint() {
if err := root.Expand(ctx); err != nil {
log.Fatal(err)
}
}
for _, child := range root.Children() {
kind := "leaf"
if child.HasChildrenHint() {
kind = "has children"
}
fmt.Printf("%s (%s)\n", child.Object().GetTagName(), kind)
}
}
```
`Expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`Browse` again from the root.
### Watching deploy events ### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a `WatchDeployEvents` opens a server-streaming subscription. The server emits a
@@ -824,6 +824,260 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
return false return false
} }
type BrowseChildrenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
//
// Types that are valid to be assigned to Parent:
//
// *BrowseChildrenRequest_ParentGobjectId
// *BrowseChildrenRequest_ParentTagName
// *BrowseChildrenRequest_ParentContainedPath
Parent isBrowseChildrenRequest_Parent `protobuf_oneof:"parent"`
// Maximum number of direct children to return. Server default 500; cap 5000.
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
// Opaque token returned by a previous BrowseChildren response. Bound to the
// cache sequence, parent selector, and the filter set; a mismatch returns
// InvalidArgument.
PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
CategoryIds []int32 `protobuf:"varint,6,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
TemplateChainContains []string `protobuf:"bytes,7,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
TagNameGlob string `protobuf:"bytes,8,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
IncludeAttributes *bool `protobuf:"varint,9,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
AlarmBearingOnly bool `protobuf:"varint,10,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
HistorizedOnly bool `protobuf:"varint,11,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenRequest) Reset() {
*x = BrowseChildrenRequest{}
mi := &file_galaxy_repository_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenRequest) ProtoMessage() {}
func (x *BrowseChildrenRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenRequest.ProtoReflect.Descriptor instead.
func (*BrowseChildrenRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{10}
}
func (x *BrowseChildrenRequest) GetParent() isBrowseChildrenRequest_Parent {
if x != nil {
return x.Parent
}
return nil
}
func (x *BrowseChildrenRequest) GetParentGobjectId() int32 {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentGobjectId); ok {
return x.ParentGobjectId
}
}
return 0
}
func (x *BrowseChildrenRequest) GetParentTagName() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentTagName); ok {
return x.ParentTagName
}
}
return ""
}
func (x *BrowseChildrenRequest) GetParentContainedPath() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentContainedPath); ok {
return x.ParentContainedPath
}
}
return ""
}
func (x *BrowseChildrenRequest) GetPageSize() int32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *BrowseChildrenRequest) GetPageToken() string {
if x != nil {
return x.PageToken
}
return ""
}
func (x *BrowseChildrenRequest) GetCategoryIds() []int32 {
if x != nil {
return x.CategoryIds
}
return nil
}
func (x *BrowseChildrenRequest) GetTemplateChainContains() []string {
if x != nil {
return x.TemplateChainContains
}
return nil
}
func (x *BrowseChildrenRequest) GetTagNameGlob() string {
if x != nil {
return x.TagNameGlob
}
return ""
}
func (x *BrowseChildrenRequest) GetIncludeAttributes() bool {
if x != nil && x.IncludeAttributes != nil {
return *x.IncludeAttributes
}
return false
}
func (x *BrowseChildrenRequest) GetAlarmBearingOnly() bool {
if x != nil {
return x.AlarmBearingOnly
}
return false
}
func (x *BrowseChildrenRequest) GetHistorizedOnly() bool {
if x != nil {
return x.HistorizedOnly
}
return false
}
type isBrowseChildrenRequest_Parent interface {
isBrowseChildrenRequest_Parent()
}
type BrowseChildrenRequest_ParentGobjectId struct {
ParentGobjectId int32 `protobuf:"varint,1,opt,name=parent_gobject_id,json=parentGobjectId,proto3,oneof"`
}
type BrowseChildrenRequest_ParentTagName struct {
ParentTagName string `protobuf:"bytes,2,opt,name=parent_tag_name,json=parentTagName,proto3,oneof"`
}
type BrowseChildrenRequest_ParentContainedPath struct {
ParentContainedPath string `protobuf:"bytes,3,opt,name=parent_contained_path,json=parentContainedPath,proto3,oneof"`
}
func (*BrowseChildrenRequest_ParentGobjectId) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentTagName) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentContainedPath) isBrowseChildrenRequest_Parent() {}
type BrowseChildrenReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Direct children matching the filter, sorted areas-first then by
// case-insensitive display name (same order as the dashboard tree).
Children []*GalaxyObject `protobuf:"bytes,1,rep,name=children,proto3" json:"children,omitempty"`
// Non-empty when another page of siblings is available.
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
// Total matching direct children of the parent (post-filter).
TotalChildCount int32 `protobuf:"varint,3,opt,name=total_child_count,json=totalChildCount,proto3" json:"total_child_count,omitempty"`
// Parallel array, indexed with `children`. True when the child has at least
// one matching descendant under the same filter set. Lets a UI choose
// whether to draw an expand triangle without an extra round trip.
ChildHasChildren []bool `protobuf:"varint,4,rep,packed,name=child_has_children,json=childHasChildren,proto3" json:"child_has_children,omitempty"`
// Cache sequence this reply was projected from. Clients may pass it back as
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
CacheSequence uint64 `protobuf:"varint,5,opt,name=cache_sequence,json=cacheSequence,proto3" json:"cache_sequence,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenReply) Reset() {
*x = BrowseChildrenReply{}
mi := &file_galaxy_repository_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenReply) ProtoMessage() {}
func (x *BrowseChildrenReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenReply.ProtoReflect.Descriptor instead.
func (*BrowseChildrenReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{11}
}
func (x *BrowseChildrenReply) GetChildren() []*GalaxyObject {
if x != nil {
return x.Children
}
return nil
}
func (x *BrowseChildrenReply) GetNextPageToken() string {
if x != nil {
return x.NextPageToken
}
return ""
}
func (x *BrowseChildrenReply) GetTotalChildCount() int32 {
if x != nil {
return x.TotalChildCount
}
return 0
}
func (x *BrowseChildrenReply) GetChildHasChildren() []bool {
if x != nil {
return x.ChildHasChildren
}
return nil
}
func (x *BrowseChildrenReply) GetCacheSequence() uint64 {
if x != nil {
return x.CacheSequence
}
return 0
}
var File_galaxy_repository_proto protoreflect.FileDescriptor var File_galaxy_repository_proto protoreflect.FileDescriptor
const file_galaxy_repository_proto_rawDesc = "" + const file_galaxy_repository_proto_rawDesc = "" +
@@ -897,12 +1151,35 @@ const file_galaxy_repository_proto_rawDesc = "" +
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" + "\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
"\ris_historized\x18\n" + "\ris_historized\x18\n" +
" \x01(\bR\fisHistorized\x12\x19\n" + " \x01(\bR\fisHistorized\x12\x19\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" + "\bis_alarm\x18\v \x01(\bR\aisAlarm\"\x8c\x04\n" +
"\x15BrowseChildrenRequest\x12,\n" +
"\x11parent_gobject_id\x18\x01 \x01(\x05H\x00R\x0fparentGobjectId\x12(\n" +
"\x0fparent_tag_name\x18\x02 \x01(\tH\x00R\rparentTagName\x124\n" +
"\x15parent_contained_path\x18\x03 \x01(\tH\x00R\x13parentContainedPath\x12\x1b\n" +
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" +
"\n" +
"page_token\x18\x05 \x01(\tR\tpageToken\x12!\n" +
"\fcategory_ids\x18\x06 \x03(\x05R\vcategoryIds\x126\n" +
"\x17template_chain_contains\x18\a \x03(\tR\x15templateChainContains\x12\"\n" +
"\rtag_name_glob\x18\b \x01(\tR\vtagNameGlob\x122\n" +
"\x12include_attributes\x18\t \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
"\x12alarm_bearing_only\x18\n" +
" \x01(\bR\x10alarmBearingOnly\x12'\n" +
"\x0fhistorized_only\x18\v \x01(\bR\x0ehistorizedOnlyB\b\n" +
"\x06parentB\x15\n" +
"\x13_include_attributes\"\xfe\x01\n" +
"\x13BrowseChildrenReply\x12>\n" +
"\bchildren\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\bchildren\x12&\n" +
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12*\n" +
"\x11total_child_count\x18\x03 \x01(\x05R\x0ftotalChildCount\x12,\n" +
"\x12child_has_children\x18\x04 \x03(\bR\x10childHasChildren\x12%\n" +
"\x0ecache_sequence\x18\x05 \x01(\x04R\rcacheSequence2\xb6\x04\n" +
"\x10GalaxyRepository\x12h\n" + "\x10GalaxyRepository\x12h\n" +
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" + "\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" + "\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" + "\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3" "\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n" +
"\x0eBrowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var ( var (
file_galaxy_repository_proto_rawDescOnce sync.Once file_galaxy_repository_proto_rawDescOnce sync.Once
@@ -916,7 +1193,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
return file_galaxy_repository_proto_rawDescData return file_galaxy_repository_proto_rawDescData
} }
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_galaxy_repository_proto_goTypes = []any{ var file_galaxy_repository_proto_goTypes = []any{
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest (*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply (*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
@@ -928,30 +1205,35 @@ var file_galaxy_repository_proto_goTypes = []any{
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent (*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject (*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute (*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp (*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value (*BrowseChildrenReply)(nil), // 11: galaxy_repository.v1.BrowseChildrenReply
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
(*wrapperspb.Int32Value)(nil), // 13: google.protobuf.Int32Value
} }
var file_galaxy_repository_proto_depIdxs = []int32{ var file_galaxy_repository_proto_depIdxs = []int32{
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp 12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value 13, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject 8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp 12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp 12, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp 12, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute 9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest 8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest 0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest 2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest 4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply 6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply 10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply 1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent 3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
11, // [11:15] is the sub-list for method output_type 5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // [7:11] is the sub-list for method input_type 7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
7, // [7:7] is the sub-list for extension type_name 11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
7, // [7:7] is the sub-list for extension extendee 13, // [13:18] is the sub-list for method output_type
0, // [0:7] is the sub-list for field type_name 8, // [8:13] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
} }
func init() { file_galaxy_repository_proto_init() } func init() { file_galaxy_repository_proto_init() }
@@ -964,13 +1246,18 @@ func file_galaxy_repository_proto_init() {
(*DiscoverHierarchyRequest_RootTagName)(nil), (*DiscoverHierarchyRequest_RootTagName)(nil),
(*DiscoverHierarchyRequest_RootContainedPath)(nil), (*DiscoverHierarchyRequest_RootContainedPath)(nil),
} }
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
(*BrowseChildrenRequest_ParentGobjectId)(nil),
(*BrowseChildrenRequest_ParentTagName)(nil),
(*BrowseChildrenRequest_ParentContainedPath)(nil),
}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 10, NumMessages: 12,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1 // - protoc v7.34.1
// source: galaxy_repository.proto // source: galaxy_repository.proto
@@ -23,6 +23,7 @@ const (
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime" GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy" GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents" GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
) )
// GalaxyRepositoryClient is the client API for GalaxyRepository service. // GalaxyRepositoryClient is the client API for GalaxyRepository service.
@@ -44,6 +45,11 @@ type GalaxyRepositoryClient interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped // increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow. // older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error) WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error)
} }
type galaxyRepositoryClient struct { type galaxyRepositoryClient struct {
@@ -103,6 +109,16 @@ func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *Watc
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent] type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
func (c *galaxyRepositoryClient) BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BrowseChildrenReply)
err := c.cc.Invoke(ctx, GalaxyRepository_BrowseChildren_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GalaxyRepositoryServer is the server API for GalaxyRepository service. // GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer // All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility. // for forward compatibility.
@@ -122,6 +138,11 @@ type GalaxyRepositoryServer interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped // increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow. // older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error)
mustEmbedUnimplementedGalaxyRepositoryServer() mustEmbedUnimplementedGalaxyRepositoryServer()
} }
@@ -144,6 +165,9 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error { func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented") return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
} }
func (UnimplementedGalaxyRepositoryServer) BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error) {
return nil, status.Error(codes.Unimplemented, "method BrowseChildren not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {} func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {} func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
@@ -230,6 +254,24 @@ func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.Se
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent] type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
func _GalaxyRepository_BrowseChildren_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BrowseChildrenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_BrowseChildren_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, req.(*BrowseChildrenRequest))
}
return interceptor(ctx, in, info, handler)
}
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service. // GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -249,6 +291,10 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
MethodName: "DiscoverHierarchy", MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler, Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
}, },
{
MethodName: "BrowseChildren",
Handler: _GalaxyRepository_BrowseChildren_Handler,
},
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
@@ -725,9 +725,10 @@ func (SessionState) EnumDescriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8} return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8}
} }
// Public request shape for QueryActiveAlarms. session_id is currently unused // Public request shape for QueryActiveAlarms.
// (the snapshot is session-less) but reserved so a future per-session view // Clients may leave `session_id` empty; the gateway currently ignores it and
// can be added without a wire break. // serves the session-less central-monitor cache. A future version may use it
// to scope the snapshot to one session.
type QueryActiveAlarmsRequest struct { type QueryActiveAlarmsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1 // - protoc v7.34.1
// source: mxaccess_gateway.proto // source: mxaccess_gateway.proto
@@ -50,6 +50,9 @@ type MxAccessGatewayClient interface {
// reconnect to seed Part 9 client state, or to reconcile alarms that may // reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can // have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set. // begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
} }
@@ -180,6 +183,9 @@ type MxAccessGatewayServer interface {
// reconnect to seed Part 9 client state, or to reconcile alarms that may // reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can // have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set. // begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
mustEmbedUnimplementedMxAccessGatewayServer() mustEmbedUnimplementedMxAccessGatewayServer()
} }
+143
View File
@@ -3,7 +3,9 @@ package mxgateway
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io" "io"
"sync"
"time" "time"
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
@@ -13,6 +15,9 @@ import (
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
// browseChildrenPageSize is the per-request page size used by the lazy walker.
const browseChildrenPageSize = 500
// RawGalaxyRepositoryClient is the generated gRPC client interface for the // RawGalaxyRepositoryClient is the generated gRPC client interface for the
// Galaxy Repository service exposed for callers that need direct contract // Galaxy Repository service exposed for callers that need direct contract
// access. // access.
@@ -40,6 +45,10 @@ type (
WatchDeployEventsRequest = pb.WatchDeployEventsRequest WatchDeployEventsRequest = pb.WatchDeployEventsRequest
// DeployEvent is one Galaxy Repository deploy event. // DeployEvent is one Galaxy Repository deploy event.
DeployEvent = pb.DeployEvent DeployEvent = pb.DeployEvent
// BrowseChildrenRequest is the request for BrowseChildren.
BrowseChildrenRequest = pb.BrowseChildrenRequest
// BrowseChildrenReply is the reply for BrowseChildren.
BrowseChildrenReply = pb.BrowseChildrenReply
) )
// RawDeployEventStream is the generated WatchDeployEvents client stream. // RawDeployEventStream is the generated WatchDeployEvents client stream.
@@ -238,6 +247,140 @@ func (c *GalaxyClient) Close() error {
return c.conn.Close() return c.conn.Close()
} }
// LazyBrowseNode is one node in a lazy Galaxy hierarchy walk produced by
// (*GalaxyClient).Browse. Children are not fetched until Expand is called.
// The node is safe for concurrent use; concurrent Expand calls collapse to a
// single RPC.
type LazyBrowseNode struct {
client *GalaxyClient
object *pb.GalaxyObject
hasChildrenHint bool
options BrowseChildrenOptions
mu sync.Mutex
children []*LazyBrowseNode
isExpanded bool
}
// Object returns the underlying GalaxyObject describing this node.
func (n *LazyBrowseNode) Object() *pb.GalaxyObject { return n.object }
// HasChildrenHint reports the server-supplied hint on whether this node has
// matching descendants under the current filter set.
func (n *LazyBrowseNode) HasChildrenHint() bool { return n.hasChildrenHint }
// Children returns a snapshot copy of the currently-loaded child nodes. Returns
// an empty slice when Expand has not yet been called.
func (n *LazyBrowseNode) Children() []*LazyBrowseNode {
n.mu.Lock()
defer n.mu.Unlock()
out := make([]*LazyBrowseNode, len(n.children))
copy(out, n.children)
return out
}
// IsExpanded reports whether Expand has completed successfully on this node.
func (n *LazyBrowseNode) IsExpanded() bool {
n.mu.Lock()
defer n.mu.Unlock()
return n.isExpanded
}
// Expand fetches this node's direct children via BrowseChildren when they have
// not yet been loaded. Subsequent calls after a successful Expand are a no-op
// and do not issue another RPC.
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
n.mu.Lock()
defer n.mu.Unlock()
if n.isExpanded {
return nil
}
parentID := n.object.GetGobjectId()
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
if err != nil {
return err
}
n.children = children
n.isExpanded = true
return nil
}
// Browse returns the root nodes of the Galaxy hierarchy. The returned nodes
// have only their server-supplied hints populated; call Expand on each node to
// fetch its direct children. When opts is nil the server defaults apply.
func (c *GalaxyClient) Browse(ctx context.Context, opts *BrowseChildrenOptions) ([]*LazyBrowseNode, error) {
effective := BrowseChildrenOptions{}
if opts != nil {
effective = *opts
}
return c.browseChildrenInner(ctx, nil, effective)
}
// BrowseChildrenRaw issues a single BrowseChildren RPC and returns the raw
// reply for callers that need direct page-token control. Transport-level
// failures are wrapped in *GatewayError to match the rest of the client.
func (c *GalaxyClient) BrowseChildrenRaw(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
callCtx, cancel := c.callContext(ctx)
defer cancel()
reply, err := c.raw.BrowseChildren(callCtx, req)
if err != nil {
return nil, &GatewayError{Op: "galaxy browse children", Err: err}
}
return reply, nil
}
func (c *GalaxyClient) browseChildrenInner(
ctx context.Context,
parentGobjectID *int32,
opts BrowseChildrenOptions,
) ([]*LazyBrowseNode, error) {
var nodes []*LazyBrowseNode
pageToken := ""
seen := map[string]struct{}{}
for {
req := &pb.BrowseChildrenRequest{
PageSize: browseChildrenPageSize,
PageToken: pageToken,
CategoryIds: opts.CategoryIds,
TemplateChainContains: opts.TemplateChainContains,
TagNameGlob: opts.TagNameGlob,
AlarmBearingOnly: opts.AlarmBearingOnly,
HistorizedOnly: opts.HistorizedOnly,
}
if parentGobjectID != nil {
req.Parent = &pb.BrowseChildrenRequest_ParentGobjectId{ParentGobjectId: *parentGobjectID}
}
if opts.IncludeAttributes != nil {
req.IncludeAttributes = opts.IncludeAttributes
}
reply, err := c.BrowseChildrenRaw(ctx, req)
if err != nil {
return nil, err
}
for i, child := range reply.GetChildren() {
hasChildren := reply.GetChildHasChildren()
hint := i < len(hasChildren) && hasChildren[i]
nodes = append(nodes, &LazyBrowseNode{
client: c,
object: child,
hasChildrenHint: hint,
options: opts,
})
}
pageToken = reply.GetNextPageToken()
if pageToken == "" {
return nodes, nil
}
if _, dup := seen[pageToken]; dup {
return nil, fmt.Errorf("mxgateway: galaxy browse children returned repeated page token %q", pageToken)
}
seen[pageToken] = struct{}{}
}
}
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) { func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
timeout := c.opts.CallTimeout timeout := c.opts.CallTimeout
if timeout == 0 { if timeout == 0 {
+322 -9
View File
@@ -9,6 +9,8 @@ import (
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated" pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
"google.golang.org/grpc" "google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn" "google.golang.org/grpc/test/bufconn"
"google.golang.org/protobuf/types/known/timestamppb" "google.golang.org/protobuf/types/known/timestamppb"
) )
@@ -370,15 +372,18 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
type fakeGalaxyServer struct { type fakeGalaxyServer struct {
pb.UnimplementedGalaxyRepositoryServer pb.UnimplementedGalaxyRepositoryServer
testReply *pb.TestConnectionReply testReply *pb.TestConnectionReply
testAuth string testAuth string
failTest bool failTest bool
deployReply *pb.GetLastDeployTimeReply deployReply *pb.GetLastDeployTimeReply
discoverReply *pb.DiscoverHierarchyReply discoverReply *pb.DiscoverHierarchyReply
watchEvents []*pb.DeployEvent watchEvents []*pb.DeployEvent
watchRequest *pb.WatchDeployEventsRequest watchRequest *pb.WatchDeployEventsRequest
watchSendInterval time.Duration watchSendInterval time.Duration
watchHoldOpen bool watchHoldOpen bool
browseChildrenCalls []*pb.BrowseChildrenRequest
browseChildrenReplies []*pb.BrowseChildrenReply
browseChildrenError error
} }
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) { func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
@@ -425,3 +430,311 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
} }
return nil return nil
} }
func (s *fakeGalaxyServer) BrowseChildren(ctx context.Context, req *pb.BrowseChildrenRequest) (*pb.BrowseChildrenReply, error) {
s.browseChildrenCalls = append(s.browseChildrenCalls, req)
if s.browseChildrenError != nil {
err := s.browseChildrenError
s.browseChildrenError = nil
return nil, err
}
if len(s.browseChildrenReplies) == 0 {
return &pb.BrowseChildrenReply{}, nil
}
reply := s.browseChildrenReplies[0]
s.browseChildrenReplies = s.browseChildrenReplies[1:]
return reply, nil
}
func obj(id int32, tag string, isArea bool) *pb.GalaxyObject {
return &pb.GalaxyObject{
GobjectId: id,
TagName: tag,
BrowseName: tag,
IsArea: isArea,
}
}
func buildBrowseReply(children []*pb.GalaxyObject, hasChildren []bool, seq uint64) *pb.BrowseChildrenReply {
return &pb.BrowseChildrenReply{
TotalChildCount: int32(len(children)),
CacheSequence: seq,
Children: children,
ChildHasChildren: hasChildren,
}
}
func TestGalaxyBrowseNoParentReturnsRoots(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true), obj(99, "Other", false)},
[]bool{true, false},
7,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if got, want := len(roots), 2; got != want {
t.Fatalf("len(roots) = %d, want %d", got, want)
}
if roots[0].Object().GetTagName() != "Plant" {
t.Fatalf("roots[0].TagName = %q", roots[0].Object().GetTagName())
}
if !roots[0].HasChildrenHint() {
t.Fatal("roots[0].HasChildrenHint = false, want true")
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true, want false")
}
if roots[1].HasChildrenHint() {
t.Fatal("roots[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
if fake.browseChildrenCalls[0].GetParent() != nil {
t.Fatalf("root browse should not set Parent oneof, got %T", fake.browseChildrenCalls[0].GetParent())
}
}
func TestGalaxyBrowseExpandPopulatesChildrenAndMarksExpanded(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true), obj(11, "Tank1", false)},
[]bool{true, false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
plant := roots[0]
if plant.IsExpanded() {
t.Fatal("plant.IsExpanded = true before Expand, want false")
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
if !plant.IsExpanded() {
t.Fatal("plant.IsExpanded = false after Expand, want true")
}
children := plant.Children()
if len(children) != 2 {
t.Fatalf("len(children) = %d, want 2", len(children))
}
if children[0].Object().GetTagName() != "Area1" {
t.Fatalf("children[0].TagName = %q, want Area1", children[0].Object().GetTagName())
}
if !children[0].HasChildrenHint() {
t.Fatal("children[0].HasChildrenHint = false, want true")
}
if children[1].HasChildrenHint() {
t.Fatal("children[1].HasChildrenHint = true, want false")
}
if len(fake.browseChildrenCalls) != 2 {
t.Fatalf("BrowseChildren calls = %d, want 2", len(fake.browseChildrenCalls))
}
parent := fake.browseChildrenCalls[1].GetParent()
parentGobj, ok := parent.(*pb.BrowseChildrenRequest_ParentGobjectId)
if !ok {
t.Fatalf("Parent oneof = %T, want *BrowseChildrenRequest_ParentGobjectId", parent)
}
if parentGobj.ParentGobjectId != 1 {
t.Fatalf("ParentGobjectId = %d, want 1", parentGobj.ParentGobjectId)
}
}
func TestGalaxyBrowseExpandIdempotentNoSecondRpc(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Area1", true)},
[]bool{false},
1,
),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
plant := roots[0]
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #1: %v", err)
}
callsAfterFirst := len(fake.browseChildrenCalls)
if callsAfterFirst != 2 {
t.Fatalf("BrowseChildren calls after first Expand = %d, want 2", callsAfterFirst)
}
if err := plant.Expand(context.Background()); err != nil {
t.Fatalf("Expand #2: %v", err)
}
if got := len(fake.browseChildrenCalls); got != callsAfterFirst {
t.Fatalf("BrowseChildren calls after second Expand = %d, want %d (no extra RPC)", got, callsAfterFirst)
}
}
func TestGalaxyBrowseExpandUnknownParentReturnsNotFoundError(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
1,
),
},
browseChildrenError: status.Error(codes.NotFound, "parent not found"),
}
// The first Browse() consumes the first reply; the next call (Expand) will
// then hit browseChildrenError. We need the error to fire only on the second
// call, so seed the reply first and let the call sequence consume them in
// order. Because BrowseChildren in the fake consumes browseChildrenError
// before falling through to replies, swap the strategy: keep the root reply
// but have BrowseChildren return the error on the second call. We do this by
// emptying the reply list after the first Browse.
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
// First call returns the error (because browseChildrenError takes precedence).
// To avoid that, clear it for the root call by performing a manual setup: we
// pre-stage replies first, then set the error after the first call. Easiest:
// pre-Browse() with error=nil, then set error before Expand.
fake.browseChildrenError = nil
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if len(roots) != 1 {
t.Fatalf("len(roots) = %d, want 1", len(roots))
}
fake.browseChildrenError = status.Error(codes.NotFound, "parent not found")
err = roots[0].Expand(context.Background())
if err == nil {
t.Fatal("Expand: error = nil, want NotFound")
}
if status.Code(err) != codes.NotFound {
t.Fatalf("status.Code = %s, want NotFound", status.Code(err))
}
if roots[0].IsExpanded() {
t.Fatal("roots[0].IsExpanded = true after failed Expand, want false")
}
}
func TestGalaxyBrowseExpandMultiPageGathersAllPages(t *testing.T) {
firstPage := buildBrowseReply(
[]*pb.GalaxyObject{obj(1, "Plant", true)},
[]bool{true},
7,
)
pageA := buildBrowseReply(
[]*pb.GalaxyObject{obj(10, "Child1", false), obj(11, "Child2", false)},
[]bool{false, false},
7,
)
pageA.NextPageToken = "7:abc:2"
pageB := buildBrowseReply(
[]*pb.GalaxyObject{obj(12, "Child3", false)},
[]bool{false},
7,
)
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{firstPage, pageA, pageB},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
roots, err := client.Browse(context.Background(), nil)
if err != nil {
t.Fatalf("Browse: %v", err)
}
if err := roots[0].Expand(context.Background()); err != nil {
t.Fatalf("Expand: %v", err)
}
children := roots[0].Children()
if len(children) != 3 {
t.Fatalf("len(children) = %d, want 3", len(children))
}
if len(fake.browseChildrenCalls) != 3 {
t.Fatalf("BrowseChildren calls = %d, want 3", len(fake.browseChildrenCalls))
}
if got := fake.browseChildrenCalls[2].GetPageToken(); got != "7:abc:2" {
t.Fatalf("third call PageToken = %q, want %q", got, "7:abc:2")
}
}
func TestGalaxyBrowseWithFilterForwardsToRequest(t *testing.T) {
fake := &fakeGalaxyServer{
browseChildrenReplies: []*pb.BrowseChildrenReply{
buildBrowseReply(nil, nil, 1),
},
}
client, cleanup := newGalaxyBufconnClient(t, fake)
defer cleanup()
include := true
opts := &BrowseChildrenOptions{
CategoryIds: []int32{7, 9},
TemplateChainContains: []string{"$AppObject"},
TagNameGlob: "Tank*",
IncludeAttributes: &include,
AlarmBearingOnly: true,
HistorizedOnly: true,
}
if _, err := client.Browse(context.Background(), opts); err != nil {
t.Fatalf("Browse: %v", err)
}
if len(fake.browseChildrenCalls) != 1 {
t.Fatalf("BrowseChildren calls = %d, want 1", len(fake.browseChildrenCalls))
}
got := fake.browseChildrenCalls[0]
if want := []int32{7, 9}; len(got.GetCategoryIds()) != 2 || got.GetCategoryIds()[0] != want[0] || got.GetCategoryIds()[1] != want[1] {
t.Fatalf("CategoryIds = %v, want %v", got.GetCategoryIds(), want)
}
if want := []string{"$AppObject"}; len(got.GetTemplateChainContains()) != 1 || got.GetTemplateChainContains()[0] != want[0] {
t.Fatalf("TemplateChainContains = %v, want %v", got.GetTemplateChainContains(), want)
}
if got.GetTagNameGlob() != "Tank*" {
t.Fatalf("TagNameGlob = %q, want %q", got.GetTagNameGlob(), "Tank*")
}
if !got.GetIncludeAttributes() {
t.Fatal("IncludeAttributes = false, want true")
}
if !got.GetAlarmBearingOnly() {
t.Fatal("AlarmBearingOnly = false, want true")
}
if !got.GetHistorizedOnly() {
t.Fatal("HistorizedOnly = false, want true")
}
}
+22
View File
@@ -36,6 +36,28 @@ type Options struct {
DialOptions []grpc.DialOption DialOptions []grpc.DialOption
} }
// BrowseChildrenOptions configures lazy Galaxy hierarchy walks performed by
// (*GalaxyClient).Browse and (*LazyBrowseNode).Expand. All fields are optional;
// the zero value matches the dashboard default (no filters, all attributes per
// the server default).
type BrowseChildrenOptions struct {
// CategoryIds restricts results to the listed Galaxy category ids when set.
CategoryIds []int32
// TemplateChainContains restricts results to objects whose template chain
// contains any of the listed template tag names.
TemplateChainContains []string
// TagNameGlob restricts results to objects whose tag name matches the glob
// pattern when non-empty.
TagNameGlob string
// IncludeAttributes overrides the server default for attribute inclusion when
// non-nil. The pointer form mirrors the proto's optional field.
IncludeAttributes *bool
// AlarmBearingOnly limits results to alarm-bearing objects when true.
AlarmBearingOnly bool
// HistorizedOnly limits results to historized objects when true.
HistorizedOnly bool
}
// RedactedAPIKey returns a display-safe representation of the configured API // RedactedAPIKey returns a display-safe representation of the configured API
// key for diagnostics and CLI output. // key for diagnostics and CLI output.
func (o Options) RedactedAPIKey() string { func (o Options) RedactedAPIKey() string {
+53
View File
@@ -116,6 +116,59 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localh
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
``` ```
### Browsing lazily
For UI trees or OPC UA bridges, use `browseChildren` to walk one level at a
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
default request for root objects; subsequent calls set `parentGobjectId`,
`parentTagName`, or `parentContainedPath`. Filter fields match
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
`getChildHasChildrenList()` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics. This snippet documents the API as it appears once
the Java client is regenerated on the Windows host.
```java
BrowseChildrenReply reply = galaxy.browseChildren(
BrowseChildrenRequest.newBuilder().build());
List<GalaxyObject> children = reply.getChildrenList();
List<Boolean> hasChildren = reply.getChildHasChildrenList();
for (int i = 0; i < children.size(); i++) {
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```java
MxGatewayClientOptions options = MxGatewayClientOptions.builder()
.endpoint("localhost:5000")
.apiKey(System.getenv("MXGATEWAY_API_KEY"))
.plaintext(true)
.build();
try (GalaxyRepositoryClient galaxy = GalaxyRepositoryClient.connect(options)) {
List<LazyBrowseNode> roots = galaxy.browse();
for (LazyBrowseNode root : roots) {
if (root.hasChildrenHint()) {
root.expand();
}
for (LazyBrowseNode child : root.getChildren()) {
String kind = child.hasChildrenHint() ? "has children" : "leaf";
System.out.println(child.getObject().getTagName() + " (" + kind + ")");
}
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events ### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway `GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
@@ -142,6 +142,37 @@ public final class GalaxyRepositoryGrpc {
return getWatchDeployEventsMethod; return getWatchDeployEventsMethod;
} }
private static volatile io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod;
@io.grpc.stub.annotations.RpcMethod(
fullMethodName = SERVICE_NAME + '/' + "BrowseChildren",
requestType = galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest.class,
responseType = galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply.class,
methodType = io.grpc.MethodDescriptor.MethodType.UNARY)
public static io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod() {
io.grpc.MethodDescriptor<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> getBrowseChildrenMethod;
if ((getBrowseChildrenMethod = GalaxyRepositoryGrpc.getBrowseChildrenMethod) == null) {
synchronized (GalaxyRepositoryGrpc.class) {
if ((getBrowseChildrenMethod = GalaxyRepositoryGrpc.getBrowseChildrenMethod) == null) {
GalaxyRepositoryGrpc.getBrowseChildrenMethod = getBrowseChildrenMethod =
io.grpc.MethodDescriptor.<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>newBuilder()
.setType(io.grpc.MethodDescriptor.MethodType.UNARY)
.setFullMethodName(generateFullMethodName(SERVICE_NAME, "BrowseChildren"))
.setSampledToLocalTracing(true)
.setRequestMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest.getDefaultInstance()))
.setResponseMarshaller(io.grpc.protobuf.ProtoUtils.marshaller(
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply.getDefaultInstance()))
.setSchemaDescriptor(new GalaxyRepositoryMethodDescriptorSupplier("BrowseChildren"))
.build();
}
}
}
return getBrowseChildrenMethod;
}
/** /**
* Creates a new async stub that supports all call types for the service * Creates a new async stub that supports all call types for the service
*/ */
@@ -246,6 +277,19 @@ public final class GalaxyRepositoryGrpc {
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) { io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver); io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getWatchDeployEventsMethod(), responseObserver);
} }
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
default void browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> responseObserver) {
io.grpc.stub.ServerCalls.asyncUnimplementedUnaryCall(getBrowseChildrenMethod(), responseObserver);
}
} }
/** /**
@@ -326,6 +370,20 @@ public final class GalaxyRepositoryGrpc {
io.grpc.stub.ClientCalls.asyncServerStreamingCall( io.grpc.stub.ClientCalls.asyncServerStreamingCall(
getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver); getChannel().newCall(getWatchDeployEventsMethod(), getCallOptions()), request, responseObserver);
} }
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public void browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request,
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> responseObserver) {
io.grpc.stub.ClientCalls.asyncUnaryCall(
getChannel().newCall(getBrowseChildrenMethod(), getCallOptions()), request, responseObserver);
}
} }
/** /**
@@ -387,6 +445,19 @@ public final class GalaxyRepositoryGrpc {
return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall( return io.grpc.stub.ClientCalls.blockingV2ServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request); getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
} }
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) throws io.grpc.StatusException {
return io.grpc.stub.ClientCalls.blockingV2UnaryCall(
getChannel(), getBrowseChildrenMethod(), getCallOptions(), request);
}
} }
/** /**
@@ -447,6 +518,19 @@ public final class GalaxyRepositoryGrpc {
return io.grpc.stub.ClientCalls.blockingServerStreamingCall( return io.grpc.stub.ClientCalls.blockingServerStreamingCall(
getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request); getChannel(), getWatchDeployEventsMethod(), getCallOptions(), request);
} }
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply browseChildren(galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) {
return io.grpc.stub.ClientCalls.blockingUnaryCall(
getChannel(), getBrowseChildrenMethod(), getCallOptions(), request);
}
} }
/** /**
@@ -494,12 +578,27 @@ public final class GalaxyRepositoryGrpc {
return io.grpc.stub.ClientCalls.futureUnaryCall( return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request); getChannel().newCall(getDiscoverHierarchyMethod(), getCallOptions()), request);
} }
/**
* <pre>
* Returns the direct children of a parent object (or the root objects when
* `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
* one level at a time instead of paging the full hierarchy. Filters mirror
* DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
* </pre>
*/
public com.google.common.util.concurrent.ListenableFuture<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply> browseChildren(
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest request) {
return io.grpc.stub.ClientCalls.futureUnaryCall(
getChannel().newCall(getBrowseChildrenMethod(), getCallOptions()), request);
}
} }
private static final int METHODID_TEST_CONNECTION = 0; private static final int METHODID_TEST_CONNECTION = 0;
private static final int METHODID_GET_LAST_DEPLOY_TIME = 1; private static final int METHODID_GET_LAST_DEPLOY_TIME = 1;
private static final int METHODID_DISCOVER_HIERARCHY = 2; private static final int METHODID_DISCOVER_HIERARCHY = 2;
private static final int METHODID_WATCH_DEPLOY_EVENTS = 3; private static final int METHODID_WATCH_DEPLOY_EVENTS = 3;
private static final int METHODID_BROWSE_CHILDREN = 4;
private static final class MethodHandlers<Req, Resp> implements private static final class MethodHandlers<Req, Resp> implements
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>, io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
@@ -534,6 +633,10 @@ public final class GalaxyRepositoryGrpc {
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request, serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver); (io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
break; break;
case METHODID_BROWSE_CHILDREN:
serviceImpl.browseChildren((galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest) request,
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>) responseObserver);
break;
default: default:
throw new AssertionError(); throw new AssertionError();
} }
@@ -580,6 +683,13 @@ public final class GalaxyRepositoryGrpc {
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest, galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>( galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
service, METHODID_WATCH_DEPLOY_EVENTS))) service, METHODID_WATCH_DEPLOY_EVENTS)))
.addMethod(
getBrowseChildrenMethod(),
io.grpc.stub.ServerCalls.asyncUnaryCall(
new MethodHandlers<
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest,
galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply>(
service, METHODID_BROWSE_CHILDREN)))
.build(); .build();
} }
@@ -632,6 +742,7 @@ public final class GalaxyRepositoryGrpc {
.addMethod(getGetLastDeployTimeMethod()) .addMethod(getGetLastDeployTimeMethod())
.addMethod(getDiscoverHierarchyMethod()) .addMethod(getDiscoverHierarchyMethod())
.addMethod(getWatchDeployEventsMethod()) .addMethod(getWatchDeployEventsMethod())
.addMethod(getBrowseChildrenMethod())
.build(); .build();
} }
} }
@@ -0,0 +1,105 @@
package com.zb.mom.ww.mxgateway.client;
import java.util.Collections;
import java.util.List;
/**
* Filters and shape options for {@link GalaxyRepositoryClient#browse(BrowseChildrenOptions)}.
* Mirror of the existing DiscoverHierarchy options for the lazy-browse path.
*
* <p>All filter fields are AND-combined server-side. Empty / unset fields disable
* that filter. The {@code includeAttributes} tri-state uses {@code null} to mean
* "let the server use its default"; non-{@code null} forwards the explicit flag.
*/
public final class BrowseChildrenOptions {
private final List<Integer> categoryIds;
private final List<String> templateChainContains;
private final String tagNameGlob;
private final Boolean includeAttributes;
private final boolean alarmBearingOnly;
private final boolean historizedOnly;
private BrowseChildrenOptions(Builder b) {
this.categoryIds = List.copyOf(b.categoryIds);
this.templateChainContains = List.copyOf(b.templateChainContains);
this.tagNameGlob = b.tagNameGlob;
this.includeAttributes = b.includeAttributes;
this.alarmBearingOnly = b.alarmBearingOnly;
this.historizedOnly = b.historizedOnly;
}
/** @return immutable list of category IDs to include; empty disables this filter. */
public List<Integer> getCategoryIds() { return categoryIds; }
/** @return immutable list of template names that must appear in each child's template chain. */
public List<String> getTemplateChainContains() { return templateChainContains; }
/** @return SQL-LIKE-style glob applied to {@code tag_name}; empty disables. */
public String getTagNameGlob() { return tagNameGlob; }
/** @return tri-state override for {@code include_attributes}; {@code null} keeps the server default. */
public Boolean getIncludeAttributes() { return includeAttributes; }
/** @return restrict to alarm-bearing objects. */
public boolean isAlarmBearingOnly() { return alarmBearingOnly; }
/** @return restrict to objects with at least one historized attribute. */
public boolean isHistorizedOnly() { return historizedOnly; }
/** @return a fresh builder. */
public static Builder builder() { return new Builder(); }
/** @return options with every filter disabled and {@code includeAttributes} unset. */
public static BrowseChildrenOptions empty() { return builder().build(); }
/** Fluent builder for {@link BrowseChildrenOptions}. */
public static final class Builder {
private List<Integer> categoryIds = Collections.emptyList();
private List<String> templateChainContains = Collections.emptyList();
private String tagNameGlob = "";
private Boolean includeAttributes = null;
private boolean alarmBearingOnly = false;
private boolean historizedOnly = false;
/** Sets the category-id filter. */
public Builder categoryIds(List<Integer> v) {
this.categoryIds = v == null ? Collections.emptyList() : v;
return this;
}
/** Sets the template-chain-contains filter. */
public Builder templateChainContains(List<String> v) {
this.templateChainContains = v == null ? Collections.emptyList() : v;
return this;
}
/** Sets the tag-name glob. */
public Builder tagNameGlob(String v) {
this.tagNameGlob = v == null ? "" : v;
return this;
}
/** Sets the tri-state {@code includeAttributes} override; {@code null} keeps the server default. */
public Builder includeAttributes(Boolean v) {
this.includeAttributes = v;
return this;
}
/** Toggles the alarm-bearing-only filter. */
public Builder alarmBearingOnly(boolean v) {
this.alarmBearingOnly = v;
return this;
}
/** Toggles the historized-only filter. */
public Builder historizedOnly(boolean v) {
this.historizedOnly = v;
return this;
}
/** Builds the immutable options. */
public BrowseChildrenOptions build() {
return new BrowseChildrenOptions(this);
}
}
}
@@ -4,6 +4,8 @@ import com.google.common.util.concurrent.FutureCallback;
import com.google.common.util.concurrent.Futures; import com.google.common.util.concurrent.Futures;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
import galaxy_repository.v1.GalaxyRepositoryGrpc; import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
@@ -37,6 +39,7 @@ import javax.net.ssl.SSLException;
*/ */
public final class GalaxyRepositoryClient implements AutoCloseable { public final class GalaxyRepositoryClient implements AutoCloseable {
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000; private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
private static final int BROWSE_CHILDREN_PAGE_SIZE = 500;
private final ManagedChannel ownedChannel; private final ManagedChannel ownedChannel;
private final MxGatewayClientOptions options; private final MxGatewayClientOptions options;
@@ -213,6 +216,98 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>()); return discoverHierarchyPageAsync("", new java.util.ArrayList<>(), new java.util.HashSet<>());
} }
/**
* Lazy-browse entry point: fetches the root layer of the Galaxy hierarchy.
* Each returned {@link LazyBrowseNode} can be expanded on demand via
* {@link LazyBrowseNode#expand()} to load its direct children.
*
* @return the root nodes (no parent selector) with default options
* @throws MxGatewayException on transport or protocol failure
*/
public List<LazyBrowseNode> browse() {
return browse(null);
}
/**
* Lazy-browse entry point with caller-supplied filters / shape.
*
* @param options filter and shape options; {@code null} means {@link BrowseChildrenOptions#empty()}
* @return the root nodes matching the options
* @throws MxGatewayException on transport or protocol failure
*/
public List<LazyBrowseNode> browse(BrowseChildrenOptions options) {
BrowseChildrenOptions effective = options == null ? BrowseChildrenOptions.empty() : options;
return browseChildrenInner(null, effective);
}
/**
* Issues a single {@code BrowseChildren} RPC and returns the raw reply.
* Callers wanting full control over pagination can drive the loop themselves.
*
* @param request the request to send
* @return the reply
* @throws MxGatewayException on transport or protocol failure
*/
public BrowseChildrenReply browseChildrenRaw(BrowseChildrenRequest request) {
try {
return rawBlockingStub().browseChildren(request);
} catch (RuntimeException error) {
if (error instanceof MxGatewayException) {
throw error;
}
throw MxGatewayErrors.fromGrpc("galaxy browse children", error);
}
}
/**
* Drives the BrowseChildren paging loop for a single parent (or roots when
* {@code parentGobjectId} is {@code null}). Detects repeated page tokens to
* avoid infinite loops on a buggy server.
*/
List<LazyBrowseNode> browseChildrenInner(Integer parentGobjectId, BrowseChildrenOptions options) {
java.util.ArrayList<LazyBrowseNode> nodes = new java.util.ArrayList<>();
java.util.HashSet<String> seenPageTokens = new java.util.HashSet<>();
String pageToken = "";
while (true) {
BrowseChildrenRequest.Builder builder = BrowseChildrenRequest.newBuilder()
.setPageSize(BROWSE_CHILDREN_PAGE_SIZE)
.setPageToken(pageToken)
.setAlarmBearingOnly(options.isAlarmBearingOnly())
.setHistorizedOnly(options.isHistorizedOnly());
if (parentGobjectId != null) {
builder.setParentGobjectId(parentGobjectId.intValue());
}
if (!options.getCategoryIds().isEmpty()) {
builder.addAllCategoryIds(options.getCategoryIds());
}
if (!options.getTemplateChainContains().isEmpty()) {
builder.addAllTemplateChainContains(options.getTemplateChainContains());
}
if (!options.getTagNameGlob().isEmpty()) {
builder.setTagNameGlob(options.getTagNameGlob());
}
if (options.getIncludeAttributes() != null) {
builder.setIncludeAttributes(options.getIncludeAttributes());
}
BrowseChildrenReply reply = browseChildrenRaw(builder.build());
for (int i = 0; i < reply.getChildrenCount(); i++) {
boolean hint = i < reply.getChildHasChildrenCount() && reply.getChildHasChildren(i);
nodes.add(new LazyBrowseNode(this, reply.getChildren(i), hint, options));
}
pageToken = reply.getNextPageToken();
if (pageToken == null || pageToken.isEmpty()) {
return nodes;
}
if (!seenPageTokens.add(pageToken)) {
throw new MxGatewayException(
"galaxy browse children returned repeated page token: " + pageToken);
}
}
}
/** /**
* Subscribes to {@code WatchDeployEvents} via the async stub and consumes * Subscribes to {@code WatchDeployEvents} via the async stub and consumes
* results through a blocking iterator. Closing the returned stream cancels * results through a blocking iterator. Closing the returned stream cancels
@@ -0,0 +1,75 @@
package com.zb.mom.ww.mxgateway.client;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
import java.util.Collections;
import java.util.List;
/**
* One node in a lazy-loaded Galaxy browse tree. Holds the underlying
* {@link GalaxyObject} and exposes {@link #expand()} to fetch its direct
* children on demand. Expansion is one-shot: a second call is a no-op.
* Pagination of large sibling sets is handled internally by the client.
*/
public final class LazyBrowseNode {
private final GalaxyRepositoryClient client;
private final GalaxyObject object;
private final boolean hasChildrenHint;
private final BrowseChildrenOptions options;
private final Object lock = new Object();
private List<LazyBrowseNode> children = Collections.emptyList();
private boolean isExpanded;
LazyBrowseNode(
GalaxyRepositoryClient client,
GalaxyObject object,
boolean hasChildrenHint,
BrowseChildrenOptions options) {
this.client = client;
this.object = object;
this.hasChildrenHint = hasChildrenHint;
this.options = options;
}
/** @return the underlying Galaxy object proto for this node. */
public GalaxyObject getObject() {
return object;
}
/** @return {@code true} when the server reports this node has at least one matching descendant. */
public boolean hasChildrenHint() {
return hasChildrenHint;
}
/** @return a snapshot of direct children loaded by {@link #expand()}; empty until then. */
public List<LazyBrowseNode> getChildren() {
synchronized (lock) {
return List.copyOf(children);
}
}
/** @return {@code true} after the first {@link #expand()} call completes. */
public boolean isExpanded() {
synchronized (lock) {
return isExpanded;
}
}
/**
* Fetches direct children from the gateway and populates {@link #getChildren()}.
* Idempotent: subsequent calls are no-ops and do not issue a second RPC.
*
* @throws MxGatewayException on transport or protocol failure
*/
public void expand() {
synchronized (lock) {
if (isExpanded) {
return;
}
List<LazyBrowseNode> loaded =
client.browseChildrenInner(Integer.valueOf(object.getGobjectId()), options);
this.children = loaded;
this.isExpanded = true;
}
}
}
@@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
import com.google.protobuf.Timestamp; import com.google.protobuf.Timestamp;
import galaxy_repository.v1.GalaxyRepositoryGrpc; import galaxy_repository.v1.GalaxyRepositoryGrpc;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.BrowseChildrenRequest;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyReply;
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest; import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
@@ -24,6 +26,7 @@ import io.grpc.Server;
import io.grpc.ServerCall; import io.grpc.ServerCall;
import io.grpc.ServerCallHandler; import io.grpc.ServerCallHandler;
import io.grpc.ServerInterceptor; import io.grpc.ServerInterceptor;
import io.grpc.Status;
import io.grpc.inprocess.InProcessChannelBuilder; import io.grpc.inprocess.InProcessChannelBuilder;
import io.grpc.inprocess.InProcessServerBuilder; import io.grpc.inprocess.InProcessServerBuilder;
import io.grpc.stub.ClientCallStreamObserver; import io.grpc.stub.ClientCallStreamObserver;
@@ -31,9 +34,13 @@ import io.grpc.stub.ClientResponseObserver;
import io.grpc.stub.StreamObserver; import io.grpc.stub.StreamObserver;
import java.time.Duration; import java.time.Duration;
import java.time.Instant; import java.time.Instant;
import java.util.ArrayDeque;
import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Optional; import java.util.Optional;
import java.util.Queue;
import java.util.UUID; import java.util.UUID;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.CountDownLatch; import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.atomic.AtomicReference;
@@ -306,6 +313,209 @@ final class GalaxyRepositoryClientTests {
} }
} }
@Test
void browseNoParentReturnsRoots() throws Exception {
BrowseChildrenService service = new BrowseChildrenService();
service.replies.add(browseReply(
List.of(obj(1, "Plant", true), obj(2, "Other", false)),
List.of(true, false),
1L,
""));
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<LazyBrowseNode> roots = client.browse();
assertEquals(2, roots.size());
assertEquals("Plant", roots.get(0).getObject().getTagName());
assertTrue(roots.get(0).hasChildrenHint());
assertFalse(roots.get(0).isExpanded());
assertEquals("Other", roots.get(1).getObject().getTagName());
assertFalse(roots.get(1).hasChildrenHint());
assertFalse(roots.get(1).isExpanded());
assertEquals(1, service.calls.size());
assertFalse(service.calls.get(0).hasParentGobjectId());
}
}
@Test
void browseExpandPopulatesChildrenAndMarksExpanded() throws Exception {
BrowseChildrenService service = new BrowseChildrenService();
service.replies.add(browseReply(
List.of(obj(1, "Plant", true)),
List.of(true),
1L,
""));
service.replies.add(browseReply(
List.of(obj(10, "Line1", false)),
List.of(false),
1L,
""));
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<LazyBrowseNode> roots = client.browse();
roots.get(0).expand();
assertTrue(roots.get(0).isExpanded());
assertEquals(1, roots.get(0).getChildren().size());
assertEquals("Line1", roots.get(0).getChildren().get(0).getObject().getTagName());
assertEquals(2, service.calls.size());
assertTrue(service.calls.get(1).hasParentGobjectId());
assertEquals(1, service.calls.get(1).getParentGobjectId());
}
}
@Test
void browseExpandIdempotentNoSecondRpc() throws Exception {
BrowseChildrenService service = new BrowseChildrenService();
service.replies.add(browseReply(
List.of(obj(1, "Plant", true)),
List.of(true),
1L,
""));
service.replies.add(browseReply(
List.of(obj(10, "Line1", false)),
List.of(false),
1L,
""));
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<LazyBrowseNode> roots = client.browse();
roots.get(0).expand();
roots.get(0).expand();
assertEquals(2, service.calls.size());
assertEquals(1, roots.get(0).getChildren().size());
}
}
@Test
void browseExpandUnknownParentThrowsGalaxyNotFound() throws Exception {
BrowseChildrenService service = new BrowseChildrenService();
service.replies.add(browseReply(
List.of(obj(1, "Plant", true)),
List.of(true),
1L,
""));
service.errors.add(Status.NOT_FOUND.withDescription("Parent not found").asRuntimeException());
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<LazyBrowseNode> roots = client.browse();
MxGatewayException error = assertThrows(MxGatewayException.class, () -> roots.get(0).expand());
assertTrue(
error.getMessage().toLowerCase().contains("not found"),
"expected message to mention 'not found', got: " + error.getMessage());
}
}
@Test
void browseExpandMultiPageGathersAllPages() throws Exception {
BrowseChildrenService service = new BrowseChildrenService();
// Roots
service.replies.add(browseReply(
List.of(obj(7, "Plant", true)),
List.of(true),
1L,
""));
// First child page with a next token
service.replies.add(browseReply(
List.of(obj(70, "ChildA", false), obj(71, "ChildB", false)),
List.of(false, false),
1L,
"7:abc:2"));
// Second child page closes the loop
service.replies.add(browseReply(
List.of(obj(72, "ChildC", false)),
List.of(false),
1L,
""));
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
List<LazyBrowseNode> roots = client.browse();
roots.get(0).expand();
assertEquals(3, roots.get(0).getChildren().size());
assertEquals(3, service.calls.size());
assertEquals("7:abc:2", service.calls.get(2).getPageToken());
}
}
@Test
void browseWithFilterForwardsToRequest() throws Exception {
BrowseChildrenService service = new BrowseChildrenService();
// Default reply is empty; only the request shape matters here.
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
GalaxyRepositoryClient client = g.client("")) {
client.browse(BrowseChildrenOptions.builder()
.tagNameGlob("Mixer*")
.alarmBearingOnly(true)
.build());
}
assertEquals(1, service.calls.size());
BrowseChildrenRequest request = service.calls.get(0);
assertEquals("Mixer*", request.getTagNameGlob());
assertTrue(request.getAlarmBearingOnly());
}
private static GalaxyObject obj(int id, String tag, boolean isArea) {
return GalaxyObject.newBuilder()
.setGobjectId(id)
.setTagName(tag)
.setBrowseName(tag)
.setIsArea(isArea)
.build();
}
private static BrowseChildrenReply browseReply(
List<GalaxyObject> children,
List<Boolean> childHasChildren,
long cacheSequence,
String nextPageToken) {
BrowseChildrenReply.Builder b = BrowseChildrenReply.newBuilder()
.setTotalChildCount(children.size())
.setCacheSequence(cacheSequence)
.setNextPageToken(nextPageToken);
b.addAllChildren(children);
b.addAllChildHasChildren(childHasChildren);
return b.build();
}
private static final class BrowseChildrenService extends TestService {
final List<BrowseChildrenRequest> calls =
Collections.synchronizedList(new CopyOnWriteArrayList<>());
final Queue<BrowseChildrenReply> replies = new ArrayDeque<>();
final Queue<Throwable> errors = new ArrayDeque<>();
@Override
public void browseChildren(
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
calls.add(request);
BrowseChildrenReply reply;
Throwable err;
synchronized (this) {
// Prefer queued replies first; once they're exhausted, fall through to any
// queued error. This matches the .NET fake's ordering used by parity tests.
reply = replies.poll();
err = reply == null ? errors.poll() : null;
}
if (err != null) {
responseObserver.onError(err);
return;
}
if (reply == null) {
reply = BrowseChildrenReply.getDefaultInstance();
}
responseObserver.onNext(reply);
responseObserver.onCompleted();
}
}
private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase { private abstract static class TestService extends GalaxyRepositoryGrpc.GalaxyRepositoryImplBase {
@Override @Override
public void testConnection( public void testConnection(
+43
View File
@@ -138,6 +138,49 @@ The methods return native Python types (`bool`, `datetime | None`, and a
into the hierarchy without learning the underlying stub class. The into the hierarchy without learning the underlying stub class. The
service requires the `metadata:read` scope on the API key. service requires the `metadata:read` scope on the API key.
### Browsing lazily
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
time instead of loading the full hierarchy with `discover_hierarchy`. Pass an
empty request for root objects; subsequent calls set `parent_gobject_id`,
`parent_tag_name`, or `parent_contained_path`. Filter fields match
`DiscoverHierarchy`. Each response pairs `children` with `child_has_children` so
you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```python
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb2
reply = await galaxy.browse_children(galaxy_pb2.BrowseChildrenRequest())
for child, has_children in zip(reply.children, reply.child_has_children):
print(child.tag_name, "expand=" + str(has_children))
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```python
async with await GalaxyRepositoryClient.connect(
endpoint="localhost:5000",
api_key="<gateway-api-key>",
plaintext=True,
) as galaxy:
roots = await galaxy.browse()
for root in roots:
if root.has_children_hint:
await root.expand()
for child in root.children:
kind = "has children" if child.has_children_hint else "leaf"
print(f"{child.object.tag_name} ({kind})")
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events ### Watching deploy events
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming `GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
@@ -21,9 +21,10 @@ from .auth import merge_metadata
from .errors import MxGatewayError, map_rpc_error from .errors import MxGatewayError, map_rpc_error
from .generated import galaxy_repository_pb2 as galaxy_pb from .generated import galaxy_repository_pb2 as galaxy_pb
from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc from .generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
from .options import ClientOptions, create_channel from .options import BrowseChildrenOptions, ClientOptions, create_channel
_DISCOVER_HIERARCHY_PAGE_SIZE = 5000 _DISCOVER_HIERARCHY_PAGE_SIZE = 5000
_BROWSE_CHILDREN_PAGE_SIZE = 500
class GalaxyRepositoryClient: class GalaxyRepositoryClient:
@@ -139,6 +140,73 @@ class GalaxyRepositoryClient:
) )
seen_page_tokens.add(page_token) seen_page_tokens.add(page_token)
async def browse(
self,
options: BrowseChildrenOptions | None = None,
) -> list["LazyBrowseNode"]:
"""Return the root browse nodes for lazy hierarchy traversal.
Each returned ``LazyBrowseNode`` wraps a Galaxy object whose direct
children can be loaded on demand by ``await node.expand()``.
"""
effective = options or BrowseChildrenOptions()
return [
node
async for node in self._iter_browse_children(
parent_gobject_id=None,
options=effective,
)
]
async def _iter_browse_children(
self,
*,
parent_gobject_id: int | None,
options: BrowseChildrenOptions,
) -> AsyncIterator["LazyBrowseNode"]:
page_token = ""
seen_page_tokens: set[str] = set()
while True:
request = galaxy_pb.BrowseChildrenRequest(
page_size=_BROWSE_CHILDREN_PAGE_SIZE,
page_token=page_token,
alarm_bearing_only=options.alarm_bearing_only,
historized_only=options.historized_only,
)
if parent_gobject_id is not None:
request.parent_gobject_id = parent_gobject_id
if options.category_ids:
request.category_ids.extend(options.category_ids)
if options.template_chain_contains:
request.template_chain_contains.extend(options.template_chain_contains)
if options.tag_name_glob:
request.tag_name_glob = options.tag_name_glob
if options.include_attributes is not None:
request.include_attributes = options.include_attributes
reply = await self._unary(
"browse children",
self.raw_stub.BrowseChildren,
request,
)
for index, obj in enumerate(reply.children):
hint = (
index < len(reply.child_has_children)
and bool(reply.child_has_children[index])
)
yield LazyBrowseNode(self, obj, hint, options)
page_token = reply.next_page_token
if not page_token:
return
if page_token in seen_page_tokens:
raise MxGatewayError(
f"galaxy browse children returned repeated page token {page_token!r}"
)
seen_page_tokens.add(page_token)
def watch_deploy_events( def watch_deploy_events(
self, self,
last_seen_deploy_time: datetime | None = None, last_seen_deploy_time: datetime | None = None,
@@ -202,6 +270,67 @@ class GalaxyRepositoryClient:
raise map_rpc_error(operation, error) from error raise map_rpc_error(operation, error) from error
class LazyBrowseNode:
"""One node in a lazy-loaded Galaxy browse tree.
Calling ``expand`` once fetches direct children (paginating as needed)
and populates ``children``. Subsequent calls are no-ops so callers can
drive UI expand toggles without de-duping.
"""
def __init__(
self,
client: "GalaxyRepositoryClient",
obj: galaxy_pb.GalaxyObject,
has_children_hint: bool,
options: BrowseChildrenOptions,
) -> None:
"""Initialize a node bound to its owning client and filter set."""
self._client = client
self._object = obj
self._has_children_hint = has_children_hint
self._options = options
self._children: list[LazyBrowseNode] = []
self._is_expanded = False
self._expand_lock = asyncio.Lock()
@property
def object(self) -> galaxy_pb.GalaxyObject:
"""Return the underlying ``GalaxyObject`` proto for this node."""
return self._object
@property
def has_children_hint(self) -> bool:
"""Return the server hint about whether this node has children."""
return self._has_children_hint
@property
def children(self) -> list["LazyBrowseNode"]:
"""Return a copy of the loaded child nodes (empty until expanded)."""
return list(self._children)
@property
def is_expanded(self) -> bool:
"""Return whether ``expand`` has already populated ``children``."""
return self._is_expanded
async def expand(self) -> None:
"""Fetch direct children of this node; no-op on subsequent calls."""
if self._is_expanded:
return
async with self._expand_lock:
if self._is_expanded:
return
new_children: list[LazyBrowseNode] = []
async for child in self._client._iter_browse_children(
parent_gobject_id=self._object.gobject_id,
options=self._options,
):
new_children.append(child)
self._children.extend(new_children)
self._is_expanded = True
async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]: async def _canceling_iterator(call: Any) -> AsyncIterator[galaxy_pb.DeployEvent]:
try: try:
async for event in call: async for event in call:
@@ -26,7 +26,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2 from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3') DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\"\xdc\x02\n\x15\x42rowseChildrenRequest\x12\x1b\n\x11parent_gobject_id\x18\x01 \x01(\x05H\x00\x12\x19\n\x0fparent_tag_name\x18\x02 \x01(\tH\x00\x12\x1f\n\x15parent_contained_path\x18\x03 \x01(\tH\x00\x12\x11\n\tpage_size\x18\x04 \x01(\x05\x12\x12\n\npage_token\x18\x05 \x01(\t\x12\x14\n\x0c\x63\x61tegory_ids\x18\x06 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x07 \x03(\t\x12\x15\n\rtag_name_glob\x18\x08 \x01(\t\x12\x1f\n\x12include_attributes\x18\t \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\n \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0b \x01(\x08\x42\x08\n\x06parentB\x15\n\x13_include_attributes\"\xb3\x01\n\x13\x42rowseChildrenReply\x12\x34\n\x08\x63hildren\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x19\n\x11total_child_count\x18\x03 \x01(\x05\x12\x1a\n\x12\x63hild_has_children\x18\x04 \x03(\x08\x12\x16\n\x0e\x63\x61\x63he_sequence\x18\x05 \x01(\x04\x32\xb6\x04\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n\x0e\x42rowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
_globals = globals() _globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -54,6 +54,10 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_GALAXYOBJECT']._serialized_end=1416 _globals['_GALAXYOBJECT']._serialized_end=1416
_globals['_GALAXYATTRIBUTE']._serialized_start=1419 _globals['_GALAXYATTRIBUTE']._serialized_start=1419
_globals['_GALAXYATTRIBUTE']._serialized_end=1715 _globals['_GALAXYATTRIBUTE']._serialized_end=1715
_globals['_GALAXYREPOSITORY']._serialized_start=1718 _globals['_BROWSECHILDRENREQUEST']._serialized_start=1718
_globals['_GALAXYREPOSITORY']._serialized_end=2178 _globals['_BROWSECHILDRENREQUEST']._serialized_end=2066
_globals['_BROWSECHILDRENREPLY']._serialized_start=2069
_globals['_BROWSECHILDRENREPLY']._serialized_end=2248
_globals['_GALAXYREPOSITORY']._serialized_start=2251
_globals['_GALAXYREPOSITORY']._serialized_end=2817
# @@protoc_insertion_point(module_scope) # @@protoc_insertion_point(module_scope)
@@ -65,6 +65,11 @@ class GalaxyRepositoryStub(object):
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString, request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString, response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
_registered_method=True) _registered_method=True)
self.BrowseChildren = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
request_serializer=galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.BrowseChildrenReply.FromString,
_registered_method=True)
class GalaxyRepositoryServicer(object): class GalaxyRepositoryServicer(object):
@@ -111,6 +116,16 @@ class GalaxyRepositoryServicer(object):
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!') raise NotImplementedError('Method not implemented!')
def BrowseChildren(self, request, context):
"""Returns the direct children of a parent object (or the root objects when
`parent` is unset). Designed for OPC UA-style lazy expand: clients walk
one level at a time instead of paging the full hierarchy. Filters mirror
DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_GalaxyRepositoryServicer_to_server(servicer, server): def add_GalaxyRepositoryServicer_to_server(servicer, server):
rpc_method_handlers = { rpc_method_handlers = {
@@ -134,6 +149,11 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString, request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString, response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
), ),
'BrowseChildren': grpc.unary_unary_rpc_method_handler(
servicer.BrowseChildren,
request_deserializer=galaxy__repository__pb2.BrowseChildrenRequest.FromString,
response_serializer=galaxy__repository__pb2.BrowseChildrenReply.SerializeToString,
),
} }
generic_handler = grpc.method_handlers_generic_handler( generic_handler = grpc.method_handlers_generic_handler(
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers) 'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
@@ -263,3 +283,30 @@ class GalaxyRepository(object):
timeout, timeout,
metadata, metadata,
_registered_method=True) _registered_method=True)
@staticmethod
def BrowseChildren(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
galaxy__repository__pb2.BrowseChildrenReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@@ -135,6 +135,9 @@ class MxAccessGatewayServicer(object):
reconnect to seed Part 9 client state, or to reconcile alarms that may reconnect to seed Part 9 client state, or to reconcile alarms that may
have been missed during a transport blip. Streamed so callers can have been missed during a transport blip. Streamed so callers can
begin processing without buffering the full set. begin processing without buffering the full set.
`QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
snapshot to alarms whose `alarm_full_reference` starts with the given
prefix; an empty prefix returns the full set.
""" """
context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
@@ -2,7 +2,8 @@
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass from collections.abc import Sequence
from dataclasses import dataclass, field
from pathlib import Path from pathlib import Path
import grpc import grpc
@@ -51,6 +52,23 @@ class ClientOptions:
) )
@dataclass(frozen=True)
class BrowseChildrenOptions:
"""Filters and shape options for ``GalaxyRepositoryClient.browse``.
Mirrors the AND-combined filter set on ``BrowseChildrenRequest`` so a
single instance can be re-used across an entire lazy browse session
(the filter set is part of the page-token contract).
"""
category_ids: Sequence[int] = field(default_factory=tuple)
template_chain_contains: Sequence[str] = field(default_factory=tuple)
tag_name_glob: str | None = None
include_attributes: bool | None = None
alarm_bearing_only: bool = False
historized_only: bool = False
def create_channel(options: ClientOptions) -> grpc.aio.Channel: def create_channel(options: ClientOptions) -> grpc.aio.Channel:
"""Create a plaintext or TLS `grpc.aio` channel from client options.""" """Create a plaintext or TLS `grpc.aio` channel from client options."""
+247
View File
@@ -6,12 +6,16 @@ import asyncio
from datetime import datetime, timezone from datetime import datetime, timezone
from typing import Any from typing import Any
import grpc
import pytest import pytest
from google.protobuf.timestamp_pb2 import Timestamp from google.protobuf.timestamp_pb2 import Timestamp
from zb_mom_ww_mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest from zb_mom_ww_mxgateway import ClientOptions, DeployEvent, GalaxyRepositoryClient, WatchDeployEventsRequest
from zb_mom_ww_mxgateway.errors import MxGatewayError
from zb_mom_ww_mxgateway.galaxy import LazyBrowseNode
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2_grpc as galaxy_pb_grpc
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions
def test_galaxy_messages_import() -> None: def test_galaxy_messages_import() -> None:
@@ -268,15 +272,252 @@ async def test_close_marks_channel_closed_when_no_real_channel() -> None:
await client.close() await client.close()
def _obj(gid: int, tag: str, is_area: bool = False) -> galaxy_pb.GalaxyObject:
return galaxy_pb.GalaxyObject(
gobject_id=gid, tag_name=tag, browse_name=tag, is_area=is_area,
)
def _build_browse_reply(
children: list[galaxy_pb.GalaxyObject],
child_has_children: list[bool],
cache_sequence: int,
next_page_token: str = "",
) -> galaxy_pb.BrowseChildrenReply:
reply = galaxy_pb.BrowseChildrenReply(
total_child_count=len(children),
cache_sequence=cache_sequence,
next_page_token=next_page_token,
)
reply.children.extend(children)
reply.child_has_children.extend(child_has_children)
return reply
def _fake_aio_rpc_error(code: grpc.StatusCode, details: str) -> grpc.aio.AioRpcError:
return grpc.aio.AioRpcError(
code=code,
initial_metadata=grpc.aio.Metadata(),
trailing_metadata=grpc.aio.Metadata(),
details=details,
)
@pytest.mark.asyncio
async def test_browse_no_parent_returns_roots() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True), _obj(2, "Area_B", is_area=True)],
child_has_children=[True, False],
cache_sequence=7,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
assert len(roots) == 2
assert all(isinstance(node, LazyBrowseNode) for node in roots)
assert roots[0].object.tag_name == "Area_A"
assert roots[0].has_children_hint is True
assert roots[1].has_children_hint is False
assert roots[0].is_expanded is False
request = stub.browse_children.requests[0]
assert request.WhichOneof("parent") is None
assert request.page_size == 500
assert request.page_token == ""
@pytest.mark.asyncio
async def test_browse_expand_populates_children_and_marks_expanded() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True)],
child_has_children=[True],
cache_sequence=1,
),
_build_browse_reply(
children=[_obj(11, "Child_A"), _obj(12, "Child_B")],
child_has_children=[False, False],
cache_sequence=1,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
await roots[0].expand()
assert roots[0].is_expanded is True
assert [n.object.tag_name for n in roots[0].children] == ["Child_A", "Child_B"]
assert len(stub.browse_children.requests) == 2
expand_request = stub.browse_children.requests[1]
assert expand_request.WhichOneof("parent") == "parent_gobject_id"
assert expand_request.parent_gobject_id == 1
@pytest.mark.asyncio
async def test_browse_expand_idempotent_no_second_rpc() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True)],
child_has_children=[True],
cache_sequence=1,
),
_build_browse_reply(
children=[_obj(11, "Child_A")],
child_has_children=[False],
cache_sequence=1,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
await roots[0].expand()
await roots[0].expand()
assert len(stub.browse_children.requests) == 2
assert len(roots[0].children) == 1
@pytest.mark.asyncio
async def test_browse_expand_concurrent_callers_only_fire_one_rpc() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply([_obj(1, "Plant", is_area=True)], [True], 7),
_build_browse_reply([_obj(2, "Mixer_001")], [False], 7),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
# Ten concurrent expand calls on the same node should issue exactly one RPC.
await asyncio.gather(*(roots[0].expand() for _ in range(10)))
assert roots[0].is_expanded
assert len(roots[0].children) == 1
# 1 roots fetch + exactly 1 expand fetch = 2 total
assert len(stub.browse_children.requests) == 2
@pytest.mark.asyncio
async def test_browse_expand_unknown_parent_raises_mxgateway_error() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(99, "Stale_Parent", is_area=True)],
child_has_children=[True],
cache_sequence=1,
),
]
stub.browse_children.exceptions = [
None,
_fake_aio_rpc_error(grpc.StatusCode.NOT_FOUND, "parent not found"),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
with pytest.raises(MxGatewayError):
await roots[0].expand()
@pytest.mark.asyncio
async def test_browse_expand_multi_page_gathers_all_pages() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(7, "Area_Big", is_area=True)],
child_has_children=[True],
cache_sequence=2,
),
_build_browse_reply(
children=[_obj(71, "Child_1"), _obj(72, "Child_2")],
child_has_children=[False, False],
cache_sequence=2,
next_page_token="7:abc:2",
),
_build_browse_reply(
children=[_obj(73, "Child_3")],
child_has_children=[False],
cache_sequence=2,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
roots = await client.browse()
await roots[0].expand()
assert [n.object.tag_name for n in roots[0].children] == ["Child_1", "Child_2", "Child_3"]
assert len(stub.browse_children.requests) == 3
assert stub.browse_children.requests[2].page_token == "7:abc:2"
assert stub.browse_children.requests[2].parent_gobject_id == 7
@pytest.mark.asyncio
async def test_browse_with_filter_forwards_to_request() -> None:
stub = FakeGalaxyStub()
stub.browse_children.replies = [
_build_browse_reply(
children=[_obj(1, "Area_A", is_area=True)],
child_has_children=[False],
cache_sequence=3,
),
]
client = await GalaxyRepositoryClient.connect(
ClientOptions(endpoint="fake", plaintext=True),
stub=stub,
)
options = BrowseChildrenOptions(
category_ids=(4, 5),
template_chain_contains=("$DelmiaReceiver",),
tag_name_glob="Area_*",
include_attributes=True,
alarm_bearing_only=True,
historized_only=True,
)
await client.browse(options)
request = stub.browse_children.requests[0]
assert list(request.category_ids) == [4, 5]
assert list(request.template_chain_contains) == ["$DelmiaReceiver"]
assert request.tag_name_glob == "Area_*"
assert request.HasField("include_attributes")
assert request.include_attributes is True
assert request.alarm_bearing_only is True
assert request.historized_only is True
class FakeGalaxyStub: class FakeGalaxyStub:
def __init__(self) -> None: def __init__(self) -> None:
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)]) self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)]) self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()]) self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
self.browse_children = FakeUnary([galaxy_pb.BrowseChildrenReply()])
self.watch_deploy_events = FakeStream([]) self.watch_deploy_events = FakeStream([])
self.TestConnection = self.test_connection self.TestConnection = self.test_connection
self.GetLastDeployTime = self.get_last_deploy_time self.GetLastDeployTime = self.get_last_deploy_time
self.DiscoverHierarchy = self.discover_hierarchy self.DiscoverHierarchy = self.discover_hierarchy
self.BrowseChildren = self.browse_children
@property @property
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
@@ -287,6 +528,8 @@ class FakeUnary:
def __init__(self, replies: list[Any]) -> None: def __init__(self, replies: list[Any]) -> None:
self.replies = replies self.replies = replies
self.requests: list[Any] = [] self.requests: list[Any] = []
# None entries mean "no exception on this call"; aligns with the replies queue index-by-index.
self.exceptions: list[BaseException | None] = []
self.metadata: tuple[tuple[str, str], ...] | None = None self.metadata: tuple[tuple[str, str], ...] | None = None
async def __call__( async def __call__(
@@ -298,6 +541,10 @@ class FakeUnary:
) -> Any: ) -> Any:
self.requests.append(request) self.requests.append(request)
self.metadata = metadata self.metadata = metadata
if self.exceptions:
exc = self.exceptions.pop(0)
if exc is not None:
raise exc
return self.replies.pop(0) return self.replies.pop(0)
+44
View File
@@ -138,6 +138,50 @@ cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:500
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
``` ```
### Browsing lazily
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
time instead of paging the full hierarchy. Pass a default request for root
objects; subsequent calls set `parent_gobject_id`, `parent_tag_name`, or
`parent_contained_path`. Filter fields match `discover_hierarchy`. Each response
pairs `children` with `child_has_children` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```rust
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;
let reply = galaxy.browse_children(BrowseChildrenRequest::default()).await?.into_inner();
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
println!("{} expand={}", child.tag_name, has_children);
}
```
#### High-level walker
For UI trees, the client provides a `LazyBrowseNode` walker that handles
sibling pagination and the `child_has_children` hint for you:
```rust
let mut client = GalaxyClient::connect(
ClientOptions::new("http://localhost:5000").with_api_key(ApiKey::new(api_key)),
).await?;
let roots = client.browse(None).await?;
for root in &roots {
if root.has_children_hint() {
root.expand().await?;
}
for child in root.children().await {
let kind = if child.has_children_hint() { "has children" } else { "leaf" };
println!("{} ({kind})", child.object().tag_name);
}
}
```
`expand` is idempotent — calling it twice fires only one RPC,
and is safe under concurrent callers. To refresh after a Galaxy redeploy, call
`browse` again from the root.
### Watching deploy events ### Watching deploy events
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The `watch_deploy_events` opens the `WatchDeployEvents` server stream. The
+536 -5
View File
@@ -5,9 +5,12 @@
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are //! read-only RPCs as Rust async methods. Generated Galaxy proto types are
//! re-exported through [`crate::generated::galaxy_repository::v1`]. //! re-exported through [`crate::generated::galaxy_repository::v1`].
use std::collections::HashSet;
use std::fs; use std::fs;
use std::sync::Arc;
use prost_types::Timestamp; use prost_types::Timestamp;
use tokio::sync::Mutex as AsyncMutex;
use tonic::codegen::InterceptedService; use tonic::codegen::InterceptedService;
use tonic::transport::{Certificate, Channel, ClientTlsConfig}; use tonic::transport::{Certificate, Channel, ClientTlsConfig};
use tonic::Request; use tonic::Request;
@@ -16,12 +19,130 @@ use crate::auth::AuthInterceptor;
use crate::error::Error; use crate::error::Error;
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient; use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
use crate::generated::galaxy_repository::v1::{ use crate::generated::galaxy_repository::v1::{
DeployEvent, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, browse_children_request, BrowseChildrenReply, BrowseChildrenRequest, DeployEvent,
TestConnectionRequest, WatchDeployEventsRequest, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
WatchDeployEventsRequest,
}; };
use crate::options::ClientOptions; use crate::options::ClientOptions;
const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000; const DISCOVER_HIERARCHY_PAGE_SIZE: i32 = 5000;
const BROWSE_CHILDREN_PAGE_SIZE: i32 = 500;
/// Optional filter set forwarded to `GalaxyRepository.BrowseChildren`.
///
/// Mirrors the request-level filters on the wire: combined with AND so a child
/// only appears when it satisfies every populated criterion. Construct via
/// [`BrowseChildrenOptions::default`] and tweak the fields you care about.
#[derive(Debug, Clone, Default)]
pub struct BrowseChildrenOptions {
/// Restrict to objects whose `category_id` matches one of the supplied
/// Galaxy category identifiers. Empty means "no restriction".
pub category_ids: Vec<i32>,
/// Restrict to objects whose template chain contains every supplied
/// template name (case-sensitive substring match on each entry).
pub template_chain_contains: Vec<String>,
/// Restrict to objects whose tag name matches the supplied glob (SQL
/// `LIKE`-style on the server). `None` means "no glob filter".
pub tag_name_glob: Option<String>,
/// Optional tri-state hint for whether to populate `GalaxyObject.attributes`
/// on returned children. `None` falls back to the server default.
pub include_attributes: Option<bool>,
/// When `true`, only return children that own at least one alarm-bearing
/// attribute (matches `DiscoverHierarchy` semantics).
pub alarm_bearing_only: bool,
/// When `true`, only return children that own at least one historized
/// attribute (matches `DiscoverHierarchy` semantics).
pub historized_only: bool,
}
/// Lazy hierarchy node used by the walker built on top of `BrowseChildren`.
///
/// A node owns its [`GalaxyObject`], a hint as to whether the server believes
/// it has at least one matching descendant under the active filter set, and an
/// internal `expanded` flag protected by an async mutex. Calling [`expand`]
/// the first time issues a paged `BrowseChildren` RPC; subsequent calls are
/// no-ops so callers can poll without re-hitting the server.
///
/// `LazyBrowseNode` is cheap to clone — clones share state through an
/// internal `Arc`, so expanding one clone makes the children visible to every
/// other clone.
///
/// [`expand`]: LazyBrowseNode::expand
pub struct LazyBrowseNode {
inner: Arc<LazyBrowseNodeInner>,
}
impl Clone for LazyBrowseNode {
fn clone(&self) -> Self {
Self {
inner: Arc::clone(&self.inner),
}
}
}
struct LazyBrowseNodeInner {
client: GalaxyClient,
object: GalaxyObject,
has_children_hint: bool,
options: BrowseChildrenOptions,
state: AsyncMutex<LazyBrowseNodeState>,
}
struct LazyBrowseNodeState {
children: Vec<LazyBrowseNode>,
is_expanded: bool,
}
impl LazyBrowseNode {
/// Borrow the [`GalaxyObject`] returned by the server for this node.
pub fn object(&self) -> &GalaxyObject {
&self.inner.object
}
/// Server-supplied hint: `true` when the child likely has at least one
/// further matching descendant. Useful to decide whether a UI should draw
/// an expand triangle without issuing the RPC up front.
pub fn has_children_hint(&self) -> bool {
self.inner.has_children_hint
}
/// Snapshot of the currently-known children. Empty until [`expand`] has
/// run at least once.
///
/// [`expand`]: LazyBrowseNode::expand
pub async fn children(&self) -> Vec<LazyBrowseNode> {
self.inner.state.lock().await.children.clone()
}
/// Returns `true` once [`expand`] has populated this node's children.
///
/// [`expand`]: LazyBrowseNode::expand
pub async fn is_expanded(&self) -> bool {
self.inner.state.lock().await.is_expanded
}
/// Populate this node's children by issuing a paged `BrowseChildren` RPC.
/// Subsequent calls are no-ops — the cached children stay in place and no
/// additional RPC is issued.
pub async fn expand(&self) -> Result<(), Error> {
let mut state = self.inner.state.lock().await;
if state.is_expanded {
return Ok(());
}
let mut client = self.inner.client.clone();
let new_children = client
.browse_children_inner(
Some(self.inner.object.gobject_id),
self.inner.options.clone(),
)
.await?;
state.children = new_children;
state.is_expanded = true;
Ok(())
}
}
/// Convenience alias for the generated Galaxy client wrapped in the /// Convenience alias for the generated Galaxy client wrapped in the
/// authentication interceptor. /// authentication interceptor.
@@ -172,6 +293,99 @@ impl GalaxyClient {
} }
} }
/// Browse the top-level (root) objects of the hierarchy as
/// [`LazyBrowseNode`] instances. Pass [`BrowseChildrenOptions`] to
/// restrict the result set; the same filter is reused when callers expand
/// any returned node.
pub async fn browse(
&mut self,
options: Option<BrowseChildrenOptions>,
) -> Result<Vec<LazyBrowseNode>, Error> {
let effective = options.unwrap_or_default();
self.browse_children_inner(None, effective).await
}
/// Issue a single `BrowseChildren` RPC and return the raw reply. Callers
/// that want to drive paging themselves (or inspect the cache sequence)
/// use this; high-level walking goes through [`browse`] and
/// [`LazyBrowseNode::expand`].
///
/// [`browse`]: GalaxyClient::browse
pub async fn browse_children_raw(
&mut self,
request: BrowseChildrenRequest,
) -> Result<BrowseChildrenReply, Error> {
let response = self
.inner
.browse_children(self.unary_request(request))
.await?;
Ok(response.into_inner())
}
pub(crate) async fn browse_children_inner(
&mut self,
parent_gobject_id: Option<i32>,
options: BrowseChildrenOptions,
) -> Result<Vec<LazyBrowseNode>, Error> {
let mut nodes = Vec::new();
let mut page_token = String::new();
let mut seen_page_tokens: HashSet<String> = HashSet::new();
loop {
let parent = parent_gobject_id.map(browse_children_request::Parent::ParentGobjectId);
let request = BrowseChildrenRequest {
page_size: BROWSE_CHILDREN_PAGE_SIZE,
page_token: page_token.clone(),
category_ids: options.category_ids.clone(),
template_chain_contains: options.template_chain_contains.clone(),
tag_name_glob: options.tag_name_glob.clone().unwrap_or_default(),
include_attributes: options.include_attributes,
alarm_bearing_only: options.alarm_bearing_only,
historized_only: options.historized_only,
parent,
};
let reply = self.browse_children_raw(request).await?;
let hints = reply.child_has_children;
for (index, object) in reply.children.into_iter().enumerate() {
let hint = hints.get(index).copied().unwrap_or(false);
nodes.push(self.make_lazy_node(object, hint, options.clone()));
}
page_token = reply.next_page_token;
if page_token.is_empty() {
return Ok(nodes);
}
if !seen_page_tokens.insert(page_token.clone()) {
return Err(Error::InvalidArgument {
name: "page_token".to_owned(),
detail: format!(
"galaxy browse children returned repeated page token `{page_token}`"
),
});
}
}
}
fn make_lazy_node(
&self,
object: GalaxyObject,
has_children_hint: bool,
options: BrowseChildrenOptions,
) -> LazyBrowseNode {
LazyBrowseNode {
inner: Arc::new(LazyBrowseNodeInner {
client: self.clone(),
object,
has_children_hint,
options,
state: AsyncMutex::new(LazyBrowseNodeState {
children: Vec::new(),
is_expanded: false,
}),
}),
}
}
/// Subscribe to the server-streamed deploy-event feed. /// Subscribe to the server-streamed deploy-event feed.
/// ///
/// The server emits a bootstrap event describing the current cache state /// The server emits a bootstrap event describing the current cache state
@@ -234,9 +448,10 @@ mod tests {
GalaxyRepository, GalaxyRepositoryServer, GalaxyRepository, GalaxyRepositoryServer,
}; };
use crate::generated::galaxy_repository::v1::{ use crate::generated::galaxy_repository::v1::{
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute, BrowseChildrenReply, BrowseChildrenRequest, DeployEvent, DiscoverHierarchyReply,
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply, DiscoverHierarchyRequest, GalaxyAttribute, GalaxyObject, GetLastDeployTimeReply,
TestConnectionRequest, WatchDeployEventsRequest, GetLastDeployTimeRequest, TestConnectionReply, TestConnectionRequest,
WatchDeployEventsRequest,
}; };
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>; type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
@@ -249,6 +464,9 @@ mod tests {
objects: Mutex<Vec<GalaxyObject>>, objects: Mutex<Vec<GalaxyObject>>,
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>, discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>, discover_replies: Mutex<std::collections::VecDeque<DiscoverHierarchyReply>>,
browse_children_calls: Mutex<Vec<BrowseChildrenRequest>>,
browse_children_replies: Mutex<std::collections::VecDeque<BrowseChildrenReply>>,
browse_children_errors: Mutex<Vec<Status>>,
watch_requests: Mutex<Vec<WatchDeployEventsRequest>>, watch_requests: Mutex<Vec<WatchDeployEventsRequest>>,
watch_events: Mutex<Vec<DeployEvent>>, watch_events: Mutex<Vec<DeployEvent>>,
watch_senders: Mutex<Vec<DeployEventTx>>, watch_senders: Mutex<Vec<DeployEventTx>>,
@@ -306,6 +524,28 @@ mod tests {
})) }))
} }
async fn browse_children(
&self,
request: Request<BrowseChildrenRequest>,
) -> Result<Response<BrowseChildrenReply>, Status> {
self.state
.browse_children_calls
.lock()
.unwrap()
.push(request.into_inner());
if let Some(error) = self.state.browse_children_errors.lock().unwrap().pop() {
return Err(error);
}
let reply = self
.state
.browse_children_replies
.lock()
.unwrap()
.pop_front()
.unwrap_or_default();
Ok(Response::new(reply))
}
type WatchDeployEventsStream = type WatchDeployEventsStream =
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>; Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
@@ -695,4 +935,295 @@ mod tests {
"drop signal channel closed unexpectedly" "drop signal channel closed unexpectedly"
); );
} }
fn browse_obj(gid: i32, tag: &str, is_area: bool) -> GalaxyObject {
GalaxyObject {
gobject_id: gid,
tag_name: tag.to_owned(),
contained_name: String::new(),
browse_name: tag.to_owned(),
parent_gobject_id: 0,
is_area,
category_id: 0,
hosted_by_gobject_id: 0,
template_chain: Vec::new(),
attributes: Vec::new(),
}
}
fn build_browse_reply(
children: Vec<GalaxyObject>,
child_has_children: Vec<bool>,
cache_sequence: u64,
) -> BrowseChildrenReply {
BrowseChildrenReply {
total_child_count: children.len() as i32,
cache_sequence,
children,
child_has_children,
next_page_token: String::new(),
}
}
#[tokio::test]
async fn browse_no_parent_returns_roots() {
let state = Arc::new(FakeState::default());
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(1, "Area_A", true), browse_obj(2, "Area_B", true)],
vec![true, false],
7,
));
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let roots = client.browse(None).await.unwrap();
assert_eq!(roots.len(), 2);
assert_eq!(roots[0].object().tag_name, "Area_A");
assert!(roots[0].has_children_hint());
assert_eq!(roots[1].object().tag_name, "Area_B");
assert!(!roots[1].has_children_hint());
let calls = state.browse_children_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
assert!(
calls[0].parent.is_none(),
"root browse must send an empty parent oneof, got {:?}",
calls[0].parent
);
}
#[tokio::test]
async fn browse_expand_populates_children_and_marks_expanded() {
let state = Arc::new(FakeState::default());
// First call: roots.
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(10, "Area_A", true)],
vec![true],
1,
));
// Second call: children of gobject 10.
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(11, "Receiver_1", false)],
vec![false],
1,
));
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let roots = client.browse(None).await.unwrap();
let root = roots.into_iter().next().expect("at least one root");
assert!(!root.is_expanded().await);
root.expand().await.unwrap();
assert!(root.is_expanded().await);
let children = root.children().await;
assert_eq!(children.len(), 1);
assert_eq!(children[0].object().tag_name, "Receiver_1");
let calls = state.browse_children_calls.lock().unwrap();
assert_eq!(calls.len(), 2);
let expand_call = &calls[1];
match expand_call.parent.as_ref().expect("expand sends parent") {
browse_children_request::Parent::ParentGobjectId(id) => assert_eq!(*id, 10),
other => panic!("expected ParentGobjectId variant, got {other:?}"),
}
}
#[tokio::test]
async fn browse_expand_idempotent_no_second_rpc() {
let state = Arc::new(FakeState::default());
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(20, "Area_X", true)],
vec![true],
1,
));
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(21, "Leaf", false)],
vec![false],
1,
));
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let roots = client.browse(None).await.unwrap();
let root = roots.into_iter().next().unwrap();
root.expand().await.unwrap();
let after_first = state.browse_children_calls.lock().unwrap().len();
// Calling expand a second time must NOT issue a new RPC.
root.expand().await.unwrap();
let after_second = state.browse_children_calls.lock().unwrap().len();
assert_eq!(
after_first, after_second,
"expand should be idempotent — no extra RPC the second time"
);
assert_eq!(root.children().await.len(), 1);
}
#[tokio::test]
async fn browse_expand_unknown_parent_returns_not_found_error() {
let state = Arc::new(FakeState::default());
// Root browse succeeds.
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(99, "GhostArea", true)],
vec![true],
1,
));
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let roots = client.browse(None).await.unwrap();
let root = roots.into_iter().next().unwrap();
// Seed the NotFound only AFTER the root call so the FakeGalaxy's
// error stack doesn't intercept the initial browse.
state
.browse_children_errors
.lock()
.unwrap()
.push(Status::not_found("parent gobject 99 not present in cache"));
let error = root.expand().await.unwrap_err();
match &error {
Error::Status(status) => {
assert_eq!(status.code(), tonic::Code::NotFound);
}
other => panic!("expected Error::Status(NotFound), got {other:?}"),
}
// Failed expand must NOT mark the node as expanded — caller can retry.
assert!(!root.is_expanded().await);
assert!(root.children().await.is_empty());
}
#[tokio::test]
async fn browse_expand_multi_page_gathers_all_pages() {
let state = Arc::new(FakeState::default());
// First reply: roots.
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(30, "Plant", true)],
vec![true],
5,
));
// Second reply: page 1 of children, with a next_page_token.
let mut page_one = build_browse_reply(
vec![
browse_obj(31, "Child_A", false),
browse_obj(32, "Child_B", false),
],
vec![false, false],
5,
);
page_one.next_page_token = "cursor-2".to_owned();
page_one.total_child_count = 3;
state
.browse_children_replies
.lock()
.unwrap()
.push_back(page_one);
// Third reply: page 2 of children, with no next page.
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(
vec![browse_obj(33, "Child_C", false)],
vec![false],
5,
));
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let roots = client.browse(None).await.unwrap();
let root = roots.into_iter().next().unwrap();
root.expand().await.unwrap();
let children = root.children().await;
assert_eq!(children.len(), 3);
assert_eq!(children[0].object().tag_name, "Child_A");
assert_eq!(children[1].object().tag_name, "Child_B");
assert_eq!(children[2].object().tag_name, "Child_C");
let calls = state.browse_children_calls.lock().unwrap();
// 1 root call + 2 paged expand calls = 3 total.
assert_eq!(calls.len(), 3);
assert_eq!(calls[1].page_token, "");
assert_eq!(calls[2].page_token, "cursor-2");
}
#[tokio::test]
async fn browse_with_filter_forwards_to_request() {
let state = Arc::new(FakeState::default());
state
.browse_children_replies
.lock()
.unwrap()
.push_back(build_browse_reply(Vec::new(), Vec::new(), 1));
let endpoint = spawn_fake(state.clone()).await;
let mut client = GalaxyClient::connect(ClientOptions::new(endpoint))
.await
.unwrap();
let options = BrowseChildrenOptions {
category_ids: vec![3, 5],
template_chain_contains: vec!["$DelmiaReceiver".to_owned()],
tag_name_glob: Some("Recv_*".to_owned()),
include_attributes: Some(true),
alarm_bearing_only: true,
historized_only: false,
};
let _ = client.browse(Some(options)).await.unwrap();
let calls = state.browse_children_calls.lock().unwrap();
assert_eq!(calls.len(), 1);
let req = &calls[0];
assert_eq!(req.category_ids, vec![3, 5]);
assert_eq!(req.template_chain_contains, vec!["$DelmiaReceiver"]);
assert_eq!(req.tag_name_glob, "Recv_*");
assert_eq!(req.include_attributes, Some(true));
assert!(req.alarm_bearing_only);
assert!(!req.historized_only);
}
} }
+13
View File
@@ -362,6 +362,19 @@ Dashboard access should require API-key-backed dashboard authentication with
is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is
limited to loopback requests. limited to loopback requests.
## Lazy Browse Is Wire-Only
Decision: the gateway continues to pull the full Galaxy hierarchy on each
deploy. `BrowseChildren` and the lazy dashboard render only avoid sending and
DOM-materializing the full tree — they do not push laziness into SQL or cache
loading.
Rationale: snapshot persistence and the dashboard summary both depend on a
fully-materialized cache. Lazy SQL would increase per-click latency on a
deployment-heavy box, multiply per-session SQL connections, and complicate the
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
replies and a heavy DOM) without disturbing the materialization model.
## Later Revisit Items ## Later Revisit Items
These are explicit post-v1 revisit items, not open blockers: These are explicit post-v1 revisit items, not open blockers:
+68 -4
View File
@@ -36,6 +36,7 @@ The service is defined in
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. | | `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). | | `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). | | `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
| `BrowseChildren` | Returns the direct children of one parent object (or root objects when `parent` is unset). Filters mirror `DiscoverHierarchy`. Includes a per-child `has_children` hint so UIs can draw expand triangles without an extra round trip. **Served from cache.** |
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size` `DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
and `page_token`; the server defaults omitted page size to 1000 objects and and `page_token`; the server defaults omitted page size to 1000 objects and
@@ -52,6 +53,57 @@ alarm-only, historized-only, and `include_attributes = false` for a skeleton
tree. All filters are applied with AND semantics, and `total_object_count` tree. All filters are applied with AND semantics, and `total_object_count`
reports the post-filter count. reports the post-filter count.
### BrowseChildren
`BrowseChildren` is an OPC UA-style lazy expand: clients that walk one level at
a time — UI trees, OPC UA address-space bridges — call it instead of paging the
full hierarchy with `DiscoverHierarchy`.
**Parent selection.** The `parent` oneof accepts `parent_gobject_id`,
`parent_tag_name`, or `parent_contained_path`. An empty oneof returns root
objects — those whose `parent_gobject_id` is 0.
**Filters.** Category ids, template-chain substring, tag-name glob, alarm-only,
historized-only, and `include_attributes` all behave identically to
`DiscoverHierarchy` and are AND-combined. One important difference applies to
`alarm_bearing_only` and `historized_only`: an ancestor that does not itself
carry a matching attribute is still returned when one of its descendants does.
This is intentional — without it a UI tree cannot navigate to the matching
leaves. `DiscoverHierarchy`'s flat-list semantics filter out such intermediate
ancestors; `BrowseChildren` retains them so the path to each match remains
traversable.
**`child_has_children` hint.** The reply carries a boolean parallel to
`children`, set true when the child has at least one matching descendant under
the same filter set. UIs can use this to decide whether to draw an expand
triangle without issuing a follow-up `BrowseChildren` call. Because the hint is
computed against the *filtered* descendant set, a branch that contains no
matching objects gets `false`, not `true`.
**Paging.** Default page size is 500; the server caps any requested size at
5000. Page tokens encode `(cache_sequence, parent_id, filter_signature,
offset)`. A token from a different cache generation or a different filter set
returns `InvalidArgument`. The error messages reference "DiscoverHierarchy
page_token" because `BrowseChildren` reuses the same encoding and validation
path — if you see that wording in a `BrowseChildren` context it is expected.
**Errors.**
| Condition | Status code |
|-----------|-------------|
| Unknown parent | `NotFound` |
| First load not yet complete after 5 s | `Unavailable` |
| Stale or filter-mismatched page token | `InvalidArgument` |
| Missing `metadata:read` scope | `PermissionDenied` |
| No API key | `Unauthenticated` |
**Authorization.** Same `metadata:read` scope as the other Galaxy RPCs.
`browse_subtrees` API-key constraints intersect with the result set.
**Sort order.** Areas first, then `OrdinalIgnoreCase` by display name
(`browse_name``contained_name``tag_name`). Matches the dashboard tree so
server and dashboard views are consistent.
## Hierarchy Cache ## Hierarchy Cache
The gateway holds a single shared `IGalaxyHierarchyCache` The gateway holds a single shared `IGalaxyHierarchyCache`
@@ -271,9 +323,13 @@ fields cannot express null. Use it to distinguish "no dimension reported" from
```text ```text
gRPC client(s) gRPC client(s)
-> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/) -> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/)
DiscoverHierarchy, GetLastDeployTime -> IGalaxyHierarchyCache.Current DiscoverHierarchy, GetLastDeployTime, BrowseChildren -> IGalaxyHierarchyCache.Current
WatchDeployEvents -> IGalaxyDeployNotifier WatchDeployEvents -> IGalaxyDeployNotifier
TestConnection -> GalaxyRepository (direct SQL) TestConnection -> GalaxyRepository (direct SQL)
Dashboard (Blazor)
-> IDashboardBrowseService (DashboardBrowseService)
-> GalaxyBrowseProjector over IGalaxyHierarchyCache.Current
GalaxyHierarchyRefreshService (BackgroundService) GalaxyHierarchyRefreshService (BackgroundService)
-> IGalaxyHierarchyCache.RefreshAsync -> IGalaxyHierarchyCache.RefreshAsync
@@ -309,9 +365,17 @@ Component breakdown:
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to (`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to
proto messages. Used by the cache during refresh to materialize the reply proto messages. Used by the cache during refresh to materialize the reply
once. once.
- `GalaxyBrowseProjector`
(`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs`) projects one level
of children out of an immutable cache entry. Memoizes the filtered child list
per cache-entry instance so repeated paging is an O(pageSize) slice rather than an
O(siblings) filter scan. The memo is keyed on the cache entry reference, so a new
entry from the background refresh makes the stale memo unreachable and it is
collected with it. `DashboardBrowseService` wraps this projector to drive the
dashboard's lazy-expand tree.
- `GalaxyRepositoryGrpcService` - `GalaxyRepositoryGrpcService`
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements (`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements
the four RPCs. the five RPCs.
## Configuration ## Configuration
@@ -0,0 +1,240 @@
# Client Lazy-Browse Walker Helpers + Per-Language Tests
Date: 2026-05-28
Status: approved, ready for implementation plan
## Problem
The `BrowseChildren` RPC shipped (branch `feat/lazy-browse-children`, merged
or pending merge), but each language client exposes only the raw generated
gRPC stub. Callers must hand-write recursion, sibling pagination, and
NotFound translation themselves. Only one client (.NET) has a smoke test,
and it is skippable.
This work adds a small high-level walker to each client and unit tests so
callers can build OPC UA-style browse trees without re-implementing the
same plumbing five times.
## Scope
Each of the five clients (.NET, Python, Rust, Go, Java) gains:
1. A low-level `BrowseChildren*Async` wrapper on the existing
`GalaxyRepositoryClient`, mirroring the existing `DiscoverHierarchy*Async`
shape.
2. A high-level `LazyBrowseNode` type plus a `BrowseAsync` factory.
3. Five unit tests against the language's existing fake-transport fixture.
Plus a one-time toolchain bootstrap so the Java client builds locally on
the macOS dev host (Homebrew install of Temurin 21 + Gradle).
## Architecture
`LazyBrowseNode` is shared in shape across languages:
```text
LazyBrowseNode {
Object GalaxyObject (immutable, from server)
HasChildrenHint bool (server's child_has_children value)
Children list<LazyBrowseNode> (empty until Expand)
IsExpanded bool
ExpandAsync(ct) Task (idempotent; no-op after first call)
}
```
`GalaxyRepositoryClient.BrowseAsync(parent?, ct)` returns a list of root
`LazyBrowseNode`s. Empty `parent` means structural roots. Each returned
node is unexpanded; the caller invokes `ExpandAsync` to fetch direct
children. After expand, `Children` is a list of further `LazyBrowseNode`s.
**Pagination is hidden.** `ExpandAsync` walks `next_page_token` internally
until all siblings of this parent are gathered. Callers see one flat
`Children` list.
**Errors:** server `NotFound` becomes a language-idiomatic typed error
(`MxGatewayException` in .NET, `GalaxyNotFoundError` in Python,
`GalaxyError::NotFound` in Rust, typed error in Go,
`GalaxyNotFoundException` in Java).
**Filters:** `BrowseAsync` accepts a `BrowseChildrenOptions` (or
language-equivalent) mirroring the existing `DiscoverHierarchyOptions`. The
same options apply to every `ExpandAsync` call rooted from that factory
call — stored on the node so child expansions inherit them.
## Per-language API
Each language adapts to its own idioms; the structure is parallel.
### .NET (`clients/dotnet/ZB.MOM.WW.MxGateway.Client/GalaxyRepositoryClient.cs`)
```csharp
public sealed class LazyBrowseNode
{
public GalaxyObject Object { get; }
public bool HasChildrenHint { get; }
public IReadOnlyList<LazyBrowseNode> Children { get; }
public bool IsExpanded { get; }
public Task ExpandAsync(CancellationToken ct = default);
}
public Task<IReadOnlyList<LazyBrowseNode>> BrowseAsync(
BrowseChildrenOptions? options = null,
CancellationToken ct = default);
public Task<BrowseChildrenReply> BrowseChildrenRawAsync(
BrowseChildrenRequest request,
CancellationToken ct = default);
```
### Python (`clients/python/src/zb_mom_ww_mxgateway/galaxy.py`)
```python
@dataclass
class LazyBrowseNode:
object: GalaxyObject
has_children_hint: bool
children: list["LazyBrowseNode"]
is_expanded: bool
async def expand(self) -> None: ...
async def browse(
self,
options: BrowseChildrenOptions | None = None,
) -> list[LazyBrowseNode]: ...
```
### Rust (`clients/rust/src/galaxy.rs`)
```rust
pub struct LazyBrowseNode { /* private fields; Arc<Mutex<>> for Children */ }
impl LazyBrowseNode {
pub fn object(&self) -> &GalaxyObject;
pub fn has_children_hint(&self) -> bool;
pub fn children(&self) -> Vec<LazyBrowseNode>; // cloned snapshot
pub fn is_expanded(&self) -> bool;
pub async fn expand(&self) -> Result<(), GalaxyError>;
}
pub async fn browse(
&self,
options: Option<BrowseChildrenOptions>,
) -> Result<Vec<LazyBrowseNode>, GalaxyError>;
```
### Go (`clients/go/mxgateway/galaxy.go`)
```go
type LazyBrowseNode struct { /* unexported */ }
func (n *LazyBrowseNode) Object() *pb.GalaxyObject
func (n *LazyBrowseNode) HasChildrenHint() bool
func (n *LazyBrowseNode) Children() []*LazyBrowseNode
func (n *LazyBrowseNode) IsExpanded() bool
func (n *LazyBrowseNode) Expand(ctx context.Context) error
func (c *Client) Browse(
ctx context.Context,
opts *BrowseChildrenOptions,
) ([]*LazyBrowseNode, error)
```
### Java (`clients/java/zb-mom-ww-mxgateway-client/`)
```java
public final class LazyBrowseNode {
public GalaxyObject getObject();
public boolean hasChildrenHint();
public List<LazyBrowseNode> getChildren();
public boolean isExpanded();
public CompletableFuture<Void> expandAsync();
}
public CompletableFuture<List<LazyBrowseNode>> browseAsync(
BrowseChildrenOptions options);
```
If the existing Java client surface is synchronous, mirror that — both
sync and async variants are acceptable as long as the choice matches the
client's existing convention.
## Tests
Each language adds these six facts against its existing fake-transport
fixture (`FakeGalaxyRepositoryTransport` in .NET, the equivalent in each
other client):
| # | Test | Purpose |
|---|------|---------|
| 1 | `Browse_NoParent_ReturnsRoots` | factory returns roots, each unexpanded, hint reflects fake's `child_has_children` |
| 2 | `Expand_PopulatesChildrenAndMarksExpanded` | one ExpandAsync call fires one BrowseChildren RPC; Children populated; IsExpanded flips |
| 3 | `Expand_CalledTwice_NoSecondRpc` | idempotency — fake records RPC count == 1 |
| 4 | `Expand_UnknownParent_ThrowsGalaxyNotFound` | server NotFound surfaces as language-typed error |
| 5 | `Expand_MultiPageSiblings_GathersAllPages` | fake returns NextPageToken on first call; helper walks pages until empty; flat Children list |
| 6 | `Browse_WithFilter_ForwardsToRequest` | options propagate into the wire request (`tag_name_glob` etc.) |
No new live-only tests in this batch. The existing
`BrowseChildrenSmokeTests` in .NET covers wire compatibility.
## Java toolchain bootstrap
The macOS dev host lacks a JVM. Install via Homebrew (one-time):
```bash
brew install temurin@21
brew install gradle
```
Verify:
```bash
java -version # expect 21.x
gradle --version # expect 8.x or 9.x
```
If the Temurin formula does not auto-link `JAVA_HOME`, add to shell init:
```bash
export JAVA_HOME="$(/usr/libexec/java_home -v 21)"
```
Then verify the existing Java client builds against its committed
generated tree:
```bash
cd clients/java
gradle build -x test
```
If build succeeds, regenerate protos to pick up `BrowseChildren`:
```bash
gradle generateProto
```
This produces the new Java RPC stubs that the walker work depends on.
**Failure path:** if Homebrew installs but `gradle build` fails for an
environmental reason (e.g., proto plugin version mismatch), fall back to
"defer Java" — implement the other four clients and document that Java
walker work waits for the Windows host. Do not spend more than ~30
minutes debugging local Java issues; the Windows host already builds the
Java client cleanly.
## Documentation updates
Each client's `README.md` "Browsing lazily" snippet (added in commit
`0d6193c`) gets one short example block showing the high-level walker
in addition to the existing raw-RPC snippet. Approximately three
sentences plus a 5-line code block per language.
No changes to `gateway.md`, `docs/GalaxyRepository.md`, or
`docs/DesignDecisions.md` — those describe the wire contract; the
walkers are client-side ergonomics, not part of the wire surface.
## Non-goals
- Async iterator / streaming walker (rejected in brainstorming —
encourages eager-to-completion consumption that defeats laziness).
- Explicit `RefreshAsync` on `LazyBrowseNode` (single-shot expand is
enough; caller invalidates the tree by re-calling `BrowseAsync`).
- Tree-builder helpers that pre-fetch the whole hierarchy (that's just
`DiscoverHierarchy` with extra round-trips).
- Server changes — the wire contract is final.
- Cross-client integration test runner — each client tests in isolation.
- Java regen on Mac if Homebrew install fails — defer to Windows host.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,15 @@
{
"planPath": "docs/plans/2026-05-28-client-walker-implementation.md",
"tasks": [
{"id": 23, "subject": "Task 0: Branch state check", "status": "pending"},
{"id": 24, "subject": "Task 1: Java toolchain bootstrap", "status": "pending", "blockedBy": [23]},
{"id": 25, "subject": "Task 2: .NET LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]},
{"id": 26, "subject": "Task 3: Python LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]},
{"id": 27, "subject": "Task 4: Rust LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]},
{"id": 28, "subject": "Task 5: Go LazyBrowseNode walker + 6 tests", "status": "pending", "blockedBy": [23]},
{"id": 29, "subject": "Task 6: Java LazyBrowseNode walker + tests", "status": "pending", "blockedBy": [23, 24]},
{"id": 30, "subject": "Task 7: README walker examples for all 5 clients", "status": "pending", "blockedBy": [25, 26, 27, 28]},
{"id": 31, "subject": "Task 8: Final integration build + verification", "status": "pending", "blockedBy": [29, 30]}
],
"lastUpdated": "2026-05-28T18:30:00Z"
}
+4 -2
View File
@@ -102,12 +102,14 @@ message BrowseChildrenReply {
| Condition | Status | | Condition | Status |
|---|---| |---|---|
| Unknown `parent_gobject_id` / `parent_tag_name` / `parent_contained_path` | `NotFound` | | Unknown `parent_gobject_id` / `parent_tag_name` / `parent_contained_path` | `NotFound` |
| Stale `page_token` (cache deployed forward) | `FailedPrecondition`; current `cache_sequence` in trailers | | Stale `page_token` (cache deployed forward) | `InvalidArgument`; current `cache_sequence` in trailers |
| Filter set differs between pages of the same token | `InvalidArgument` | | Filter set differs between pages of the same token | `InvalidArgument` |
| First load not complete within 5s | `Unavailable` | | First load not complete within 5s | `Unavailable` |
| API key missing `metadata:read` scope | `PermissionDenied` | | API key missing `metadata:read` scope | `PermissionDenied` |
| No API key | `Unauthenticated` | | No API key | `Unauthenticated` |
Stale and filter-changed page tokens both surface as `InvalidArgument` — same contract as `DiscoverHierarchy`, since `BrowseChildren` reuses the same token encoding (`sequence:filter-signature:offset`).
`browse_subtrees` API-key constraints intersect with the request as today. `browse_subtrees` API-key constraints intersect with the request as today.
## Server projection ## Server projection
@@ -224,7 +226,7 @@ Unit tests (no live MXAccess / Galaxy required):
- Ordering matches `DashboardBrowseTreeBuilder` byte-for-byte. - Ordering matches `DashboardBrowseTreeBuilder` byte-for-byte.
- Sibling pagination across multiple pages. - Sibling pagination across multiple pages.
- Page-token round trip (serialize → deserialize → same offset). - Page-token round trip (serialize → deserialize → same offset).
- Stale `page_token``FailedPrecondition`. - Stale `page_token``InvalidArgument`.
- Unknown parent → `NotFound`. - Unknown parent → `NotFound`.
- Filter change between pages of the same token → `InvalidArgument`. - Filter change between pages of the same token → `InvalidArgument`.
- `GalaxyRepositoryGrpcServiceTests` — new `BrowseChildren` happy path, - `GalaxyRepositoryGrpcServiceTests` — new `BrowseChildren` happy path,
+3
View File
@@ -65,6 +65,9 @@ Detailed follow-up docs:
- `docs/GalaxyRepository.md` covers the read-only Galaxy Repository browse - `docs/GalaxyRepository.md` covers the read-only Galaxy Repository browse
RPCs that let clients enumerate the deployed object hierarchy and dynamic RPCs that let clients enumerate the deployed object hierarchy and dynamic
attributes before subscribing via the MXAccess gateway service. attributes before subscribing via the MXAccess gateway service.
`DiscoverHierarchy` returns paged flat results; `BrowseChildren` returns
the direct children of one parent for OPC UA-style lazy tree walks — see
[Galaxy Repository](docs/GalaxyRepository.md#browsechildren).
Implementation style guides: Implementation style guides:
@@ -2,6 +2,8 @@
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IGalaxyHierarchyCache GalaxyCache @inject IGalaxyHierarchyCache GalaxyCache
@inject IDashboardLiveDataService LiveData @inject IDashboardLiveDataService LiveData
@inject IDashboardBrowseService BrowseService
@inject IGalaxyDeployNotifier DeployNotifier
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy @using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
@using ZB.MOM.WW.MxGateway.Server.Galaxy @using ZB.MOM.WW.MxGateway.Server.Galaxy
@@ -71,12 +73,18 @@
} }
else else
{ {
@if (!string.IsNullOrEmpty(_staleBanner))
{
<div class="alert alert-info browse-stale-banner" role="status"
@onclick="ClearStaleBanner">@_staleBanner</div>
}
<div class="browse-tree"> <div class="browse-tree">
@foreach (DashboardBrowseNode root in _roots) @foreach (DashboardBrowseNode root in _roots)
{ {
<BrowseTreeNodeView Node="root" <BrowseTreeNodeView Node="root"
OnAddTag="AddTagAsync" OnAddTag="AddTagAsync"
OnTagContextMenu="OnTagContextMenu" /> OnTagContextMenu="OnTagContextMenu"
OnLoadChildren="LoadChildrenAsync" />
} }
</div> </div>
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div> <div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
@@ -186,7 +194,11 @@
@code { @code {
private const int SearchResultLimit = 300; private const int SearchResultLimit = 300;
private IReadOnlyList<DashboardBrowseNode> _roots = []; private List<DashboardBrowseNode> _roots = [];
private ulong _cacheSequence;
private string? _staleBanner;
private CancellationTokenSource _deployCts = new();
private Task? _deployTask;
private string _search = string.Empty; private string _search = string.Empty;
private IReadOnlyList<GalaxyAttribute> _searchMatches = []; private IReadOnlyList<GalaxyAttribute> _searchMatches = [];
private readonly List<string> _subscribed = []; private readonly List<string> _subscribed = [];
@@ -210,8 +222,63 @@
/// <inheritdoc /> /// <inheritdoc />
protected override void OnInitialized() protected override void OnInitialized()
{ {
_roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects); BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
_roots = [.. roots.Nodes];
_cacheSequence = roots.CacheSequence;
_pollTask = PollLoopAsync(); _pollTask = PollLoopAsync();
_deployTask = SubscribeToDeployEventsAsync();
}
private async Task LoadChildrenAsync(DashboardBrowseNode node)
{
BrowseLevelResult result = BrowseService.GetChildren(node.Object.GobjectId, new BrowseFilterArgs());
if (!string.IsNullOrEmpty(result.Error))
{
throw new InvalidOperationException(result.Error);
}
node.Children.Clear();
foreach (DashboardBrowseNode child in result.Nodes)
{
node.Children.Add(child);
}
// First expand interaction also dismisses the stale banner — the user
// is clearly engaging with the tree, no need to keep nagging.
_staleBanner = null;
await InvokeAsync(StateHasChanged);
}
private async Task SubscribeToDeployEventsAsync()
{
try
{
await foreach (GalaxyDeployEventInfo info in DeployNotifier
.SubscribeAsync(_deployCts.Token)
.ConfigureAwait(false))
{
// First Latest replay echoes the sequence we already projected
// from — skip those to avoid a spurious "redeployed" banner.
if (info.Sequence == 0 || (ulong)info.Sequence == _cacheSequence)
{
continue;
}
BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
_roots = [.. roots.Nodes];
_cacheSequence = roots.CacheSequence;
_staleBanner = "Galaxy redeployed — tree refreshed.";
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
private void ClearStaleBanner()
{
_staleBanner = null;
} }
private string HeaderLine() private string HeaderLine()
@@ -405,6 +472,7 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await _cts.CancelAsync(); await _cts.CancelAsync();
await _deployCts.CancelAsync();
if (_pollTask is not null) if (_pollTask is not null)
{ {
try try
@@ -415,8 +483,19 @@
{ {
} }
} }
if (_deployTask is not null)
{
try
{
await _deployTask;
}
catch (OperationCanceledException)
{
}
}
_cts.Dispose(); _cts.Dispose();
_deployCts.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }
@@ -2,15 +2,21 @@
@* @*
Recursive Browse hierarchy node. Renders one Galaxy object, its child Recursive Browse hierarchy node. Renders one Galaxy object, its child
objects (recursively), and its attributes as right-clickable tag rows. objects (recursively, lazy-loaded on first expand), and its attributes as
Expansion state is local; children render only while expanded. right-clickable tag rows. Expansion state is local; children render only
while expanded.
The expand triangle is shown whenever the server's child_has_children
projector hint is set (HasChildrenHint), even before children have been
loaded — clicking it triggers OnLoadChildren so the parent page can fill
in Node.Children, then the view re-renders.
*@ *@
<div class="tree-node"> <div class="tree-node">
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")"> <div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
@if (Node.HasChildren) @if (ShowToggle())
{ {
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle"> <button type="button" class="tree-toggle" @onclick="ToggleAsync" aria-label="Toggle">
@(_expanded ? "▾" : "▸") @(_expanded ? "▾" : "▸")
</button> </button>
} }
@@ -18,7 +24,7 @@
{ {
<span class="tree-toggle tree-toggle-empty"></span> <span class="tree-toggle tree-toggle-empty"></span>
} }
<span class="tree-label" @onclick="Toggle"> <span class="tree-label" @onclick="ToggleAsync">
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span> <span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
<span class="tree-name">@Node.DisplayName</span> <span class="tree-name">@Node.DisplayName</span>
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName) @if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
@@ -31,9 +37,27 @@
@if (_expanded) @if (_expanded)
{ {
<div class="tree-children"> <div class="tree-children">
@if (Node.LoadState == BrowseLoadState.Loading)
{
<div class="tree-load-status text-secondary">
<span class="tree-toggle tree-toggle-empty"></span>
<span>⌛ Loading…</span>
</div>
}
else if (Node.LoadState == BrowseLoadState.Error)
{
<div class="tree-load-status text-danger">
<span class="tree-toggle tree-toggle-empty"></span>
<span>Failed to load: @Node.LoadError</span>
</div>
}
@foreach (DashboardBrowseNode child in Node.Children) @foreach (DashboardBrowseNode child in Node.Children)
{ {
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" /> <BrowseTreeNodeView Node="child"
OnAddTag="OnAddTag"
OnTagContextMenu="OnTagContextMenu"
OnLoadChildren="OnLoadChildren" />
} }
@foreach (GalaxyAttribute attr in Node.Attributes) @foreach (GalaxyAttribute attr in Node.Attributes)
{ {
@@ -75,13 +99,52 @@
[Parameter] [Parameter]
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; } public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
/// <summary>
/// Invoked on first expand when the projector hint says this node has children
/// but they have not been fetched yet. The callback is expected to populate
/// <see cref="DashboardBrowseNode.Children"/> on the node it receives and then
/// trigger a re-render.
/// </summary>
[Parameter]
public Func<DashboardBrowseNode, Task>? OnLoadChildren { get; set; }
private bool _expanded; private bool _expanded;
private void Toggle() // The triangle is shown whenever the projector says children exist (even
// pre-load), or attributes are already present, or already-loaded children
// are sitting on the node.
private bool ShowToggle()
{ {
if (Node.HasChildren) return Node.HasChildrenHint
|| Node.Attributes.Count > 0
|| Node.Children.Count > 0;
}
private async Task ToggleAsync()
{
if (!ShowToggle())
{ {
_expanded = !_expanded; return;
}
_expanded = !_expanded;
if (_expanded
&& Node.HasChildrenHint
&& Node.LoadState == BrowseLoadState.NotLoaded
&& OnLoadChildren is not null)
{
Node.LoadState = BrowseLoadState.Loading;
try
{
await OnLoadChildren(Node);
Node.LoadState = BrowseLoadState.Loaded;
}
catch (Exception ex)
{
Node.LoadState = BrowseLoadState.Error;
Node.LoadError = ex.Message;
}
} }
} }
@@ -29,6 +29,33 @@ public sealed class DashboardBrowseNode
/// <summary>True when the node has child objects or attributes to expand.</summary> /// <summary>True when the node has child objects or attributes to expand.</summary>
public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0; public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0;
/// <summary>Whether this node has at least one matching descendant, per the
/// server's <c>child_has_children</c> projector hint. Controls whether the UI
/// shows an expand triangle before children have actually loaded.</summary>
public bool HasChildrenHint { get; init; }
/// <summary>The lazy-load state for this node's children.</summary>
public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded;
/// <summary>Short error string if the last load attempt failed; null otherwise.</summary>
public string? LoadError { get; set; }
}
/// <summary>Lazy-load lifecycle of a browse node's children.</summary>
public enum BrowseLoadState
{
/// <summary>Children have not been requested yet.</summary>
NotLoaded,
/// <summary>A load is in progress.</summary>
Loading,
/// <summary>Children have been loaded into <see cref="DashboardBrowseNode.Children"/>.</summary>
Loaded,
/// <summary>The last load attempt failed; see <see cref="DashboardBrowseNode.LoadError"/>.</summary>
Error,
} }
/// <summary> /// <summary>
@@ -0,0 +1,82 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Default <see cref="IDashboardBrowseService"/>. Delegates to
/// <see cref="GalaxyBrowseProjector"/> via the shared
/// <see cref="IGalaxyHierarchyCache"/>; no SQL hop, no gRPC self-call. Translates
/// the projector's <see cref="RpcException"/> on unknown parent into a friendly
/// <see cref="BrowseLevelResult.Error"/> so the Blazor circuit does not see an
/// unhandled exception.
/// </summary>
public sealed class DashboardBrowseService(IGalaxyHierarchyCache cache) : IDashboardBrowseService
{
/// <inheritdoc />
public ulong CurrentCacheSequence => (ulong)cache.Current.Sequence;
/// <inheritdoc />
public BrowseLevelResult GetRoots(BrowseFilterArgs filter)
=> ProjectLevel(parentId: null, filter);
/// <inheritdoc />
public BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter)
=> ProjectLevel(parentId: parentGobjectId, filter);
private BrowseLevelResult ProjectLevel(int? parentId, BrowseFilterArgs filter)
{
ArgumentNullException.ThrowIfNull(filter);
GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData)
{
return new BrowseLevelResult(
Array.Empty<DashboardBrowseNode>(),
0,
(ulong)entry.Sequence,
Error: "Galaxy hierarchy is not loaded yet.");
}
BrowseChildrenRequest request = new()
{
TagNameGlob = filter.TagNameGlob ?? string.Empty,
AlarmBearingOnly = filter.AlarmBearingOnly,
HistorizedOnly = filter.HistorizedOnly,
};
if (parentId is int pid)
{
request.ParentGobjectId = pid;
}
try
{
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
request,
browseSubtreeGlobs: null,
offset: 0,
pageSize: int.MaxValue);
List<DashboardBrowseNode> nodes = new(result.Children.Count);
for (int i = 0; i < result.Children.Count; i++)
{
nodes.Add(new DashboardBrowseNode
{
Object = result.Children[i],
HasChildrenHint = result.ChildHasChildren[i],
});
}
return new BrowseLevelResult(nodes, result.TotalChildCount, (ulong)entry.Sequence);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
return new BrowseLevelResult(
Array.Empty<DashboardBrowseNode>(),
0,
(ulong)entry.Sequence,
Error: ex.Status.Detail);
}
}
}
@@ -25,6 +25,7 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>(); services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
services.AddSingleton<HubTokenService>(); services.AddSingleton<HubTokenService>();
services.AddScoped<Hubs.DashboardHubConnectionFactory>(); services.AddScoped<Hubs.DashboardHubConnectionFactory>();
services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();
services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>(); services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>();
services.AddHostedService<Hubs.DashboardSnapshotPublisher>(); services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
services.AddHostedService<Hubs.AlarmsHubPublisher>(); services.AddHostedService<Hubs.AlarmsHubPublisher>();
@@ -0,0 +1,44 @@
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// In-process facade over <see cref="GalaxyBrowseProjector"/> for the dashboard's
/// BrowsePage. Provides one-level-at-a-time browse without going through the
/// gRPC stack. Backed by the same shared <see cref="IGalaxyHierarchyCache"/> the
/// gRPC service uses, so dashboard and external clients render identical results.
/// </summary>
public interface IDashboardBrowseService
{
/// <summary>Returns root browse nodes (objects with no parent).</summary>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
/// <summary>Returns the direct children of the given parent gobject id.</summary>
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
ulong CurrentCacheSequence { get; }
}
/// <summary>Filter arguments forwarded into the projector.</summary>
/// <param name="TagNameGlob">Optional tag-name glob filter (case-insensitive).</param>
/// <param name="AlarmBearingOnly">When true, only return objects with at least one alarm-bearing attribute.</param>
/// <param name="HistorizedOnly">When true, only return objects with at least one historized attribute.</param>
public sealed record BrowseFilterArgs(
string? TagNameGlob = null,
bool AlarmBearingOnly = false,
bool HistorizedOnly = false);
/// <summary>One level of browse data plus the cache sequence it was projected from.</summary>
/// <param name="Nodes">The direct-child nodes for the requested parent (or roots when no parent given).</param>
/// <param name="TotalCount">Total matching sibling count, post-filter.</param>
/// <param name="CacheSequence">The cache entry sequence this result was projected from.</param>
/// <param name="Error">Friendly error string if the projection failed; null on success.</param>
public sealed record BrowseLevelResult(
IReadOnlyList<DashboardBrowseNode> Nodes,
int TotalCount,
ulong CacheSequence,
string? Error = null);
@@ -0,0 +1,156 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Coverage for <see cref="DashboardBrowseService"/> — the in-process facade the
/// Blazor BrowsePage uses to walk the Galaxy hierarchy one level at a time. The
/// service must surface the projector's <c>child_has_children</c> hint, expose the
/// current cache sequence, and translate the projector's
/// <see cref="Grpc.Core.RpcException"/> on unknown parent into a friendly error
/// rather than propagating it into the Blazor circuit.
/// </summary>
public sealed class DashboardBrowseServiceTests
{
/// <summary>Verifies that <see cref="DashboardBrowseService.GetRoots"/> returns root-level
/// objects with the <c>HasChildrenHint</c> projector bit set, and reports the cache
/// sequence of the entry it projected from.</summary>
[Fact]
public void GetRoots_ReturnsRootObjects_WithHasChildrenHint()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetRoots(new BrowseFilterArgs());
Assert.Single(result.Nodes);
Assert.Equal("Plant", result.Nodes[0].Object.TagName);
Assert.True(result.Nodes[0].HasChildrenHint);
Assert.Equal(11UL, result.CacheSequence);
Assert.Null(result.Error);
}
/// <summary>Verifies that <see cref="DashboardBrowseService.GetChildren"/> returns the
/// direct children of the requested parent and that leaf nodes report
/// <c>HasChildrenHint == false</c>.</summary>
[Fact]
public void GetChildren_ByParentGobjectId_ReturnsDirectChildren()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 1, new BrowseFilterArgs());
Assert.Single(result.Nodes);
Assert.Equal("Mixer_001", result.Nodes[0].Object.TagName);
Assert.False(result.Nodes[0].HasChildrenHint);
Assert.Null(result.Error);
}
/// <summary>Verifies that an unknown parent id does not surface the projector's
/// <see cref="Grpc.Core.RpcException"/> — the service catches the NotFound and
/// returns an empty <see cref="BrowseLevelResult"/> with the error string set.</summary>
[Fact]
public void GetChildren_UnknownParent_ReturnsEmptyResultWithErrorFlag()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 999, new BrowseFilterArgs());
Assert.Empty(result.Nodes);
Assert.NotNull(result.Error);
Assert.False(string.IsNullOrEmpty(result.Error));
}
/// <summary>Verifies that swapping the cache's <c>Current</c> entry (as the refresh loop
/// does after a deploy bump) causes subsequent queries to observe the new sequence.</summary>
[Fact]
public void CacheSequence_AdvancesAfterRefresh_NewQueriesReflectIt()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult before = service.GetRoots(new BrowseFilterArgs());
Assert.Equal(11UL, before.CacheSequence);
cache.Current = CreateEntry(CreateObjects(), sequence: 12);
BrowseLevelResult after = service.GetRoots(new BrowseFilterArgs());
Assert.Equal(12UL, after.CacheSequence);
}
/// <summary>Verifies that <see cref="DashboardBrowseService.GetChildren"/> returns an
/// empty node list with a non-empty <c>Error</c> when the cache has not yet loaded
/// (i.e. <see cref="GalaxyHierarchyCacheEntry.HasData"/> is false).</summary>
[Fact]
public void GetChildren_CacheNotLoaded_ReturnsErrorResult()
{
StubGalaxyHierarchyCache cache = new(GalaxyHierarchyCacheEntry.Empty);
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 1, new BrowseFilterArgs());
Assert.Empty(result.Nodes);
Assert.False(string.IsNullOrEmpty(result.Error));
}
private static IReadOnlyList<GalaxyObject> CreateObjects()
{
// Fixture: an Area "Plant" (id 1, parent 0, IsArea=true) containing one
// Instance "Mixer_001" (id 2, parent 1). Both with no attributes — the
// service is exercised through the projector, which only needs id /
// parent / IsArea / display name to build the level slice.
return
[
new GalaxyObject
{
GobjectId = 1,
ParentGobjectId = 0,
TagName = "Plant",
BrowseName = "Plant",
IsArea = true,
},
new GalaxyObject
{
GobjectId = 2,
ParentGobjectId = 1,
TagName = "Mixer_001",
BrowseName = "Mixer_001",
IsArea = false,
},
];
}
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> objects, long sequence)
{
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = sequence,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
ObjectCount = objects.Count,
},
ObjectCount = objects.Count,
};
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry initial) : IGalaxyHierarchyCache
{
/// <summary>Mutable so a single test can swap the entry mid-flight.</summary>
public GalaxyHierarchyCacheEntry Current { get; set; } = initial;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}