Compare commits
40 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| e13152f340 | |||
| deba5ed115 | |||
| 4bf71a0b2c | |||
| b4a7bac4c0 | |||
| 6df373ae4c | |||
| fe44e3c18a | |||
| 523f944f3e | |||
| c33f1e6047 | |||
| 92cc4688e6 | |||
| a155554038 | |||
| 68f905a344 | |||
| 5abc222c72 | |||
| da3aa7b0b2 | |||
| f0ec068430 | |||
| 1a1d14a9fd | |||
| b2448510ac | |||
| 75610e3f55 | |||
| 5032166106 | |||
| 76a042d663 | |||
| 4a19854eb9 | |||
| a4467e23ef | |||
| eacfeff9fb | |||
| b4bc2df015 | |||
| fd2a0ac4c7 | |||
| 555e4be51f | |||
| 1d8c0d83c4 | |||
| 6600f2a7bd | |||
| 803a207ad2 | |||
| 97e583e96b | |||
| eaf479349d | |||
| 83a4d41fce | |||
| 0d6193cdc4 | |||
| 8cd3e1c20e | |||
| 5c28458624 | |||
| 0b389f5a97 | |||
| 108c4bb118 | |||
| cf54a278e1 | |||
| 81b2aacfe2 | |||
| 5932fe2fd3 | |||
| 310dfab8b4 |
@@ -0,0 +1,21 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<!-- Shared package metadata for clients/dotnet/. Individual projects opt in via <IsPackable>true</IsPackable>. -->
|
||||
<Authors>Joseph Doherty</Authors>
|
||||
<Company>ZB MOM WW</Company>
|
||||
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||
<Product>MxAccessGateway Client</Product>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
||||
<PackageTags>mxaccess;mxgateway;grpc;client;archestra</PackageTags>
|
||||
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||
<!-- Versioning: bump per release. Symbols ship as snupkg. -->
|
||||
<Version>0.1.0</Version>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
<!-- Default: do NOT pack. Each project opts in. -->
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
|
||||
@@ -251,6 +299,29 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Pump001.Speed'
|
||||
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
```
|
||||
|
||||
## Installing as a NuGet Package
|
||||
|
||||
The client publishes to the internal Gitea NuGet feed at
|
||||
`https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json`.
|
||||
|
||||
Add the feed once:
|
||||
|
||||
````bash
|
||||
dotnet nuget add source https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json \
|
||||
--name dohertj2-gitea \
|
||||
--username <gitea-username> \
|
||||
--password <gitea-token-or-password> \
|
||||
--store-password-in-clear-text
|
||||
````
|
||||
|
||||
Then add the package to your project:
|
||||
|
||||
````bash
|
||||
dotnet add package ZB.MOM.WW.MxGateway.Client --version 0.1.0
|
||||
````
|
||||
|
||||
The `ZB.MOM.WW.MxGateway.Contracts` package is pulled in transitively.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// Gets the list of WatchDeployEvents RPC calls made by the client.
|
||||
/// </summary>
|
||||
|
||||
@@ -0,0 +1,221 @@
|
||||
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());
|
||||
Assert.False(roots[0].IsExpanded);
|
||||
Assert.Empty(roots[0].Children);
|
||||
}
|
||||
|
||||
/// <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
|
||||
{
|
||||
private const int DiscoverHierarchyPageSize = 5000;
|
||||
private const int BrowseChildrenPageSize = 500;
|
||||
|
||||
private readonly GrpcChannel? _channel;
|
||||
private readonly IGalaxyRepositoryClientTransport _transport;
|
||||
@@ -278,6 +279,89 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
||||
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>
|
||||
/// 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
|
||||
|
||||
@@ -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 />
|
||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||
WatchDeployEventsRequest request,
|
||||
|
||||
@@ -33,6 +33,13 @@ internal interface IGalaxyRepositoryClientTransport
|
||||
DiscoverHierarchyRequest request,
|
||||
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>
|
||||
/// <param name="request">The watch deploy events request.</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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,9 +7,11 @@ namespace ZB.MOM.WW.MxGateway.Client;
|
||||
/// </summary>
|
||||
public static class MxGatewayClientContractInfo
|
||||
{
|
||||
/// <inheritdoc cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
||||
public const uint GatewayProtocolVersion =
|
||||
GatewayContractInfo.GatewayProtocolVersion;
|
||||
|
||||
/// <inheritdoc cref="GatewayContractInfo.WorkerProtocolVersion"/>
|
||||
public const uint WorkerProtocolVersion =
|
||||
GatewayContractInfo.WorkerProtocolVersion;
|
||||
}
|
||||
|
||||
@@ -16,4 +16,15 @@
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Client</PackageId>
|
||||
<Description>.NET 10 gRPC client for the MxAccessGateway service. Provides typed wrappers, retry, and a lazy-browse walker over the Galaxy Repository hierarchy.</Description>
|
||||
<PackageReadmeFile>README.md</PackageReadmeFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Include="..\README.md" Pack="true" PackagePath="\" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -121,6 +121,68 @@ reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
|
||||
the generated `*GalaxyObject` slice with each object's dynamic attributes
|
||||
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
|
||||
|
||||
`WatchDeployEvents` opens a server-streaming subscription. The server emits a
|
||||
@@ -213,6 +275,38 @@ $env:MXGATEWAY_TEST_ITEM = 'Area001.Tag.Value'
|
||||
go run ./cmd/mxgw-go smoke -endpoint $env:MXGATEWAY_ENDPOINT -plaintext -api-key-env MXGATEWAY_API_KEY -item $env:MXGATEWAY_TEST_ITEM -json
|
||||
```
|
||||
|
||||
## Installing the Go client
|
||||
|
||||
The module is resolved directly from the git repo — no package registry:
|
||||
|
||||
````bash
|
||||
go get gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go@v0.1.0
|
||||
````
|
||||
|
||||
Then import:
|
||||
|
||||
````go
|
||||
import "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/mxgateway"
|
||||
````
|
||||
|
||||
If your build environment cannot reach `gitea.dohertylan.com` directly,
|
||||
configure `GOPROXY` to point at an internal proxy that fronts the Gitea
|
||||
repo, or use `GONOSUMCHECK` + `GOPRIVATE` to bypass the checksum database
|
||||
for the internal module path.
|
||||
|
||||
## Releasing a new version
|
||||
|
||||
Go modules in monorepo subdirectories use prefixed tags. To tag a release
|
||||
from this repo:
|
||||
|
||||
````bash
|
||||
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
|
||||
````
|
||||
|
||||
The script validates semver, refuses to tag with uncommitted tracked
|
||||
changes, creates an annotated tag `clients/go/v0.1.1`, and (with `-Push`)
|
||||
pushes it to origin.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -824,6 +824,260 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
|
||||
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
|
||||
|
||||
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" +
|
||||
"\ris_historized\x18\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" +
|
||||
"\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" +
|
||||
"\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 (
|
||||
file_galaxy_repository_proto_rawDescOnce sync.Once
|
||||
@@ -916,7 +1193,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
|
||||
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{
|
||||
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
|
||||
(*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
|
||||
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
|
||||
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
|
||||
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp
|
||||
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value
|
||||
(*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
|
||||
(*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{
|
||||
10, // 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
|
||||
12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||
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
|
||||
10, // 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
|
||||
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
|
||||
12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
|
||||
12, // 4: galaxy_repository.v1.DeployEvent.observed_at: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
|
||||
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
||||
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
||||
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
||||
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
||||
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
||||
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
||||
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
||||
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
||||
11, // [11:15] is the sub-list for method output_type
|
||||
7, // [7:11] is the sub-list for method input_type
|
||||
7, // [7:7] is the sub-list for extension type_name
|
||||
7, // [7:7] is the sub-list for extension extendee
|
||||
0, // [0:7] is the sub-list for field type_name
|
||||
8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
|
||||
0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
|
||||
2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
|
||||
4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
|
||||
6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
|
||||
10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
|
||||
1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
|
||||
3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
|
||||
5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
|
||||
7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
|
||||
11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
|
||||
13, // [13:18] is the sub-list for method output_type
|
||||
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() }
|
||||
@@ -964,13 +1246,18 @@ func file_galaxy_repository_proto_init() {
|
||||
(*DiscoverHierarchyRequest_RootTagName)(nil),
|
||||
(*DiscoverHierarchyRequest_RootContainedPath)(nil),
|
||||
}
|
||||
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
|
||||
(*BrowseChildrenRequest_ParentGobjectId)(nil),
|
||||
(*BrowseChildrenRequest_ParentTagName)(nil),
|
||||
(*BrowseChildrenRequest_ParentContainedPath)(nil),
|
||||
}
|
||||
type x struct{}
|
||||
out := protoimpl.TypeBuilder{
|
||||
File: protoimpl.DescBuilder{
|
||||
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
|
||||
NumEnums: 0,
|
||||
NumMessages: 10,
|
||||
NumMessages: 12,
|
||||
NumExtensions: 0,
|
||||
NumServices: 1,
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v7.34.1
|
||||
// source: galaxy_repository.proto
|
||||
|
||||
@@ -23,6 +23,7 @@ const (
|
||||
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
|
||||
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
|
||||
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
|
||||
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
|
||||
)
|
||||
|
||||
// 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
|
||||
// older events because the client was too slow.
|
||||
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 {
|
||||
@@ -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.
|
||||
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.
|
||||
// All implementations must embed UnimplementedGalaxyRepositoryServer
|
||||
// for forward compatibility.
|
||||
@@ -122,6 +138,11 @@ type GalaxyRepositoryServer interface {
|
||||
// increasing per server start; gaps indicate the per-subscriber buffer dropped
|
||||
// older events because the client was too slow.
|
||||
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()
|
||||
}
|
||||
|
||||
@@ -144,6 +165,9 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
|
||||
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
|
||||
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) 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.
|
||||
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.
|
||||
// It's only intended for direct use with grpc.RegisterService,
|
||||
// and not to be introspected or modified (even as a copy)
|
||||
@@ -249,6 +291,10 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
|
||||
MethodName: "DiscoverHierarchy",
|
||||
Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
|
||||
},
|
||||
{
|
||||
MethodName: "BrowseChildren",
|
||||
Handler: _GalaxyRepository_BrowseChildren_Handler,
|
||||
},
|
||||
},
|
||||
Streams: []grpc.StreamDesc{
|
||||
{
|
||||
|
||||
@@ -725,9 +725,10 @@ func (SessionState) EnumDescriptor() ([]byte, []int) {
|
||||
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8}
|
||||
}
|
||||
|
||||
// Public request shape for QueryActiveAlarms. session_id is currently unused
|
||||
// (the snapshot is session-less) but reserved so a future per-session view
|
||||
// can be added without a wire break.
|
||||
// Public request shape for QueryActiveAlarms.
|
||||
// Clients may leave `session_id` empty; the gateway currently ignores it and
|
||||
// serves the session-less central-monitor cache. A future version may use it
|
||||
// to scope the snapshot to one session.
|
||||
type QueryActiveAlarmsRequest struct {
|
||||
state protoimpl.MessageState `protogen:"open.v1"`
|
||||
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.
|
||||
// versions:
|
||||
// - protoc-gen-go-grpc v1.6.1
|
||||
// - protoc-gen-go-grpc v1.6.2
|
||||
// - protoc v7.34.1
|
||||
// source: mxaccess_gateway.proto
|
||||
|
||||
@@ -50,6 +50,9 @@ type MxAccessGatewayClient interface {
|
||||
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||
// have been missed during a transport blip. Streamed so callers can
|
||||
// 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)
|
||||
}
|
||||
|
||||
@@ -180,6 +183,9 @@ type MxAccessGatewayServer interface {
|
||||
// reconnect to seed Part 9 client state, or to reconcile alarms that may
|
||||
// have been missed during a transport blip. Streamed so callers can
|
||||
// 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
|
||||
mustEmbedUnimplementedMxAccessGatewayServer()
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ package mxgateway
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
@@ -13,6 +15,14 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
// browseChildrenPageSize is the per-request page size used by the lazy walker.
|
||||
const browseChildrenPageSize = 500
|
||||
|
||||
// discoverHierarchyPageSize is the per-request page size used by DiscoverHierarchy.
|
||||
// Mirrors the .NET client constant so large galaxies are not silently truncated
|
||||
// by the server's default page cap.
|
||||
const discoverHierarchyPageSize = 5000
|
||||
|
||||
// RawGalaxyRepositoryClient is the generated gRPC client interface for the
|
||||
// Galaxy Repository service exposed for callers that need direct contract
|
||||
// access.
|
||||
@@ -40,6 +50,10 @@ type (
|
||||
WatchDeployEventsRequest = pb.WatchDeployEventsRequest
|
||||
// DeployEvent is one Galaxy Repository deploy event.
|
||||
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.
|
||||
@@ -146,16 +160,35 @@ func (c *GalaxyClient) GetLastDeployTime(ctx context.Context) (time.Time, bool,
|
||||
|
||||
// DiscoverHierarchy returns the deployed Galaxy object hierarchy with each
|
||||
// object's dynamic attributes. The objects are returned in the order supplied
|
||||
// by the server.
|
||||
// by the server. The call pages over the server's NextPageToken until the
|
||||
// server signals it has no more results, matching the .NET client.
|
||||
func (c *GalaxyClient) DiscoverHierarchy(ctx context.Context) ([]*GalaxyObject, error) {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
defer cancel()
|
||||
|
||||
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{})
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||
var objects []*GalaxyObject
|
||||
pageToken := ""
|
||||
seen := map[string]struct{}{}
|
||||
for {
|
||||
callCtx, cancel := c.callContext(ctx)
|
||||
reply, err := c.raw.DiscoverHierarchy(callCtx, &pb.DiscoverHierarchyRequest{
|
||||
PageSize: discoverHierarchyPageSize,
|
||||
PageToken: pageToken,
|
||||
})
|
||||
cancel()
|
||||
if err != nil {
|
||||
return nil, &GatewayError{Op: "galaxy discover hierarchy", Err: err}
|
||||
}
|
||||
objects = append(objects, reply.GetObjects()...)
|
||||
pageToken = reply.GetNextPageToken()
|
||||
if pageToken == "" {
|
||||
return objects, nil
|
||||
}
|
||||
if _, dup := seen[pageToken]; dup {
|
||||
return nil, &GatewayError{
|
||||
Op: "galaxy discover hierarchy",
|
||||
Err: fmt.Errorf("repeated page token %q", pageToken),
|
||||
}
|
||||
}
|
||||
seen[pageToken] = struct{}{}
|
||||
}
|
||||
return reply.GetObjects(), nil
|
||||
}
|
||||
|
||||
// WatchDeployEventsRaw starts the generated WatchDeployEvents stream for callers
|
||||
@@ -238,6 +271,206 @@ func (c *GalaxyClient) Close() error {
|
||||
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 coalesce onto
|
||||
// a single in-flight RPC and do not block snapshot accessors.
|
||||
type LazyBrowseNode struct {
|
||||
client *GalaxyClient
|
||||
object *pb.GalaxyObject
|
||||
hasChildrenHint bool
|
||||
options BrowseChildrenOptions
|
||||
|
||||
// expandLock gates inspection and mutation of expand-coordination state
|
||||
// (expanding, expandDone, expandErr). It is held only briefly; the BrowseChildren
|
||||
// RPC itself runs outside this lock so concurrent readers and waiters are not blocked.
|
||||
expandLock sync.Mutex
|
||||
expanding bool
|
||||
expandDone chan struct{}
|
||||
expandErr error
|
||||
|
||||
// mu protects the children snapshot and isExpanded flag for concurrent
|
||||
// Children() / IsExpanded() readers.
|
||||
mu sync.RWMutex
|
||||
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.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
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.RLock()
|
||||
defer n.mu.RUnlock()
|
||||
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.
|
||||
//
|
||||
// Expand is safe to call concurrently from multiple goroutines: callers that
|
||||
// arrive while an expansion is in flight wait on the active RPC and share its
|
||||
// result instead of issuing a second RPC. The RPC itself runs without holding
|
||||
// the snapshot mutex, so concurrent Children() and IsExpanded() callers are
|
||||
// not blocked for the duration of the network round trip.
|
||||
//
|
||||
// Failure semantics: a failed expansion surfaces the same error to every
|
||||
// in-flight waiter, but the node is left in its pre-call state (isExpanded =
|
||||
// false, no in-flight expansion). The next Expand call therefore retries with
|
||||
// a fresh RPC; failures are not sticky.
|
||||
func (n *LazyBrowseNode) Expand(ctx context.Context) error {
|
||||
// Fast path: already expanded.
|
||||
n.mu.RLock()
|
||||
if n.isExpanded {
|
||||
n.mu.RUnlock()
|
||||
return nil
|
||||
}
|
||||
n.mu.RUnlock()
|
||||
|
||||
// Either start a new expansion or wait on an existing one.
|
||||
n.expandLock.Lock()
|
||||
n.mu.RLock()
|
||||
alreadyExpanded := n.isExpanded
|
||||
n.mu.RUnlock()
|
||||
if alreadyExpanded {
|
||||
n.expandLock.Unlock()
|
||||
return nil
|
||||
}
|
||||
if n.expanding {
|
||||
done := n.expandDone
|
||||
n.expandLock.Unlock()
|
||||
select {
|
||||
case <-done:
|
||||
n.expandLock.Lock()
|
||||
err := n.expandErr
|
||||
n.expandLock.Unlock()
|
||||
return err
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
n.expanding = true
|
||||
n.expandDone = make(chan struct{})
|
||||
done := n.expandDone
|
||||
n.expandLock.Unlock()
|
||||
|
||||
// Issue the RPC outside any lock so concurrent readers/waiters are not blocked.
|
||||
parentID := n.object.GetGobjectId()
|
||||
children, err := n.client.browseChildrenInner(ctx, &parentID, n.options)
|
||||
|
||||
if err == nil {
|
||||
n.mu.Lock()
|
||||
n.children = children
|
||||
n.isExpanded = true
|
||||
n.mu.Unlock()
|
||||
}
|
||||
|
||||
// Publish result to waiters and clear the in-flight marker so a failed
|
||||
// expansion can be retried by the next Expand call.
|
||||
n.expandLock.Lock()
|
||||
n.expandErr = err
|
||||
n.expanding = false
|
||||
close(done)
|
||||
n.expandLock.Unlock()
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// 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, &GatewayError{
|
||||
Op: "galaxy browse children",
|
||||
Err: fmt.Errorf("repeated page token %q", pageToken),
|
||||
}
|
||||
}
|
||||
seen[pageToken] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *GalaxyClient) callContext(ctx context.Context) (context.Context, context.CancelFunc) {
|
||||
timeout := c.opts.CallTimeout
|
||||
if timeout == 0 {
|
||||
|
||||
@@ -4,11 +4,14 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
"google.golang.org/grpc/test/bufconn"
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
@@ -144,6 +147,47 @@ func TestGalaxyDiscoverHierarchyReturnsObjects(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalaxyDiscoverHierarchyPaginatesAcrossMultiplePages(t *testing.T) {
|
||||
page1 := &pb.DiscoverHierarchyReply{
|
||||
Objects: []*pb.GalaxyObject{
|
||||
{GobjectId: 1, TagName: "A"},
|
||||
{GobjectId: 2, TagName: "B"},
|
||||
},
|
||||
NextPageToken: "page-2",
|
||||
TotalObjectCount: 3,
|
||||
}
|
||||
page2 := &pb.DiscoverHierarchyReply{
|
||||
Objects: []*pb.GalaxyObject{
|
||||
{GobjectId: 3, TagName: "C"},
|
||||
},
|
||||
TotalObjectCount: 3,
|
||||
}
|
||||
fake := &fakeGalaxyServer{
|
||||
discoverHierarchyReplies: []*pb.DiscoverHierarchyReply{page1, page2},
|
||||
}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
objs, err := client.DiscoverHierarchy(context.Background())
|
||||
if err != nil {
|
||||
t.Fatalf("DiscoverHierarchy: %v", err)
|
||||
}
|
||||
if got, want := len(objs), 3; got != want {
|
||||
t.Fatalf("len(objs) = %d, want %d", got, want)
|
||||
}
|
||||
if len(fake.discoverHierarchyCalls) != 2 {
|
||||
t.Fatalf("expected 2 RPC calls, got %d", len(fake.discoverHierarchyCalls))
|
||||
}
|
||||
if fake.discoverHierarchyCalls[0].GetPageSize() != discoverHierarchyPageSize {
|
||||
t.Fatalf("first call PageSize = %d, want %d",
|
||||
fake.discoverHierarchyCalls[0].GetPageSize(), discoverHierarchyPageSize)
|
||||
}
|
||||
if fake.discoverHierarchyCalls[1].GetPageToken() != "page-2" {
|
||||
t.Fatalf("second call page token = %q, want %q",
|
||||
fake.discoverHierarchyCalls[1].GetPageToken(), "page-2")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalaxyDialReturnsGatewayErrorOnRpcFailure(t *testing.T) {
|
||||
fake := &fakeGalaxyServer{failTest: true}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
@@ -370,15 +414,20 @@ func newGalaxyBufconnClient(t *testing.T, fake *fakeGalaxyServer) (*GalaxyClient
|
||||
type fakeGalaxyServer struct {
|
||||
pb.UnimplementedGalaxyRepositoryServer
|
||||
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
watchRequest *pb.WatchDeployEventsRequest
|
||||
watchSendInterval time.Duration
|
||||
watchHoldOpen bool
|
||||
testReply *pb.TestConnectionReply
|
||||
testAuth string
|
||||
failTest bool
|
||||
deployReply *pb.GetLastDeployTimeReply
|
||||
discoverReply *pb.DiscoverHierarchyReply
|
||||
discoverHierarchyCalls []*pb.DiscoverHierarchyRequest
|
||||
discoverHierarchyReplies []*pb.DiscoverHierarchyReply
|
||||
watchEvents []*pb.DeployEvent
|
||||
watchRequest *pb.WatchDeployEventsRequest
|
||||
watchSendInterval time.Duration
|
||||
watchHoldOpen bool
|
||||
browseChildrenCalls []*pb.BrowseChildrenRequest
|
||||
browseChildrenReplies []*pb.BrowseChildrenReply
|
||||
browseChildrenError error
|
||||
}
|
||||
|
||||
func (s *fakeGalaxyServer) TestConnection(ctx context.Context, req *pb.TestConnectionRequest) (*pb.TestConnectionReply, error) {
|
||||
@@ -400,6 +449,12 @@ func (s *fakeGalaxyServer) GetLastDeployTime(ctx context.Context, req *pb.GetLas
|
||||
}
|
||||
|
||||
func (s *fakeGalaxyServer) DiscoverHierarchy(ctx context.Context, req *pb.DiscoverHierarchyRequest) (*pb.DiscoverHierarchyReply, error) {
|
||||
s.discoverHierarchyCalls = append(s.discoverHierarchyCalls, req)
|
||||
if len(s.discoverHierarchyReplies) > 0 {
|
||||
reply := s.discoverHierarchyReplies[0]
|
||||
s.discoverHierarchyReplies = s.discoverHierarchyReplies[1:]
|
||||
return reply, nil
|
||||
}
|
||||
if s.discoverReply != nil {
|
||||
return s.discoverReply, nil
|
||||
}
|
||||
@@ -425,3 +480,385 @@ func (s *fakeGalaxyServer) WatchDeployEvents(req *pb.WatchDeployEventsRequest, s
|
||||
}
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalaxyBrowseExpandConcurrentCallersOnlyFireOneRpc(t *testing.T) {
|
||||
fake := &fakeGalaxyServer{
|
||||
browseChildrenReplies: []*pb.BrowseChildrenReply{
|
||||
// roots
|
||||
buildBrowseReply([]*pb.GalaxyObject{obj(1, "Plant", true)}, []bool{true}, 7),
|
||||
// one expand: one child
|
||||
buildBrowseReply([]*pb.GalaxyObject{obj(2, "Mixer", false)}, []bool{false}, 7),
|
||||
},
|
||||
}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
ctx := context.Background()
|
||||
roots, err := client.Browse(ctx, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Browse: %v", err)
|
||||
}
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 10)
|
||||
for i := 0; i < 10; i++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
errs <- roots[0].Expand(ctx)
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatalf("concurrent Expand: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
if !roots[0].IsExpanded() {
|
||||
t.Fatal("IsExpanded() = false after 10 concurrent expands")
|
||||
}
|
||||
if got, want := len(roots[0].Children()), 1; got != want {
|
||||
t.Fatalf("len(children) = %d, want %d", got, want)
|
||||
}
|
||||
// 1 roots fetch + exactly 1 expand fetch.
|
||||
if got, want := len(fake.browseChildrenCalls), 2; got != want {
|
||||
t.Fatalf("RPC count = %d, want %d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGalaxyBrowseChildrenRejectsRepeatedPageToken(t *testing.T) {
|
||||
// Build a reply that carries a non-empty NextPageToken so browseChildrenInner
|
||||
// will request a second page. Queue the same reply twice so the second response
|
||||
// returns the same page token, triggering the duplicate-token guard.
|
||||
page := buildBrowseReply(
|
||||
[]*pb.GalaxyObject{obj(1, "Plant", true)},
|
||||
[]bool{true},
|
||||
1,
|
||||
)
|
||||
page.NextPageToken = "1:abc:1"
|
||||
|
||||
fake := &fakeGalaxyServer{
|
||||
browseChildrenReplies: []*pb.BrowseChildrenReply{page, page},
|
||||
}
|
||||
client, cleanup := newGalaxyBufconnClient(t, fake)
|
||||
defer cleanup()
|
||||
|
||||
_, err := client.Browse(context.Background(), nil)
|
||||
if err == nil {
|
||||
t.Fatal("Browse: error = nil, want repeated-page-token error")
|
||||
}
|
||||
var gwErr *GatewayError
|
||||
if !errors.As(err, &gwErr) {
|
||||
t.Fatalf("error type = %T, want *GatewayError; err = %v", err, err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,28 @@ type Options struct {
|
||||
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
|
||||
// key for diagnostics and CLI output.
|
||||
func (o Options) RedactedAPIKey() string {
|
||||
|
||||
@@ -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"
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
|
||||
@@ -229,6 +282,37 @@ $env:MXGATEWAY_TEST_ITEM = 'TestObject.TestInt'
|
||||
gradle :zb-mom-ww-mxgateway-cli:run --args="smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json"
|
||||
```
|
||||
|
||||
## Installing from the Gitea Maven repository
|
||||
|
||||
The client publishes to the internal Gitea Maven repository at
|
||||
`https://gitea.dohertylan.com/api/packages/dohertj2/maven`.
|
||||
|
||||
In your consumer project's `build.gradle`:
|
||||
|
||||
````groovy
|
||||
repositories {
|
||||
maven {
|
||||
url 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||
credentials {
|
||||
username = System.getenv('GITEA_USERNAME')
|
||||
password = System.getenv('GITEA_TOKEN')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation 'com.zb.mom.ww.mxgateway:zb-mom-ww-mxgateway-client:0.1.0'
|
||||
}
|
||||
````
|
||||
|
||||
To publish a new version from this repo:
|
||||
|
||||
````bash
|
||||
export GITEA_USERNAME=dohertj2
|
||||
export GITEA_TOKEN=<your-gitea-token>
|
||||
gradle :zb-mom-ww-mxgateway-client:publish
|
||||
````
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -37,4 +37,44 @@ subprojects {
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
}
|
||||
|
||||
pluginManager.withPlugin('maven-publish') {
|
||||
publishing {
|
||||
publications {
|
||||
maven(MavenPublication) {
|
||||
from components.java
|
||||
pom {
|
||||
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||
description = 'MxAccessGateway Java client'
|
||||
scm {
|
||||
url = 'https://gitea.dohertylan.com/dohertj2/mxaccessgw'
|
||||
connection = 'scm:git:https://gitea.dohertylan.com/dohertj2/mxaccessgw.git'
|
||||
}
|
||||
developers {
|
||||
developer {
|
||||
id = 'dohertj2'
|
||||
name = 'Joseph Doherty'
|
||||
}
|
||||
}
|
||||
licenses {
|
||||
license {
|
||||
name = 'Proprietary'
|
||||
distribution = 'repo'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
repositories {
|
||||
maven {
|
||||
name = 'GiteaPackages'
|
||||
url = 'https://gitea.dohertylan.com/api/packages/dohertj2/maven'
|
||||
credentials {
|
||||
username = System.getenv('GITEA_USERNAME') ?: ''
|
||||
password = System.getenv('GITEA_TOKEN') ?: ''
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+111
@@ -142,6 +142,37 @@ public final class GalaxyRepositoryGrpc {
|
||||
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
|
||||
*/
|
||||
@@ -246,6 +277,19 @@ public final class GalaxyRepositoryGrpc {
|
||||
io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent> 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(
|
||||
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(
|
||||
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(
|
||||
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(
|
||||
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_GET_LAST_DEPLOY_TIME = 1;
|
||||
private static final int METHODID_DISCOVER_HIERARCHY = 2;
|
||||
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
|
||||
io.grpc.stub.ServerCalls.UnaryMethod<Req, Resp>,
|
||||
@@ -534,6 +633,10 @@ public final class GalaxyRepositoryGrpc {
|
||||
serviceImpl.watchDeployEvents((galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest) request,
|
||||
(io.grpc.stub.StreamObserver<galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>) responseObserver);
|
||||
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:
|
||||
throw new AssertionError();
|
||||
}
|
||||
@@ -580,6 +683,13 @@ public final class GalaxyRepositoryGrpc {
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.WatchDeployEventsRequest,
|
||||
galaxy_repository.v1.GalaxyRepositoryOuterClass.DeployEvent>(
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -632,6 +742,7 @@ public final class GalaxyRepositoryGrpc {
|
||||
.addMethod(getGetLastDeployTimeMethod())
|
||||
.addMethod(getDiscoverHierarchyMethod())
|
||||
.addMethod(getWatchDeployEventsMethod())
|
||||
.addMethod(getBrowseChildrenMethod())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
|
||||
+3650
-14
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,7 @@
|
||||
plugins {
|
||||
id 'java-library'
|
||||
id 'com.google.protobuf'
|
||||
id 'maven-publish'
|
||||
}
|
||||
|
||||
dependencies {
|
||||
@@ -30,6 +31,11 @@ sourceSets {
|
||||
}
|
||||
}
|
||||
|
||||
java {
|
||||
withSourcesJar()
|
||||
withJavadocJar()
|
||||
}
|
||||
|
||||
protobuf {
|
||||
protoc {
|
||||
artifact = "com.google.protobuf:protoc:${protobufVersion}"
|
||||
|
||||
+105
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+95
@@ -4,6 +4,8 @@ import com.google.common.util.concurrent.FutureCallback;
|
||||
import com.google.common.util.concurrent.Futures;
|
||||
import com.google.common.util.concurrent.MoreExecutors;
|
||||
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.DiscoverHierarchyReply;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||
@@ -37,6 +39,7 @@ import javax.net.ssl.SSLException;
|
||||
*/
|
||||
public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
private static final int DISCOVER_HIERARCHY_PAGE_SIZE = 5000;
|
||||
private static final int BROWSE_CHILDREN_PAGE_SIZE = 500;
|
||||
|
||||
private final ManagedChannel ownedChannel;
|
||||
private final MxGatewayClientOptions options;
|
||||
@@ -213,6 +216,98 @@ public final class GalaxyRepositoryClient implements AutoCloseable {
|
||||
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
|
||||
* results through a blocking iterator. Closing the returned stream cancels
|
||||
|
||||
+150
@@ -0,0 +1,150 @@
|
||||
package com.zb.mom.ww.mxgateway.client;
|
||||
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.GalaxyObject;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutionException;
|
||||
import java.util.concurrent.locks.ReentrantReadWriteLock;
|
||||
|
||||
/**
|
||||
* 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;
|
||||
|
||||
// expandLock gates the start of a new expand AND the publish of the in-flight
|
||||
// future. Readers (getChildren / isExpanded) use a separate read-write lock so
|
||||
// they never block on the gRPC call.
|
||||
private final Object expandLock = new Object();
|
||||
private CompletableFuture<Void> inFlight;
|
||||
|
||||
private final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
|
||||
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() {
|
||||
readWriteLock.readLock().lock();
|
||||
try {
|
||||
return List.copyOf(children);
|
||||
} finally {
|
||||
readWriteLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/** @return {@code true} after the first {@link #expand()} call completes. */
|
||||
public boolean isExpanded() {
|
||||
readWriteLock.readLock().lock();
|
||||
try {
|
||||
return isExpanded;
|
||||
} finally {
|
||||
readWriteLock.readLock().unlock();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches direct children from the gateway and populates {@link #getChildren()}.
|
||||
* Idempotent: subsequent calls are no-ops and do not issue a second RPC.
|
||||
*
|
||||
* <p>Concurrent callers coalesce onto a single in-flight RPC: the first caller
|
||||
* (the "leader") issues the gRPC call, while any other thread that calls
|
||||
* {@code expand()} during that window blocks on the leader's future and sees
|
||||
* the same result (or the same exception). On failure the in-flight slot is
|
||||
* cleared so a subsequent call can retry.
|
||||
*
|
||||
* <p>Readers ({@link #getChildren()} / {@link #isExpanded()}) take a separate
|
||||
* read lock and are never blocked for the duration of the RPC.
|
||||
*
|
||||
* @throws MxGatewayException on transport or protocol failure
|
||||
*/
|
||||
public void expand() {
|
||||
if (isExpanded()) {
|
||||
return;
|
||||
}
|
||||
|
||||
CompletableFuture<Void> future;
|
||||
boolean iAmTheLeader;
|
||||
synchronized (expandLock) {
|
||||
if (isExpanded()) {
|
||||
return;
|
||||
}
|
||||
if (inFlight != null) {
|
||||
future = inFlight;
|
||||
iAmTheLeader = false;
|
||||
} else {
|
||||
future = new CompletableFuture<>();
|
||||
inFlight = future;
|
||||
iAmTheLeader = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (iAmTheLeader) {
|
||||
try {
|
||||
List<LazyBrowseNode> loaded =
|
||||
client.browseChildrenInner(object.getGobjectId(), options);
|
||||
readWriteLock.writeLock().lock();
|
||||
try {
|
||||
this.children = loaded;
|
||||
this.isExpanded = true;
|
||||
} finally {
|
||||
readWriteLock.writeLock().unlock();
|
||||
}
|
||||
synchronized (expandLock) {
|
||||
inFlight = null;
|
||||
}
|
||||
future.complete(null);
|
||||
} catch (RuntimeException ex) {
|
||||
synchronized (expandLock) {
|
||||
inFlight = null;
|
||||
}
|
||||
future.completeExceptionally(ex);
|
||||
throw ex;
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
future.get();
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
throw new MxGatewayException("Interrupted waiting for browse-children expand.", ie);
|
||||
} catch (ExecutionException ee) {
|
||||
Throwable cause = ee.getCause();
|
||||
if (cause instanceof MxGatewayException me) {
|
||||
throw me;
|
||||
}
|
||||
if (cause instanceof RuntimeException re) {
|
||||
throw re;
|
||||
}
|
||||
throw new MxGatewayException("BrowseChildren expand failed.", cause);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+321
@@ -8,6 +8,8 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import com.google.protobuf.Timestamp;
|
||||
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.DiscoverHierarchyReply;
|
||||
import galaxy_repository.v1.GalaxyRepositoryOuterClass.DiscoverHierarchyRequest;
|
||||
@@ -24,6 +26,7 @@ import io.grpc.Server;
|
||||
import io.grpc.ServerCall;
|
||||
import io.grpc.ServerCallHandler;
|
||||
import io.grpc.ServerInterceptor;
|
||||
import io.grpc.Status;
|
||||
import io.grpc.inprocess.InProcessChannelBuilder;
|
||||
import io.grpc.inprocess.InProcessServerBuilder;
|
||||
import io.grpc.stub.ClientCallStreamObserver;
|
||||
@@ -31,11 +34,20 @@ import io.grpc.stub.ClientResponseObserver;
|
||||
import io.grpc.stub.StreamObserver;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayDeque;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
import java.util.Queue;
|
||||
import java.util.UUID;
|
||||
import java.util.ArrayList;
|
||||
import java.util.concurrent.CopyOnWriteArrayList;
|
||||
import java.util.concurrent.CountDownLatch;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.Future;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
@@ -196,6 +208,27 @@ final class GalaxyRepositoryClientTests {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void browseChildrenRejectsRepeatedPageToken() throws Exception {
|
||||
// Queue the same BrowseChildrenReply twice with a non-empty NextPageToken.
|
||||
// The client will request a second page and detect that the token repeats.
|
||||
BrowseChildrenService service = new BrowseChildrenService();
|
||||
BrowseChildrenReply repeatedReply = browseReply(
|
||||
List.of(obj(1, "Plant", true)),
|
||||
List.of(true),
|
||||
1L,
|
||||
"1:abc:1");
|
||||
service.replies.add(repeatedReply);
|
||||
service.replies.add(repeatedReply);
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
MxGatewayException error = assertThrows(MxGatewayException.class, client::browse);
|
||||
|
||||
assertTrue(error.getMessage().contains("repeated page token"));
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
void watchDeployEventsReceivesEventsInOrder() throws Exception {
|
||||
DeployEvent first = DeployEvent.newBuilder()
|
||||
@@ -306,6 +339,294 @@ 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 browseExpandConcurrentCallersOnlyFireOneRpc() throws Exception {
|
||||
// Verifies that concurrent expand() calls coalesce onto a single in-flight
|
||||
// BrowseChildren RPC and that readers (isExpanded/getChildren) are not
|
||||
// blocked for the full RPC duration.
|
||||
BrowseChildrenReply rootsReply = browseReply(
|
||||
List.of(obj(1, "Plant", true)),
|
||||
List.of(true),
|
||||
7L,
|
||||
"");
|
||||
BrowseChildrenReply childrenReply = browseReply(
|
||||
List.of(obj(2, "Mixer_001", false)),
|
||||
List.of(false),
|
||||
7L,
|
||||
"");
|
||||
|
||||
// Gate the child fetch behind a latch so multiple expanders can pile up.
|
||||
CountDownLatch release = new CountDownLatch(1);
|
||||
AtomicInteger childCalls = new AtomicInteger();
|
||||
BrowseChildrenService service = new BrowseChildrenService() {
|
||||
@Override
|
||||
public void browseChildren(
|
||||
BrowseChildrenRequest request, StreamObserver<BrowseChildrenReply> responseObserver) {
|
||||
calls.add(request);
|
||||
BrowseChildrenReply reply;
|
||||
if (!request.hasParentGobjectId()) {
|
||||
reply = rootsReply;
|
||||
} else {
|
||||
// Block the leader until the followers have arrived.
|
||||
try {
|
||||
assertTrue(release.await(5, TimeUnit.SECONDS), "release latch never tripped");
|
||||
} catch (InterruptedException ie) {
|
||||
Thread.currentThread().interrupt();
|
||||
responseObserver.onError(Status.CANCELLED.asRuntimeException());
|
||||
return;
|
||||
}
|
||||
childCalls.incrementAndGet();
|
||||
reply = childrenReply;
|
||||
}
|
||||
responseObserver.onNext(reply);
|
||||
responseObserver.onCompleted();
|
||||
}
|
||||
};
|
||||
|
||||
try (InProcessGalaxy g = InProcessGalaxy.start(service, new AtomicReference<>());
|
||||
GalaxyRepositoryClient client = g.client("")) {
|
||||
List<LazyBrowseNode> roots = client.browse();
|
||||
LazyBrowseNode root = roots.get(0);
|
||||
|
||||
int parallelism = 10;
|
||||
ExecutorService pool = Executors.newFixedThreadPool(parallelism);
|
||||
try {
|
||||
CountDownLatch ready = new CountDownLatch(parallelism);
|
||||
List<Future<Void>> futures = new ArrayList<>();
|
||||
for (int i = 0; i < parallelism; i++) {
|
||||
futures.add(pool.submit(() -> {
|
||||
ready.countDown();
|
||||
root.expand();
|
||||
return null;
|
||||
}));
|
||||
}
|
||||
// Wait for all callers to be in flight, then release the leader.
|
||||
assertTrue(ready.await(5, TimeUnit.SECONDS), "expander threads did not start");
|
||||
// Readers must not be blocked by an in-flight expand; this should not deadlock
|
||||
// and should return the pre-expand state.
|
||||
assertFalse(root.isExpanded());
|
||||
assertEquals(0, root.getChildren().size());
|
||||
release.countDown();
|
||||
|
||||
for (Future<Void> f : futures) {
|
||||
f.get(10, TimeUnit.SECONDS);
|
||||
}
|
||||
} finally {
|
||||
pool.shutdownNow();
|
||||
}
|
||||
|
||||
assertTrue(root.isExpanded());
|
||||
assertEquals(1, root.getChildren().size());
|
||||
// Exactly one expand RPC was issued even though many callers raced.
|
||||
assertEquals(1, childCalls.get());
|
||||
// 1 roots fetch + exactly 1 expand fetch.
|
||||
assertEquals(2, service.calls.size());
|
||||
}
|
||||
}
|
||||
|
||||
@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 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 {
|
||||
@Override
|
||||
public void testConnection(
|
||||
|
||||
@@ -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
|
||||
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
|
||||
|
||||
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
|
||||
@@ -225,6 +268,19 @@ $env:MXGATEWAY_TEST_ITEM = 'Object.Attribute'
|
||||
mxgw-py smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --api-key-env MXGATEWAY_API_KEY --item $env:MXGATEWAY_TEST_ITEM --json
|
||||
```
|
||||
|
||||
## Installing from the Gitea PyPI Feed
|
||||
|
||||
The client publishes to the internal Gitea PyPI feed:
|
||||
|
||||
````bash
|
||||
pip install \
|
||||
--index-url https://gitea.dohertylan.com/api/packages/dohertj2/pypi/simple/ \
|
||||
zb-mom-ww-mxaccess-gateway-client
|
||||
````
|
||||
|
||||
If you need authentication (private feed), use `--extra-index-url` and either
|
||||
a `~/.netrc` entry or `PIP_INDEX_URL=https://<user>:<token>@gitea.dohertylan.com/...`.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Client Packaging](../../docs/ClientPackaging.md)
|
||||
|
||||
@@ -13,12 +13,35 @@ dependencies = [
|
||||
"grpcio>=1.80,<2",
|
||||
"protobuf>=6.33,<7",
|
||||
]
|
||||
authors = [
|
||||
{ name = "Joseph Doherty" },
|
||||
]
|
||||
license = { text = "Proprietary" }
|
||||
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||
classifiers = [
|
||||
"Development Status :: 3 - Alpha",
|
||||
"License :: Other/Proprietary License",
|
||||
"Programming Language :: Python :: 3",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: System :: Distributed Computing",
|
||||
"Topic :: Software Development :: Libraries :: Python Modules",
|
||||
"Intended Audience :: Developers",
|
||||
"Operating System :: OS Independent",
|
||||
]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
Issues = "https://gitea.dohertylan.com/dohertj2/mxaccessgw/issues"
|
||||
|
||||
[project.optional-dependencies]
|
||||
dev = [
|
||||
"grpcio-tools>=1.80,<2",
|
||||
"pytest>=9,<10",
|
||||
"pytest-asyncio>=1.3,<2",
|
||||
"build>=1.2,<2",
|
||||
"twine>=5,<6",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@@ -21,9 +21,10 @@ from .auth import merge_metadata
|
||||
from .errors import MxGatewayError, map_rpc_error
|
||||
from .generated import galaxy_repository_pb2 as galaxy_pb
|
||||
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
|
||||
_BROWSE_CHILDREN_PAGE_SIZE = 500
|
||||
|
||||
|
||||
class GalaxyRepositoryClient:
|
||||
@@ -139,6 +140,89 @@ class GalaxyRepositoryClient:
|
||||
)
|
||||
seen_page_tokens.add(page_token)
|
||||
|
||||
async def browse_children_raw(
|
||||
self, request: galaxy_pb.BrowseChildrenRequest
|
||||
) -> galaxy_pb.BrowseChildrenReply:
|
||||
"""Issue one BrowseChildren RPC and return the raw reply.
|
||||
|
||||
Lower-level escape hatch for callers that need direct page-token control
|
||||
or do not want LazyBrowseNode wrapping. Most callers should use
|
||||
:py:meth:`browse` and :py:meth:`LazyBrowseNode.expand` instead.
|
||||
"""
|
||||
|
||||
return await self._unary(
|
||||
"browse children",
|
||||
self.raw_stub.BrowseChildren,
|
||||
request,
|
||||
)
|
||||
|
||||
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(
|
||||
self,
|
||||
last_seen_deploy_time: datetime | None = None,
|
||||
@@ -202,6 +286,67 @@ class GalaxyRepositoryClient:
|
||||
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]:
|
||||
try:
|
||||
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
|
||||
|
||||
|
||||
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()
|
||||
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
|
||||
@@ -54,6 +54,10 @@ if not _descriptor._USE_C_DESCRIPTORS:
|
||||
_globals['_GALAXYOBJECT']._serialized_end=1416
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_start=1419
|
||||
_globals['_GALAXYATTRIBUTE']._serialized_end=1715
|
||||
_globals['_GALAXYREPOSITORY']._serialized_start=1718
|
||||
_globals['_GALAXYREPOSITORY']._serialized_end=2178
|
||||
_globals['_BROWSECHILDRENREQUEST']._serialized_start=1718
|
||||
_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)
|
||||
|
||||
@@ -65,6 +65,11 @@ class GalaxyRepositoryStub(object):
|
||||
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
|
||||
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
|
||||
_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):
|
||||
@@ -111,6 +116,16 @@ class GalaxyRepositoryServicer(object):
|
||||
context.set_details('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):
|
||||
rpc_method_handlers = {
|
||||
@@ -134,6 +149,11 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
|
||||
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
|
||||
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(
|
||||
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
|
||||
@@ -263,3 +283,30 @@ class GalaxyRepository(object):
|
||||
timeout,
|
||||
metadata,
|
||||
_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
|
||||
have been missed during a transport blip. Streamed so callers can
|
||||
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_details('Method not implemented!')
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
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:
|
||||
"""Create a plaintext or TLS `grpc.aio` channel from client options."""
|
||||
|
||||
|
||||
@@ -6,12 +6,16 @@ import asyncio
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
import grpc
|
||||
import pytest
|
||||
from google.protobuf.timestamp_pb2 import Timestamp
|
||||
|
||||
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_grpc as galaxy_pb_grpc
|
||||
from zb_mom_ww_mxgateway.options import BrowseChildrenOptions
|
||||
|
||||
|
||||
def test_galaxy_messages_import() -> None:
|
||||
@@ -268,15 +272,281 @@ async def test_close_marks_channel_closed_when_no_real_channel() -> None:
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_browse_children_raw_returns_reply_unwrapped() -> None:
|
||||
"""browse_children_raw forwards the request to the stub and returns the raw reply."""
|
||||
stub = FakeGalaxyStub()
|
||||
expected = _build_browse_reply(
|
||||
children=[_obj(1, "Plant", is_area=True)],
|
||||
child_has_children=[True],
|
||||
cache_sequence=42,
|
||||
)
|
||||
stub.browse_children.replies = [expected]
|
||||
|
||||
async with await GalaxyRepositoryClient.connect(
|
||||
endpoint="fake",
|
||||
plaintext=True,
|
||||
stub=stub,
|
||||
) as client:
|
||||
request = galaxy_pb.BrowseChildrenRequest(
|
||||
page_size=10,
|
||||
tag_name_glob="Plant*",
|
||||
)
|
||||
reply = await client.browse_children_raw(request)
|
||||
|
||||
assert reply.cache_sequence == 42
|
||||
assert len(reply.children) == 1
|
||||
assert reply.children[0].tag_name == "Plant"
|
||||
assert len(stub.browse_children.requests) == 1
|
||||
assert stub.browse_children.requests[0].tag_name_glob == "Plant*"
|
||||
|
||||
|
||||
class FakeGalaxyStub:
|
||||
def __init__(self) -> None:
|
||||
self.test_connection = FakeUnary([galaxy_pb.TestConnectionReply(ok=False)])
|
||||
self.get_last_deploy_time = FakeUnary([galaxy_pb.GetLastDeployTimeReply(present=False)])
|
||||
self.discover_hierarchy = FakeUnary([galaxy_pb.DiscoverHierarchyReply()])
|
||||
self.browse_children = FakeUnary([galaxy_pb.BrowseChildrenReply()])
|
||||
self.watch_deploy_events = FakeStream([])
|
||||
self.TestConnection = self.test_connection
|
||||
self.GetLastDeployTime = self.get_last_deploy_time
|
||||
self.DiscoverHierarchy = self.discover_hierarchy
|
||||
self.BrowseChildren = self.browse_children
|
||||
|
||||
@property
|
||||
def WatchDeployEvents(self) -> "FakeStream": # noqa: N802 — gRPC naming
|
||||
@@ -287,6 +557,8 @@ class FakeUnary:
|
||||
def __init__(self, replies: list[Any]) -> None:
|
||||
self.replies = replies
|
||||
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
|
||||
|
||||
async def __call__(
|
||||
@@ -298,6 +570,10 @@ class FakeUnary:
|
||||
) -> Any:
|
||||
self.requests.append(request)
|
||||
self.metadata = metadata
|
||||
if self.exceptions:
|
||||
exc = self.exceptions.pop(0)
|
||||
if exc is not None:
|
||||
raise exc
|
||||
return self.replies.pop(0)
|
||||
|
||||
|
||||
|
||||
@@ -17,3 +17,6 @@
|
||||
# args through the GNU linker and reject `/STACK:`, are unaffected.
|
||||
[target.'cfg(all(windows, target_env = "msvc"))']
|
||||
rustflags = ["-C", "link-arg=/STACK:8388608"]
|
||||
|
||||
[registries.dohertj2-gitea]
|
||||
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||
|
||||
+14
-2
@@ -2,7 +2,16 @@
|
||||
name = "zb-mom-ww-mxgateway-client"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
authors = ["Joseph Doherty"]
|
||||
description = "Async Rust client for the MxAccessGateway gRPC service, including a lazy-browse walker over the Galaxy Repository hierarchy."
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
homepage = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
documentation = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
readme = "README.md"
|
||||
keywords = ["mxaccess", "mxgateway", "grpc", "client", "archestra"]
|
||||
categories = ["api-bindings", "asynchronous"]
|
||||
publish = ["dohertj2-gitea"]
|
||||
build = "build.rs"
|
||||
|
||||
[workspace]
|
||||
@@ -12,7 +21,10 @@ resolver = "2"
|
||||
[workspace.package]
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
publish = false
|
||||
authors = ["Joseph Doherty"]
|
||||
license = "Proprietary"
|
||||
repository = "https://gitea.dohertylan.com/dohertj2/mxaccessgw"
|
||||
publish = ["dohertj2-gitea"]
|
||||
|
||||
[workspace.dependencies]
|
||||
clap = { version = "4.5.53", features = ["derive"] }
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
### 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
|
||||
|
||||
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The
|
||||
@@ -192,3 +236,27 @@ cargo run -p mxgw-cli -- smoke --endpoint $env:MXGATEWAY_ENDPOINT --plaintext --
|
||||
- [Client Proto Generation](../../docs/ClientProtoGeneration.md)
|
||||
- [Rust Client Detailed Design](./RustClientDesign.md)
|
||||
- [Rust Style Guide](../../docs/style-guides/RustStyleGuide.md)
|
||||
|
||||
## Installing from the Gitea Cargo registry
|
||||
|
||||
The crate publishes to the internal Gitea Cargo registry. Register the
|
||||
registry once in your global `~/.cargo/config.toml`:
|
||||
|
||||
```toml
|
||||
[registries.dohertj2-gitea]
|
||||
index = "sparse+https://gitea.dohertylan.com/api/packages/dohertj2/cargo/"
|
||||
```
|
||||
|
||||
Authentication: cargo reads credentials from `~/.cargo/credentials.toml`:
|
||||
|
||||
```toml
|
||||
[registries.dohertj2-gitea]
|
||||
token = "Bearer <your-gitea-token>"
|
||||
```
|
||||
|
||||
Then add the dependency:
|
||||
|
||||
```toml
|
||||
[dependencies]
|
||||
zb-mom-ww-mxgateway-client = { version = "0.1.0", registry = "dohertj2-gitea" }
|
||||
```
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
name = "mxgw-cli"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
publish = false
|
||||
|
||||
[[bin]]
|
||||
name = "mxgw"
|
||||
|
||||
+536
-5
@@ -5,9 +5,12 @@
|
||||
//! read-only RPCs as Rust async methods. Generated Galaxy proto types are
|
||||
//! re-exported through [`crate::generated::galaxy_repository::v1`].
|
||||
|
||||
use std::collections::HashSet;
|
||||
use std::fs;
|
||||
use std::sync::Arc;
|
||||
|
||||
use prost_types::Timestamp;
|
||||
use tokio::sync::Mutex as AsyncMutex;
|
||||
use tonic::codegen::InterceptedService;
|
||||
use tonic::transport::{Certificate, Channel, ClientTlsConfig};
|
||||
use tonic::Request;
|
||||
@@ -16,12 +19,130 @@ use crate::auth::AuthInterceptor;
|
||||
use crate::error::Error;
|
||||
use crate::generated::galaxy_repository::v1::galaxy_repository_client::GalaxyRepositoryClient;
|
||||
use crate::generated::galaxy_repository::v1::{
|
||||
DeployEvent, DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest,
|
||||
TestConnectionRequest, WatchDeployEventsRequest,
|
||||
browse_children_request, BrowseChildrenReply, BrowseChildrenRequest, DeployEvent,
|
||||
DiscoverHierarchyRequest, GalaxyObject, GetLastDeployTimeRequest, TestConnectionRequest,
|
||||
WatchDeployEventsRequest,
|
||||
};
|
||||
use crate::options::ClientOptions;
|
||||
|
||||
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
|
||||
/// 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.
|
||||
///
|
||||
/// The server emits a bootstrap event describing the current cache state
|
||||
@@ -234,9 +448,10 @@ mod tests {
|
||||
GalaxyRepository, GalaxyRepositoryServer,
|
||||
};
|
||||
use crate::generated::galaxy_repository::v1::{
|
||||
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute,
|
||||
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply,
|
||||
TestConnectionRequest, WatchDeployEventsRequest,
|
||||
BrowseChildrenReply, BrowseChildrenRequest, DeployEvent, DiscoverHierarchyReply,
|
||||
DiscoverHierarchyRequest, GalaxyAttribute, GalaxyObject, GetLastDeployTimeReply,
|
||||
GetLastDeployTimeRequest, TestConnectionReply, TestConnectionRequest,
|
||||
WatchDeployEventsRequest,
|
||||
};
|
||||
|
||||
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
|
||||
@@ -249,6 +464,9 @@ mod tests {
|
||||
objects: Mutex<Vec<GalaxyObject>>,
|
||||
discover_requests: Mutex<Vec<DiscoverHierarchyRequest>>,
|
||||
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_events: Mutex<Vec<DeployEvent>>,
|
||||
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 =
|
||||
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
|
||||
|
||||
@@ -695,4 +935,295 @@ mod tests {
|
||||
"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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -362,6 +362,19 @@ Dashboard access should require API-key-backed dashboard authentication with
|
||||
is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is
|
||||
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
|
||||
|
||||
These are explicit post-v1 revisit items, not open blockers:
|
||||
|
||||
@@ -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. |
|
||||
| `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). |
|
||||
| `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`
|
||||
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`
|
||||
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
|
||||
|
||||
The gateway holds a single shared `IGalaxyHierarchyCache`
|
||||
@@ -271,9 +323,13 @@ fields cannot express null. Use it to distinguish "no dimension reported" from
|
||||
```text
|
||||
gRPC client(s)
|
||||
-> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/)
|
||||
DiscoverHierarchy, GetLastDeployTime -> IGalaxyHierarchyCache.Current
|
||||
WatchDeployEvents -> IGalaxyDeployNotifier
|
||||
TestConnection -> GalaxyRepository (direct SQL)
|
||||
DiscoverHierarchy, GetLastDeployTime, BrowseChildren -> IGalaxyHierarchyCache.Current
|
||||
WatchDeployEvents -> IGalaxyDeployNotifier
|
||||
TestConnection -> GalaxyRepository (direct SQL)
|
||||
|
||||
Dashboard (Blazor)
|
||||
-> IDashboardBrowseService (DashboardBrowseService)
|
||||
-> GalaxyBrowseProjector over IGalaxyHierarchyCache.Current
|
||||
|
||||
GalaxyHierarchyRefreshService (BackgroundService)
|
||||
-> IGalaxyHierarchyCache.RefreshAsync
|
||||
@@ -309,9 +365,17 @@ Component breakdown:
|
||||
(`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
|
||||
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`
|
||||
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements
|
||||
the four RPCs.
|
||||
the five RPCs.
|
||||
|
||||
## 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"
|
||||
}
|
||||
@@ -102,12 +102,14 @@ message BrowseChildrenReply {
|
||||
| Condition | Status |
|
||||
|---|---|
|
||||
| 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` |
|
||||
| First load not complete within 5s | `Unavailable` |
|
||||
| API key missing `metadata:read` scope | `PermissionDenied` |
|
||||
| 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.
|
||||
|
||||
## Server projection
|
||||
@@ -224,7 +226,7 @@ Unit tests (no live MXAccess / Galaxy required):
|
||||
- Ordering matches `DashboardBrowseTreeBuilder` byte-for-byte.
|
||||
- Sibling pagination across multiple pages.
|
||||
- Page-token round trip (serialize → deserialize → same offset).
|
||||
- Stale `page_token` → `FailedPrecondition`.
|
||||
- Stale `page_token` → `InvalidArgument`.
|
||||
- Unknown parent → `NotFound`.
|
||||
- Filter change between pages of the same token → `InvalidArgument`.
|
||||
- `GalaxyRepositoryGrpcServiceTests` — new `BrowseChildren` happy path,
|
||||
|
||||
@@ -65,6 +65,9 @@ Detailed follow-up docs:
|
||||
- `docs/GalaxyRepository.md` covers the read-only Galaxy Repository browse
|
||||
RPCs that let clients enumerate the deployed object hierarchy and dynamic
|
||||
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:
|
||||
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
#Requires -Version 7
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Packs all MxAccessGateway clients into a single dist/ directory.
|
||||
|
||||
.DESCRIPTION
|
||||
Runs each language client's native packaging command:
|
||||
.NET -> dotnet pack (NuGet)
|
||||
Python -> python -m build (sdist + wheel)
|
||||
Rust -> cargo package (.crate)
|
||||
Java -> gradle assemble + jars (jar + sources + javadoc + pom)
|
||||
Go -> skipped; use scripts/tag-go-module.ps1
|
||||
|
||||
All artifacts land in -OutputDir (default: dist/).
|
||||
|
||||
With -Publish, each language pushes its package to the internal Gitea
|
||||
feed. Requires GITEA_USERNAME and GITEA_TOKEN env vars.
|
||||
|
||||
.PARAMETER OutputDir
|
||||
Where to drop the packed artifacts. Default: ./dist
|
||||
|
||||
.PARAMETER Languages
|
||||
Subset of languages to pack. Default: all five.
|
||||
Values: dotnet, python, rust, java, go
|
||||
|
||||
.PARAMETER Publish
|
||||
After packing, upload to Gitea feeds. Requires:
|
||||
GITEA_USERNAME
|
||||
GITEA_TOKEN
|
||||
Will refuse to publish if either is missing.
|
||||
|
||||
.PARAMETER SkipTests
|
||||
Skip per-language regression tests before packing. Default: false.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh scripts/pack-clients.ps1
|
||||
pwsh scripts/pack-clients.ps1 -Languages dotnet,python
|
||||
pwsh scripts/pack-clients.ps1 -Publish
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$OutputDir = (Join-Path $PSScriptRoot '..' 'dist'),
|
||||
[string[]]$Languages = @('dotnet', 'python', 'rust', 'java', 'go'),
|
||||
[switch]$Publish,
|
||||
[switch]$SkipTests
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Normalize comma-separated strings that shells may pass as a single element.
|
||||
$validLanguages = @('dotnet', 'python', 'rust', 'java', 'go')
|
||||
$Languages = @($Languages | ForEach-Object { $_ -split ',' } | ForEach-Object {
|
||||
$_.Trim().ToLowerInvariant()
|
||||
} | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
|
||||
|
||||
foreach ($lang in $Languages) {
|
||||
if ($validLanguages -notcontains $lang) {
|
||||
throw "Unsupported language '$lang'. Supported values: $($validLanguages -join ', ')."
|
||||
}
|
||||
}
|
||||
|
||||
if ($Languages.Count -eq 0) {
|
||||
throw "At least one language is required. Supported values: $($validLanguages -join ', ')."
|
||||
}
|
||||
|
||||
# Resolve absolute output dir
|
||||
$OutputDir = [System.IO.Path]::GetFullPath($OutputDir)
|
||||
$RepoRoot = [System.IO.Path]::GetFullPath((Join-Path $PSScriptRoot '..'))
|
||||
|
||||
if (-not (Test-Path $OutputDir)) {
|
||||
New-Item -ItemType Directory -Path $OutputDir | Out-Null
|
||||
}
|
||||
|
||||
if ($Publish) {
|
||||
if ([string]::IsNullOrEmpty($env:GITEA_USERNAME)) {
|
||||
throw 'Publish requires GITEA_USERNAME env var.'
|
||||
}
|
||||
if ([string]::IsNullOrEmpty($env:GITEA_TOKEN)) {
|
||||
throw 'Publish requires GITEA_TOKEN env var.'
|
||||
}
|
||||
}
|
||||
|
||||
$GiteaNugetFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/nuget/index.json'
|
||||
$GiteaPypiFeed = 'https://gitea.dohertylan.com/api/packages/dohertj2/pypi'
|
||||
$JavaHome = '/Users/dohertj2/.local/jdks/jdk-21.0.11+10/Contents/Home'
|
||||
|
||||
function Write-Header {
|
||||
param([string]$Text)
|
||||
Write-Host ''
|
||||
Write-Host '=== ' -NoNewline -ForegroundColor Cyan
|
||||
Write-Host $Text -ForegroundColor Cyan
|
||||
}
|
||||
|
||||
# -------- .NET --------
|
||||
|
||||
function Invoke-PackDotnet {
|
||||
Write-Header '.NET'
|
||||
|
||||
if (-not $SkipTests) {
|
||||
Write-Host 'Running .NET client tests...'
|
||||
$testProject = Join-Path $RepoRoot 'clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/ZB.MOM.WW.MxGateway.Client.Tests.csproj'
|
||||
& dotnet test $testProject --no-restore
|
||||
if ($LASTEXITCODE -ne 0) { throw '.NET tests failed.' }
|
||||
}
|
||||
|
||||
Write-Host 'Packing ZB.MOM.WW.MxGateway.Contracts...'
|
||||
& dotnet pack (Join-Path $RepoRoot 'src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj') `
|
||||
-c Release -o $OutputDir
|
||||
if ($LASTEXITCODE -ne 0) { throw '.NET Contracts pack failed.' }
|
||||
|
||||
Write-Host 'Packing ZB.MOM.WW.MxGateway.Client...'
|
||||
& dotnet pack (Join-Path $RepoRoot 'clients/dotnet/ZB.MOM.WW.MxGateway.Client/ZB.MOM.WW.MxGateway.Client.csproj') `
|
||||
-c Release -o $OutputDir
|
||||
if ($LASTEXITCODE -ne 0) { throw '.NET Client pack failed.' }
|
||||
|
||||
Write-Host "Packed .NET artifacts -> $OutputDir" -ForegroundColor Green
|
||||
|
||||
if ($Publish) {
|
||||
Write-Host 'Publishing .NET packages to Gitea...' -ForegroundColor Yellow
|
||||
Get-ChildItem $OutputDir -Filter 'ZB.MOM.WW.MxGateway.*.nupkg' | ForEach-Object {
|
||||
& dotnet nuget push $_.FullName --source $GiteaNugetFeed --api-key $env:GITEA_TOKEN
|
||||
if ($LASTEXITCODE -ne 0) { throw "dotnet nuget push failed for '$($_.Name)'." }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# -------- Python --------
|
||||
|
||||
function Invoke-PackPython {
|
||||
Write-Header 'Python'
|
||||
|
||||
# Use a persistent venv in /tmp so repeated runs skip reinstall.
|
||||
$Venv = '/tmp/mxgw-py'
|
||||
if (-not (Test-Path "$Venv/bin/python")) {
|
||||
Write-Host "Creating Python venv at $Venv..."
|
||||
& python3 -m venv $Venv
|
||||
if ($LASTEXITCODE -ne 0) { throw 'python3 -m venv failed.' }
|
||||
& "$Venv/bin/pip" install --quiet --upgrade pip
|
||||
& "$Venv/bin/pip" install --quiet build twine
|
||||
& "$Venv/bin/pip" install --quiet -e (Join-Path $RepoRoot 'clients/python[dev]')
|
||||
}
|
||||
|
||||
if (-not $SkipTests) {
|
||||
Write-Host 'Running Python tests...'
|
||||
Push-Location (Join-Path $RepoRoot 'clients/python')
|
||||
try {
|
||||
& "$Venv/bin/python" -m pytest -q
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Python tests failed.' }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
|
||||
Write-Host 'Building Python sdist + wheel...'
|
||||
& "$Venv/bin/python" -m build (Join-Path $RepoRoot 'clients/python') --outdir $OutputDir
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Python build failed.' }
|
||||
|
||||
Write-Host "Packed Python artifacts -> $OutputDir" -ForegroundColor Green
|
||||
|
||||
if ($Publish) {
|
||||
Write-Host 'Publishing Python distribution to Gitea...' -ForegroundColor Yellow
|
||||
$wheels = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.whl')
|
||||
$sdists = @(Get-ChildItem $OutputDir -Filter 'zb_mom_ww_mxaccess_gateway_client-*.tar.gz')
|
||||
$files = ($wheels + $sdists) | ForEach-Object { $_.FullName }
|
||||
& "$Venv/bin/python" -m twine upload `
|
||||
--repository-url $GiteaPypiFeed `
|
||||
-u $env:GITEA_USERNAME `
|
||||
-p $env:GITEA_TOKEN `
|
||||
@files
|
||||
if ($LASTEXITCODE -ne 0) { throw 'twine upload failed.' }
|
||||
}
|
||||
}
|
||||
|
||||
# -------- Rust --------
|
||||
|
||||
function Invoke-PackRust {
|
||||
Write-Header 'Rust'
|
||||
|
||||
$rustDir = Join-Path $RepoRoot 'clients/rust'
|
||||
Push-Location $rustDir
|
||||
try {
|
||||
if (-not $SkipTests) {
|
||||
Write-Host 'Running Rust tests...'
|
||||
& cargo test --workspace
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Rust tests failed.' }
|
||||
}
|
||||
|
||||
Write-Host 'Running cargo package...'
|
||||
& cargo package --no-verify
|
||||
if ($LASTEXITCODE -ne 0) { throw 'cargo package failed.' }
|
||||
|
||||
$packageDir = Join-Path $rustDir 'target/package'
|
||||
$crates = @(Get-ChildItem $packageDir -Filter '*.crate')
|
||||
if ($crates.Count -eq 0) {
|
||||
throw 'cargo package produced no .crate files.'
|
||||
}
|
||||
foreach ($crate in $crates) {
|
||||
Copy-Item $crate.FullName -Destination $OutputDir -Force
|
||||
Write-Host " Copied $($crate.Name)"
|
||||
}
|
||||
} finally { Pop-Location }
|
||||
|
||||
Write-Host "Packed Rust artifacts -> $OutputDir" -ForegroundColor Green
|
||||
|
||||
if ($Publish) {
|
||||
Write-Host 'Publishing Rust crate to Gitea...' -ForegroundColor Yellow
|
||||
Push-Location (Join-Path $RepoRoot 'clients/rust')
|
||||
try {
|
||||
& cargo publish --no-verify --registry dohertj2-gitea
|
||||
if ($LASTEXITCODE -ne 0) { throw 'cargo publish failed.' }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
}
|
||||
|
||||
# -------- Java --------
|
||||
|
||||
function Invoke-PackJava {
|
||||
Write-Header 'Java'
|
||||
|
||||
$env:JAVA_HOME = $JavaHome
|
||||
$javaDir = Join-Path $RepoRoot 'clients/java'
|
||||
Push-Location $javaDir
|
||||
try {
|
||||
if (-not $SkipTests) {
|
||||
Write-Host 'Running Java tests...'
|
||||
& gradle ':zb-mom-ww-mxgateway-client:test' --no-daemon
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Java tests failed.' }
|
||||
}
|
||||
|
||||
Write-Host 'Assembling Java jars + pom...'
|
||||
& gradle `
|
||||
':zb-mom-ww-mxgateway-client:assemble' `
|
||||
':zb-mom-ww-mxgateway-client:sourcesJar' `
|
||||
':zb-mom-ww-mxgateway-client:javadocJar' `
|
||||
':zb-mom-ww-mxgateway-client:generatePomFileForMavenPublication' `
|
||||
--no-daemon
|
||||
if ($LASTEXITCODE -ne 0) { throw 'Java assemble failed.' }
|
||||
|
||||
$libsDir = Join-Path $javaDir 'zb-mom-ww-mxgateway-client/build/libs'
|
||||
$jars = @(Get-ChildItem $libsDir -Filter 'zb-mom-ww-mxgateway-client-*.jar')
|
||||
if ($jars.Count -eq 0) {
|
||||
throw "No jars found under '$libsDir'."
|
||||
}
|
||||
foreach ($jar in $jars) {
|
||||
Copy-Item $jar.FullName -Destination $OutputDir -Force
|
||||
Write-Host " Copied $($jar.Name)"
|
||||
}
|
||||
|
||||
$pomSrc = Join-Path $javaDir 'zb-mom-ww-mxgateway-client/build/publications/maven/pom-default.xml'
|
||||
if (Test-Path $pomSrc) {
|
||||
# Derive the version from the jar filename (e.g. zb-mom-ww-mxgateway-client-0.1.0.jar).
|
||||
$versionJar = $jars | Where-Object { $_.Name -notmatch '-(sources|javadoc)\.jar$' } | Select-Object -First 1
|
||||
$version = if ($versionJar) {
|
||||
[System.IO.Path]::GetFileNameWithoutExtension($versionJar.Name) -replace '^zb-mom-ww-mxgateway-client-', ''
|
||||
} else {
|
||||
'0.1.0'
|
||||
}
|
||||
$pomDest = Join-Path $OutputDir "zb-mom-ww-mxgateway-client-$version.pom"
|
||||
Copy-Item $pomSrc -Destination $pomDest -Force
|
||||
Write-Host " Copied pom -> $([System.IO.Path]::GetFileName($pomDest))"
|
||||
} else {
|
||||
Write-Warning "POM not found at '$pomSrc'; skipping."
|
||||
}
|
||||
} finally { Pop-Location }
|
||||
|
||||
Write-Host "Packed Java artifacts -> $OutputDir" -ForegroundColor Green
|
||||
|
||||
if ($Publish) {
|
||||
Write-Host 'Publishing Java artifacts to Gitea Maven feed...' -ForegroundColor Yellow
|
||||
Push-Location $javaDir
|
||||
try {
|
||||
& gradle ':zb-mom-ww-mxgateway-client:publish' --no-daemon
|
||||
if ($LASTEXITCODE -ne 0) { throw 'gradle publish failed.' }
|
||||
} finally { Pop-Location }
|
||||
}
|
||||
}
|
||||
|
||||
# -------- Go --------
|
||||
|
||||
function Invoke-PackGo {
|
||||
Write-Header 'Go'
|
||||
Write-Host 'Go modules are released by git-tagging — no artifact to pack.' -ForegroundColor Yellow
|
||||
Write-Host 'To publish a Go release, run:' -ForegroundColor Yellow
|
||||
Write-Host ' pwsh scripts/tag-go-module.ps1 -Version v0.1.0 -Push' -ForegroundColor Yellow
|
||||
Write-Host '(skipping)' -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
# -------- Dispatch --------
|
||||
|
||||
$wanted = @{}
|
||||
foreach ($lang in $Languages) { $wanted[$lang.ToLower()] = $true }
|
||||
|
||||
if ($wanted.ContainsKey('dotnet')) { Invoke-PackDotnet }
|
||||
if ($wanted.ContainsKey('python')) { Invoke-PackPython }
|
||||
if ($wanted.ContainsKey('rust')) { Invoke-PackRust }
|
||||
if ($wanted.ContainsKey('java')) { Invoke-PackJava }
|
||||
if ($wanted.ContainsKey('go')) { Invoke-PackGo }
|
||||
|
||||
# -------- Summary --------
|
||||
|
||||
Write-Header 'Summary'
|
||||
$artifacts = @(Get-ChildItem $OutputDir)
|
||||
if ($artifacts.Count -eq 0) {
|
||||
Write-Host ' (no artifacts)' -ForegroundColor DarkGray
|
||||
} else {
|
||||
foreach ($a in $artifacts) {
|
||||
Write-Host (' {0,10} {1}' -f $a.Length, $a.Name)
|
||||
}
|
||||
}
|
||||
Write-Host ''
|
||||
Write-Host "All artifacts in: $OutputDir" -ForegroundColor Green
|
||||
@@ -0,0 +1,62 @@
|
||||
#Requires -Version 7
|
||||
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Tags a release of the Go MxAccessGateway client module.
|
||||
|
||||
.DESCRIPTION
|
||||
Go modules in monorepo subdirectories use prefixed tags
|
||||
("clients/go/v0.1.0") so `go get <module>@v0.1.0` resolves correctly.
|
||||
This script validates the version, creates the prefixed tag at HEAD,
|
||||
and (optionally) pushes it.
|
||||
|
||||
.PARAMETER Version
|
||||
Semver tag without the prefix, e.g. "v0.1.0".
|
||||
|
||||
.PARAMETER Push
|
||||
When set, pushes the tag to origin after creation.
|
||||
|
||||
.EXAMPLE
|
||||
pwsh scripts/tag-go-module.ps1 -Version v0.1.0
|
||||
pwsh scripts/tag-go-module.ps1 -Version v0.1.1 -Push
|
||||
#>
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string]$Version,
|
||||
|
||||
[switch]$Push
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Version -notmatch '^v\d+\.\d+\.\d+(-[A-Za-z0-9.-]+)?$') {
|
||||
throw "Version '$Version' must match semver vX.Y.Z (optionally with -prerelease suffix)."
|
||||
}
|
||||
|
||||
$tag = "clients/go/$Version"
|
||||
Write-Host "Creating Go-module tag: $tag" -ForegroundColor Cyan
|
||||
|
||||
# Verify we're on a clean checkout — refuse to tag with uncommitted changes.
|
||||
$status = (git status --porcelain) -join "`n"
|
||||
if ($status -and -not ($status -match '^\?\?')) {
|
||||
throw "Working tree has tracked changes. Commit or stash before tagging."
|
||||
}
|
||||
|
||||
# Verify the tag doesn't already exist.
|
||||
$existing = git tag --list $tag
|
||||
if ($existing) {
|
||||
throw "Tag '$tag' already exists. Use a new version."
|
||||
}
|
||||
|
||||
git tag -a $tag -m "Go client release $Version"
|
||||
Write-Host "Created tag: $tag" -ForegroundColor Green
|
||||
|
||||
if ($Push) {
|
||||
git push origin $tag
|
||||
Write-Host "Pushed tag to origin." -ForegroundColor Green
|
||||
} else {
|
||||
Write-Host "Tag not pushed. To publish, run: git push origin $tag" -ForegroundColor Yellow
|
||||
}
|
||||
@@ -8,10 +8,13 @@ namespace ZB.MOM.WW.MxGateway.Contracts;
|
||||
/// </summary>
|
||||
public static class GatewayContractInfo
|
||||
{
|
||||
/// <summary>Protocol version advertised to clients in <c>OpenSessionReply</c>.</summary>
|
||||
public const uint GatewayProtocolVersion = 3;
|
||||
|
||||
/// <summary>Protocol version used to validate <c>WorkerEnvelope</c> framing on the gateway-worker pipe.</summary>
|
||||
public const uint WorkerProtocolVersion = 1;
|
||||
|
||||
/// <summary>Default backend name identifying the MXAccess worker process type.</summary>
|
||||
public const string DefaultBackendName = "mxaccess-worker";
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -4,6 +4,24 @@
|
||||
<TargetFrameworks>net10.0;net48</TargetFrameworks>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>true</IsPackable>
|
||||
<PackageId>ZB.MOM.WW.MxGateway.Contracts</PackageId>
|
||||
<Version>0.1.0</Version>
|
||||
<Authors>Joseph Doherty</Authors>
|
||||
<Company>ZB MOM WW</Company>
|
||||
<Copyright>Copyright (c) ZB MOM WW. All rights reserved.</Copyright>
|
||||
<Description>Protobuf contracts and gRPC stubs for the MxAccessGateway service. Multi-targets net10.0 and net48.</Description>
|
||||
<RepositoryUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</RepositoryUrl>
|
||||
<RepositoryType>git</RepositoryType>
|
||||
<PackageProjectUrl>https://gitea.dohertylan.com/dohertj2/mxaccessgw</PackageProjectUrl>
|
||||
<PackageTags>mxaccess;mxgateway;grpc;contracts;protobuf</PackageTags>
|
||||
<PackageRequireLicenseAcceptance>false</PackageRequireLicenseAcceptance>
|
||||
<IncludeSymbols>true</IncludeSymbols>
|
||||
<SymbolPackageFormat>snupkg</SymbolPackageFormat>
|
||||
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Remove="Generated\**\*.cs" />
|
||||
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
@implements IAsyncDisposable
|
||||
@inject IGalaxyHierarchyCache GalaxyCache
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@inject IDashboardBrowseService BrowseService
|
||||
@inject IGalaxyDeployNotifier DeployNotifier
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
||||
@using ZB.MOM.WW.MxGateway.Server.Galaxy
|
||||
|
||||
@@ -71,12 +73,18 @@
|
||||
}
|
||||
else
|
||||
{
|
||||
@if (!string.IsNullOrEmpty(_staleBanner))
|
||||
{
|
||||
<div class="alert alert-info browse-stale-banner" role="status"
|
||||
@onclick="ClearStaleBanner">@_staleBanner</div>
|
||||
}
|
||||
<div class="browse-tree">
|
||||
@foreach (DashboardBrowseNode root in _roots)
|
||||
{
|
||||
<BrowseTreeNodeView Node="root"
|
||||
OnAddTag="AddTagAsync"
|
||||
OnTagContextMenu="OnTagContextMenu" />
|
||||
OnTagContextMenu="OnTagContextMenu"
|
||||
OnLoadChildren="LoadChildrenAsync" />
|
||||
}
|
||||
</div>
|
||||
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
|
||||
@@ -186,7 +194,11 @@
|
||||
@code {
|
||||
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 IReadOnlyList<GalaxyAttribute> _searchMatches = [];
|
||||
private readonly List<string> _subscribed = [];
|
||||
@@ -210,8 +222,63 @@
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects);
|
||||
BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
|
||||
_roots = [.. roots.Nodes];
|
||||
_cacheSequence = roots.CacheSequence;
|
||||
_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()
|
||||
@@ -405,6 +472,7 @@
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
await _deployCts.CancelAsync();
|
||||
if (_pollTask is not null)
|
||||
{
|
||||
try
|
||||
@@ -415,8 +483,19 @@
|
||||
{
|
||||
}
|
||||
}
|
||||
if (_deployTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _deployTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
_deployCts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
|
||||
+72
-9
@@ -2,15 +2,21 @@
|
||||
|
||||
@*
|
||||
Recursive Browse hierarchy node. Renders one Galaxy object, its child
|
||||
objects (recursively), and its attributes as right-clickable tag rows.
|
||||
Expansion state is local; children render only while expanded.
|
||||
objects (recursively, lazy-loaded on first expand), and its attributes as
|
||||
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-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 ? "▾" : "▸")
|
||||
</button>
|
||||
}
|
||||
@@ -18,7 +24,7 @@
|
||||
{
|
||||
<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-name">@Node.DisplayName</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
|
||||
@@ -31,9 +37,27 @@
|
||||
@if (_expanded)
|
||||
{
|
||||
<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)
|
||||
{
|
||||
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" />
|
||||
<BrowseTreeNodeView Node="child"
|
||||
OnAddTag="OnAddTag"
|
||||
OnTagContextMenu="OnTagContextMenu"
|
||||
OnLoadChildren="OnLoadChildren" />
|
||||
}
|
||||
@foreach (GalaxyAttribute attr in Node.Attributes)
|
||||
{
|
||||
@@ -75,13 +99,52 @@
|
||||
[Parameter]
|
||||
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 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>
|
||||
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>
|
||||
|
||||
@@ -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<HubTokenService>();
|
||||
services.AddScoped<Hubs.DashboardHubConnectionFactory>();
|
||||
services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();
|
||||
services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>();
|
||||
services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
|
||||
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,64 @@
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Bridges the gateway's <see cref="GatewayLogRedactor"/> policy onto the shared
|
||||
/// <see cref="ILogRedactor"/> seam consumed by <c>ZB.MOM.WW.Telemetry.Serilog</c>'s redaction
|
||||
/// enricher. Applied to every Serilog log event before it reaches a sink, it masks the same
|
||||
/// secrets the original MEL-scope path masked: API-key bearer tokens / client identities
|
||||
/// (<c>mxgw_*</c>) and command values for credential-bearing MXAccess commands. All masking
|
||||
/// decisions delegate to <see cref="GatewayLogRedactor"/> — this type adds no new policy.
|
||||
/// </summary>
|
||||
public sealed class GatewayLogRedactorAdapter : ILogRedactor
|
||||
{
|
||||
/// <summary>Property name carrying a client identity / authorization header value.</summary>
|
||||
private const string ClientIdentityProperty = "ClientIdentity";
|
||||
|
||||
/// <summary>Property name carrying a raw authorization header value.</summary>
|
||||
private const string AuthorizationProperty = "Authorization";
|
||||
|
||||
/// <summary>Property name carrying the MXAccess command method, used to gate value redaction.</summary>
|
||||
private const string CommandMethodProperty = "CommandMethod";
|
||||
|
||||
/// <summary>Property name carrying a command payload value that may bear credentials.</summary>
|
||||
private const string CommandValueProperty = "CommandValue";
|
||||
|
||||
/// <summary>
|
||||
/// Masks any sensitive values in <paramref name="properties"/> in place using the shared
|
||||
/// <see cref="GatewayLogRedactor"/> policy. Identity/authorization properties have their API-key
|
||||
/// secret stripped; a command value is redacted when its associated command method bears
|
||||
/// credentials.
|
||||
/// </summary>
|
||||
/// <param name="properties">The mutable log-event property dictionary for the current event.</param>
|
||||
public void Redact(IDictionary<string, object?> properties)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(properties);
|
||||
|
||||
RedactIdentity(properties, ClientIdentityProperty);
|
||||
RedactIdentity(properties, AuthorizationProperty);
|
||||
RedactCommandValue(properties);
|
||||
}
|
||||
|
||||
private static void RedactIdentity(IDictionary<string, object?> properties, string propertyName)
|
||||
{
|
||||
if (properties.TryGetValue(propertyName, out object? value) && value is string identity)
|
||||
{
|
||||
properties[propertyName] = GatewayLogRedactor.RedactClientIdentity(identity);
|
||||
}
|
||||
}
|
||||
|
||||
private static void RedactCommandValue(IDictionary<string, object?> properties)
|
||||
{
|
||||
if (!properties.TryGetValue(CommandValueProperty, out object? value) || value is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string? commandMethod = properties.TryGetValue(CommandMethodProperty, out object? method)
|
||||
? method as string
|
||||
: null;
|
||||
|
||||
properties[CommandValueProperty] = GatewayLogRedactor.RedactCommandValue(commandMethod, value);
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
public static class GatewayLoggerExtensions
|
||||
{
|
||||
/// <summary>Begins a gateway log scope with the specified scope properties.</summary>
|
||||
/// <param name="logger">Logger used for diagnostic output.</param>
|
||||
/// <param name="scope">Scope properties to apply.</param>
|
||||
/// <returns>A disposable that ends the scope when disposed.</returns>
|
||||
public static IDisposable? BeginGatewayScope(
|
||||
this ILogger logger,
|
||||
GatewayLogScope scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
return logger.BeginScope(scope.ToDictionary());
|
||||
}
|
||||
}
|
||||
+48
-7
@@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
using Serilog.Context;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
@@ -17,7 +18,12 @@ public static class GatewayRequestLoggingMiddlewareExtensions
|
||||
/// <summary>Header name for the command method name.</summary>
|
||||
public const string CommandMethodHeaderName = "x-command-method";
|
||||
|
||||
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
|
||||
/// <summary>
|
||||
/// Adds gateway request logging middleware that reads the correlation headers and pushes them
|
||||
/// as Serilog <see cref="LogContext"/> properties for the duration of the request. The pushed
|
||||
/// properties (SessionId / WorkerProcessId / CorrelationId / CommandMethod / ClientIdentity)
|
||||
/// are disposed when the request completes; the shared redaction enricher masks any secrets.
|
||||
/// </summary>
|
||||
/// <param name="app">Application builder.</param>
|
||||
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
||||
{
|
||||
@@ -25,21 +31,56 @@ public static class GatewayRequestLoggingMiddlewareExtensions
|
||||
|
||||
return app.Use(async (context, next) =>
|
||||
{
|
||||
ILogger logger = context.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("MxGateway.Request");
|
||||
|
||||
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
|
||||
GatewayLogScope scope = new(
|
||||
SessionId: ReadHeader(context, SessionIdHeaderName),
|
||||
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),
|
||||
CorrelationId: ReadUInt64Header(context, CorrelationIdHeaderName),
|
||||
CommandMethod: ReadHeader(context, CommandMethodHeaderName),
|
||||
ClientIdentity: ReadHeader(context, "authorization")));
|
||||
ClientIdentity: ReadHeader(context, "authorization"));
|
||||
|
||||
using IDisposable correlationScope = PushCorrelationProperties(scope);
|
||||
|
||||
await next(context);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the populated <paramref name="scope"/> properties onto the Serilog
|
||||
/// <see cref="LogContext"/>, returning a single disposable that pops them all when the request
|
||||
/// completes. Only the properties present in <see cref="GatewayLogScope.ToDictionary"/> (which
|
||||
/// already applies the client-identity redaction policy) are pushed.
|
||||
/// </summary>
|
||||
/// <param name="scope">The correlation properties for the current request.</param>
|
||||
/// <returns>A disposable that removes the pushed properties on disposal.</returns>
|
||||
private static IDisposable PushCorrelationProperties(GatewayLogScope scope)
|
||||
{
|
||||
Stack<IDisposable> pushed = new();
|
||||
|
||||
foreach (KeyValuePair<string, object?> property in scope.ToDictionary())
|
||||
{
|
||||
pushed.Push(LogContext.PushProperty(property.Key, property.Value));
|
||||
}
|
||||
|
||||
return new CorrelationPropertyScope(pushed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Disposes the pushed <see cref="LogContext"/> property bindings in reverse order, restoring
|
||||
/// the ambient context to its pre-request state.
|
||||
/// </summary>
|
||||
private sealed class CorrelationPropertyScope(Stack<IDisposable> bindings) : IDisposable
|
||||
{
|
||||
private readonly Stack<IDisposable> _bindings = bindings;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
while (_bindings.Count > 0)
|
||||
{
|
||||
_bindings.Pop().Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ReadHeader(HttpContext context, string headerName)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue(headerName, out StringValues values)
|
||||
|
||||
@@ -62,7 +62,16 @@ public static class GalaxyBrowseProjector
|
||||
return new GalaxyBrowseChildrenResult(page, hasChildren, filtered.Children.Count, filterSignature);
|
||||
}
|
||||
|
||||
private static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||
/// <summary>
|
||||
/// Resolves the request's parent oneof to a gobject id, throwing
|
||||
/// <see cref="RpcException"/> with <see cref="StatusCode.NotFound"/> when the
|
||||
/// parent does not exist. Public so the gRPC handler can compute the same
|
||||
/// parent id (needed for the page-token signature) without reimplementing the
|
||||
/// resolution rules.
|
||||
/// </summary>
|
||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||
/// <param name="request">The browse-children request.</param>
|
||||
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||
{
|
||||
switch (request.ParentCase)
|
||||
{
|
||||
@@ -80,9 +89,7 @@ public static class GalaxyBrowseProjector
|
||||
return request.ParentGobjectId;
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentTagName:
|
||||
{
|
||||
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
||||
view => string.Equals(view.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
if (!entry.Index.ObjectViewsByTagName.TryGetValue(request.ParentTagName, out GalaxyObjectView? match))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
@@ -90,9 +97,7 @@ public static class GalaxyBrowseProjector
|
||||
}
|
||||
case BrowseChildrenRequest.ParentOneofCase.ParentContainedPath:
|
||||
{
|
||||
GalaxyObjectView? match = entry.Index.ObjectViews.FirstOrDefault(
|
||||
view => string.Equals(view.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase));
|
||||
if (match is null)
|
||||
if (!entry.Index.ObjectViewsByContainedPath.TryGetValue(request.ParentContainedPath, out GalaxyObjectView? match))
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found."));
|
||||
}
|
||||
@@ -163,10 +168,17 @@ public static class GalaxyBrowseProjector
|
||||
return false;
|
||||
}
|
||||
|
||||
// Defend against pathological cycles in Galaxy data (e.g. a corrupt A→B→A chain).
|
||||
// BuildContainedPath uses the same visited-id pattern; mirror it so this walk
|
||||
// terminates even when ChildrenByParent forms a cycle.
|
||||
HashSet<int> visited = new() { parent.Object.GobjectId };
|
||||
Stack<GalaxyObjectView> stack = new();
|
||||
foreach (GalaxyObjectView child in children)
|
||||
{
|
||||
stack.Push(child);
|
||||
if (visited.Add(child.Object.GobjectId))
|
||||
{
|
||||
stack.Push(child);
|
||||
}
|
||||
}
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
@@ -180,7 +192,10 @@ public static class GalaxyBrowseProjector
|
||||
{
|
||||
foreach (GalaxyObjectView grandchild in grandchildren)
|
||||
{
|
||||
stack.Push(grandchild);
|
||||
if (visited.Add(grandchild.Object.GobjectId))
|
||||
{
|
||||
stack.Push(grandchild);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,12 +8,16 @@ public sealed class GalaxyHierarchyIndex
|
||||
IReadOnlyList<GalaxyObjectView> objectViews,
|
||||
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
|
||||
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress,
|
||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent)
|
||||
IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> childrenByParent,
|
||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByTagName,
|
||||
IReadOnlyDictionary<string, GalaxyObjectView> objectViewsByContainedPath)
|
||||
{
|
||||
ObjectViews = objectViews;
|
||||
ObjectViewsById = objectViewsById;
|
||||
TagsByAddress = tagsByAddress;
|
||||
ChildrenByParent = childrenByParent;
|
||||
ObjectViewsByTagName = objectViewsByTagName;
|
||||
ObjectViewsByContainedPath = objectViewsByContainedPath;
|
||||
}
|
||||
|
||||
/// <summary>Gets an empty Galaxy hierarchy index.</summary>
|
||||
@@ -21,7 +25,9 @@ public sealed class GalaxyHierarchyIndex
|
||||
Array.Empty<GalaxyObjectView>(),
|
||||
new Dictionary<int, GalaxyObjectView>(),
|
||||
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>());
|
||||
new Dictionary<int, IReadOnlyList<GalaxyObjectView>>(),
|
||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase),
|
||||
new Dictionary<string, GalaxyObjectView>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Gets the object views.</summary>
|
||||
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
|
||||
@@ -35,6 +41,12 @@ public sealed class GalaxyHierarchyIndex
|
||||
/// <summary>Gets direct children grouped by parent gobject id. Root objects (no parent, or self-parented) live under key 0. Each list is sorted areas-first, then by display name (OrdinalIgnoreCase).</summary>
|
||||
public IReadOnlyDictionary<int, IReadOnlyList<GalaxyObjectView>> ChildrenByParent { get; }
|
||||
|
||||
/// <summary>Gets object views indexed by <see cref="GalaxyObject.TagName"/> (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by tag name in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByTagName { get; }
|
||||
|
||||
/// <summary>Gets object views indexed by contained path (OrdinalIgnoreCase). Lets browse/discover handlers resolve parents/roots by path in O(1) instead of scanning <see cref="ObjectViews"/>.</summary>
|
||||
public IReadOnlyDictionary<string, GalaxyObjectView> ObjectViewsByContainedPath { get; }
|
||||
|
||||
/// <summary>Builds a Galaxy hierarchy index from the given objects.</summary>
|
||||
/// <param name="objects">The Galaxy objects to index.</param>
|
||||
/// <returns>A new Galaxy hierarchy index.</returns>
|
||||
@@ -54,6 +66,8 @@ public sealed class GalaxyHierarchyIndex
|
||||
List<GalaxyObjectView> views = new(objects.Count);
|
||||
Dictionary<int, GalaxyObjectView> viewsById = new();
|
||||
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, GalaxyObjectView> viewsByTagName = new(StringComparer.OrdinalIgnoreCase);
|
||||
Dictionary<string, GalaxyObjectView> viewsByContainedPath = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyObject obj in objects)
|
||||
{
|
||||
@@ -66,6 +80,12 @@ public sealed class GalaxyHierarchyIndex
|
||||
if (!string.IsNullOrWhiteSpace(obj.TagName))
|
||||
{
|
||||
tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path));
|
||||
viewsByTagName.TryAdd(obj.TagName, view);
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
viewsByContainedPath.TryAdd(path, view);
|
||||
}
|
||||
|
||||
foreach (GalaxyAttribute attribute in obj.Attributes)
|
||||
@@ -109,7 +129,9 @@ public sealed class GalaxyHierarchyIndex
|
||||
views,
|
||||
viewsById,
|
||||
tagsByAddress,
|
||||
readOnlyChildren);
|
||||
readOnlyChildren,
|
||||
viewsByTagName,
|
||||
viewsByContainedPath);
|
||||
}
|
||||
|
||||
private static string BuildContainedPath(
|
||||
|
||||
@@ -103,7 +103,7 @@ public static class GalaxyHierarchyProjector
|
||||
// ResolveRoot can throw RpcException(NotFound); run it before consulting the
|
||||
// memo so a bad root surfaces consistently regardless of cache state.
|
||||
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||
GalaxyObjectView? root = ResolveRoot(request, views);
|
||||
GalaxyObjectView? root = ResolveRoot(request, entry.Index);
|
||||
|
||||
ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>> memo =
|
||||
FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>(StringComparer.Ordinal));
|
||||
@@ -176,17 +176,17 @@ public static class GalaxyHierarchyProjector
|
||||
|
||||
private static GalaxyObjectView? ResolveRoot(
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<GalaxyObjectView> views)
|
||||
GalaxyHierarchyIndex index)
|
||||
{
|
||||
GalaxyObjectView? root = request.RootCase switch
|
||||
{
|
||||
DiscoverHierarchyRequest.RootOneofCase.None => null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault(
|
||||
view => view.Object.GobjectId == request.RootGobjectId),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName => views.FirstOrDefault(
|
||||
view => string.Equals(view.Object.TagName, request.RootTagName, StringComparison.OrdinalIgnoreCase)),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => views.FirstOrDefault(
|
||||
view => string.Equals(view.ContainedPath, request.RootContainedPath, StringComparison.OrdinalIgnoreCase)),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId =>
|
||||
index.ObjectViewsById.TryGetValue(request.RootGobjectId, out GalaxyObjectView? byId) ? byId : null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName =>
|
||||
index.ObjectViewsByTagName.TryGetValue(request.RootTagName, out GalaxyObjectView? byTag) ? byTag : null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath =>
|
||||
index.ObjectViewsByContainedPath.TryGetValue(request.RootContainedPath, out GalaxyObjectView? byPath) ? byPath : null,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Microsoft.AspNetCore.Hosting.StaticWebAssets;
|
||||
using Serilog;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
@@ -11,6 +12,7 @@ using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server;
|
||||
|
||||
@@ -31,7 +33,10 @@ public static class GatewayApplication
|
||||
WebApplicationBuilder builder = CreateBuilder(args);
|
||||
WebApplication app = builder.Build();
|
||||
|
||||
// Push the per-request correlation properties (via Serilog LogContext) before the
|
||||
// request-logging middleware emits its completion event, so those properties appear on it.
|
||||
app.UseGatewayRequestLoggingScope();
|
||||
app.UseSerilogRequestLogging();
|
||||
app.UseStaticFiles();
|
||||
app.UseAuthentication();
|
||||
app.UseAuthorization();
|
||||
@@ -55,6 +60,8 @@ public static class GatewayApplication
|
||||
});
|
||||
StaticWebAssetsLoader.UseStaticWebAssets(builder.Environment, builder.Configuration);
|
||||
|
||||
ConfigureSerilog(builder);
|
||||
|
||||
builder.Services.AddGatewayConfiguration();
|
||||
builder.Services.AddSqliteAuthStore();
|
||||
builder.Services.AddGatewayGrpcAuthorization();
|
||||
@@ -72,6 +79,30 @@ public static class GatewayApplication
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replaces the default Microsoft.Extensions.Logging provider with the shared
|
||||
/// <c>ZB.MOM.WW.Telemetry.Serilog</c> bootstrap (<see cref="ZbSerilogExtensions.AddZbSerilog"/>).
|
||||
/// Sinks and minimum level come from the <c>Serilog</c> configuration section; identity
|
||||
/// (<c>SiteId</c>/<c>NodeRole</c>) is read from <c>MxGateway:Telemetry</c> when present.
|
||||
/// Also registers the project's <see cref="ILogRedactor"/> adapter so the shared redaction
|
||||
/// enricher masks gateway secrets on every event.
|
||||
/// </summary>
|
||||
/// <param name="builder">The web application builder being configured.</param>
|
||||
private static void ConfigureSerilog(WebApplicationBuilder builder)
|
||||
{
|
||||
string? siteId = builder.Configuration["MxGateway:Telemetry:SiteId"];
|
||||
string? nodeRole = builder.Configuration["MxGateway:Telemetry:NodeRole"];
|
||||
|
||||
builder.Services.AddSingleton<ILogRedactor, GatewayLogRedactorAdapter>();
|
||||
|
||||
builder.AddZbSerilog(options =>
|
||||
{
|
||||
options.ServiceName = "mxgateway";
|
||||
options.SiteId = string.IsNullOrWhiteSpace(siteId) ? null : siteId;
|
||||
options.NodeRole = string.IsNullOrWhiteSpace(nodeRole) ? null : nodeRole;
|
||||
});
|
||||
}
|
||||
|
||||
private static string ResolveContentRootPath()
|
||||
{
|
||||
string? configuredContentRootPath = Environment.GetEnvironmentVariable("ASPNETCORE_CONTENTROOT");
|
||||
|
||||
@@ -128,8 +128,11 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
IReadOnlyList<string> browseSubtrees = ResolveBrowseSubtrees();
|
||||
|
||||
// Resolve the parent id once so the page-token signature can include it
|
||||
// and the projector sees the same resolved id when memoizing.
|
||||
int parentId = ResolveParentIdForToken(entry, request);
|
||||
// and the projector sees the same resolved id when memoizing. The projector
|
||||
// re-resolves internally; with the by-name/by-path indexes on
|
||||
// GalaxyHierarchyIndex that second call is O(1), so the redundancy is cheap
|
||||
// and keeps the projector self-contained.
|
||||
int parentId = GalaxyDb.GalaxyBrowseProjector.ResolveParentId(entry, request);
|
||||
string filterSignature = GalaxyDb.GalaxyBrowseProjector.ComputeFilterSignature(
|
||||
request, browseSubtrees, parentId);
|
||||
PageToken pageToken = ParsePageToken(request.PageToken, entry.Sequence, filterSignature);
|
||||
@@ -283,32 +286,6 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
return Math.Min(pageSize, MaxDiscoverPageSize);
|
||||
}
|
||||
|
||||
// Lightweight parent resolver used only for signature computation. Re-throws
|
||||
// NotFound consistently with the projector so the error surface matches.
|
||||
private static int ResolveParentIdForToken(
|
||||
GalaxyDb.GalaxyHierarchyCacheEntry entry,
|
||||
BrowseChildrenRequest request)
|
||||
{
|
||||
return request.ParentCase switch
|
||||
{
|
||||
BrowseChildrenRequest.ParentOneofCase.None => 0,
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentGobjectId =>
|
||||
request.ParentGobjectId == 0 ? 0
|
||||
: entry.Index.ObjectViewsById.ContainsKey(request.ParentGobjectId)
|
||||
? request.ParentGobjectId
|
||||
: throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentTagName =>
|
||||
entry.Index.ObjectViews.FirstOrDefault(
|
||||
v => string.Equals(v.Object.TagName, request.ParentTagName, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
||||
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
BrowseChildrenRequest.ParentOneofCase.ParentContainedPath =>
|
||||
entry.Index.ObjectViews.FirstOrDefault(
|
||||
v => string.Equals(v.ContainedPath, request.ParentContainedPath, StringComparison.OrdinalIgnoreCase))?.Object.GobjectId
|
||||
?? throw new RpcException(new Status(StatusCode.NotFound, "BrowseChildren parent was not found.")),
|
||||
_ => 0,
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> ResolveBrowseSubtrees()
|
||||
{
|
||||
ApiKeyConstraints constraints = identityAccessor.Current?.EffectiveConstraints ?? ApiKeyConstraints.Empty;
|
||||
@@ -348,21 +325,21 @@ public sealed class GalaxyRepositoryGrpcService(
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is invalid."));
|
||||
"page_token is invalid."));
|
||||
}
|
||||
|
||||
if (sequence != currentSequence)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token is stale."));
|
||||
"page_token is stale."));
|
||||
}
|
||||
|
||||
if (!string.Equals(parts[1], currentFilterSignature, StringComparison.Ordinal))
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy page_token does not match the current filters."));
|
||||
"page_token does not match the current filters."));
|
||||
}
|
||||
|
||||
return new PageToken(sequence, parts[1], offset);
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ZB.MOM.WW.MxGateway.Contracts\ZB.MOM.WW.MxGateway.Contracts.csproj" />
|
||||
<!--
|
||||
Shared structured-logging bootstrap (ZB.MOM.WW.Telemetry.Serilog) lives in the sibling
|
||||
scadaproj workspace. Cross-repo ProjectReference: the referenced project resolves its own
|
||||
Directory.Build.props / Directory.Packages.props from its own tree, so it does not perturb
|
||||
this repo's build settings. It transitively brings the ZB.MOM.WW.Telemetry core package.
|
||||
-->
|
||||
<ProjectReference Include="..\..\..\scadaproj\ZB.MOM.WW.Telemetry\src\ZB.MOM.WW.Telemetry.Serilog\ZB.MOM.WW.Telemetry.Serilog.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Serilog": {
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,30 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Serilog": {
|
||||
"Using": [
|
||||
"Serilog.Sinks.Console",
|
||||
"Serilog.Sinks.File"
|
||||
],
|
||||
"MinimumLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
"Override": {
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"WriteTo": [
|
||||
{
|
||||
"Name": "Console",
|
||||
"Args": {
|
||||
"outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] [{NodeRole}/{NodeHostname}] {Message:lj} {Properties:j}{NewLine}{Exception}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"Name": "File",
|
||||
"Args": {
|
||||
"path": "logs/mxgateway-.log",
|
||||
"rollingInterval": "Day"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"AllowedHosts": "*",
|
||||
"MxGateway": {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
using ZB.MOM.WW.Telemetry.Serilog;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Pins that <see cref="GatewayLogRedactorAdapter"/> applies the gateway's redaction policy through
|
||||
/// the shared <see cref="ILogRedactor"/> seam — the same secrets the former MEL-scope path masked
|
||||
/// must still be masked once events flow through the Serilog redaction enricher.
|
||||
/// </summary>
|
||||
public sealed class GatewayLogRedactorAdapterTests
|
||||
{
|
||||
private readonly ILogRedactor _redactor = new GatewayLogRedactorAdapter();
|
||||
|
||||
/// <summary>Verifies the client identity property has its API-key secret stripped in place.</summary>
|
||||
[Fact]
|
||||
public void Redact_StripsApiKeySecretFromClientIdentity()
|
||||
{
|
||||
Dictionary<string, object?> properties = new()
|
||||
{
|
||||
["ClientIdentity"] = "Bearer mxgw_operator01_super-secret",
|
||||
};
|
||||
|
||||
_redactor.Redact(properties);
|
||||
|
||||
Assert.Equal("Bearer mxgw_operator01_[redacted]", properties["ClientIdentity"]);
|
||||
Assert.DoesNotContain("super-secret", (string?)properties["ClientIdentity"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a raw authorization header property is redacted too.</summary>
|
||||
[Fact]
|
||||
public void Redact_StripsApiKeySecretFromAuthorizationProperty()
|
||||
{
|
||||
Dictionary<string, object?> properties = new()
|
||||
{
|
||||
["Authorization"] = "Bearer mxgw_admin_top-secret",
|
||||
};
|
||||
|
||||
_redactor.Redact(properties);
|
||||
|
||||
Assert.Equal("Bearer mxgw_admin_[redacted]", properties["Authorization"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a command value is redacted for a credential-bearing command method.</summary>
|
||||
[Fact]
|
||||
public void Redact_RedactsCommandValueForCredentialBearingCommand()
|
||||
{
|
||||
Dictionary<string, object?> properties = new()
|
||||
{
|
||||
["CommandMethod"] = "WriteSecured",
|
||||
["CommandValue"] = "credential-bearing-value",
|
||||
};
|
||||
|
||||
_redactor.Redact(properties);
|
||||
|
||||
Assert.Equal(GatewayLogRedactor.RedactedValue, properties["CommandValue"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies a command value is redacted by default (value logging disabled) for any command.</summary>
|
||||
[Fact]
|
||||
public void Redact_RedactsCommandValueByDefault()
|
||||
{
|
||||
Dictionary<string, object?> properties = new()
|
||||
{
|
||||
["CommandMethod"] = "Write",
|
||||
["CommandValue"] = "plaintext-tag-value",
|
||||
};
|
||||
|
||||
_redactor.Redact(properties);
|
||||
|
||||
Assert.Equal(GatewayLogRedactor.RedactedValue, properties["CommandValue"]);
|
||||
}
|
||||
|
||||
/// <summary>Verifies non-sensitive properties are left untouched.</summary>
|
||||
[Fact]
|
||||
public void Redact_LeavesNonSensitivePropertiesUnchanged()
|
||||
{
|
||||
Dictionary<string, object?> properties = new()
|
||||
{
|
||||
["SessionId"] = "session-1",
|
||||
["CorrelationId"] = (ulong)99,
|
||||
["ClientIdentity"] = "Bearer plain-token-no-marker",
|
||||
};
|
||||
|
||||
_redactor.Redact(properties);
|
||||
|
||||
Assert.Equal("session-1", properties["SessionId"]);
|
||||
Assert.Equal((ulong)99, properties["CorrelationId"]);
|
||||
// No mxgw_ marker — identity passes through unchanged.
|
||||
Assert.Equal("Bearer plain-token-no-marker", properties["ClientIdentity"]);
|
||||
}
|
||||
}
|
||||
@@ -223,6 +223,77 @@ public sealed class GalaxyBrowseProjectorTests
|
||||
Assert.Equal("Plant.Line_A", result.Children[0].TagName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Verifies <see cref="GalaxyBrowseProjector"/> terminates when the Galaxy data
|
||||
/// contains a cyclic parent chain (A→B→C→A). Without the visited-id guard in
|
||||
/// <c>HasMatchingDescendant</c>, the depth-first walk would loop forever; the
|
||||
/// 5-second xUnit timeout asserts termination.
|
||||
/// </summary>
|
||||
[Fact(Timeout = 5000)]
|
||||
public async Task Project_CyclicDescendants_DoesNotInfiniteLoop()
|
||||
{
|
||||
await Task.Yield();
|
||||
// Construct a 3-node cycle: A(10)→B(11)→C(12)→A. Each node's ParentGobjectId
|
||||
// points to the next, so GalaxyHierarchyIndex.ChildrenByParent has
|
||||
// [12] = [A], [10] = [B], [11] = [C].
|
||||
// None of them are historized, so HistorizedOnly=true forces the projector to
|
||||
// call HasMatchingDescendant on every direct child, exercising the cycle walk.
|
||||
GalaxyObject a = new()
|
||||
{
|
||||
GobjectId = 10,
|
||||
ParentGobjectId = 12,
|
||||
ContainedName = "A",
|
||||
BrowseName = "A",
|
||||
TagName = "A",
|
||||
};
|
||||
GalaxyObject b = new()
|
||||
{
|
||||
GobjectId = 11,
|
||||
ParentGobjectId = 10,
|
||||
ContainedName = "B",
|
||||
BrowseName = "B",
|
||||
TagName = "B",
|
||||
};
|
||||
GalaxyObject c = new()
|
||||
{
|
||||
GobjectId = 12,
|
||||
ParentGobjectId = 11,
|
||||
ContainedName = "C",
|
||||
BrowseName = "C",
|
||||
TagName = "C",
|
||||
};
|
||||
|
||||
IReadOnlyList<GalaxyObject> objects = new[] { a, b, c };
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
Sequence = 1,
|
||||
LastSuccessAt = DateTimeOffset.UtcNow,
|
||||
Objects = objects,
|
||||
Index = GalaxyHierarchyIndex.Build(objects),
|
||||
DashboardSummary = DashboardGalaxySummary.Unknown with
|
||||
{
|
||||
Status = DashboardGalaxyStatus.Healthy,
|
||||
ObjectCount = objects.Count,
|
||||
},
|
||||
ObjectCount = objects.Count,
|
||||
};
|
||||
|
||||
// Browse children of A (id=10). Its direct child B fails HistorizedOnly, so the
|
||||
// projector falls back to HasMatchingDescendant(B), which walks B→C→A→B…
|
||||
// without the visited-id guard. With the guard, the walk terminates and returns
|
||||
// an empty page (no historized descendants exist anywhere in the cycle).
|
||||
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
|
||||
entry,
|
||||
new BrowseChildrenRequest { ParentGobjectId = 10, HistorizedOnly = true },
|
||||
browseSubtreeGlobs: null,
|
||||
offset: 0,
|
||||
pageSize: 10);
|
||||
|
||||
Assert.Empty(result.Children);
|
||||
Assert.Equal(0, result.TotalChildCount);
|
||||
}
|
||||
|
||||
private static GalaxyHierarchyCacheEntry CreateEntry()
|
||||
{
|
||||
IReadOnlyList<GalaxyObject> objects = CreateObjects();
|
||||
|
||||
@@ -75,6 +75,47 @@ public sealed class GalaxyHierarchyIndexTests
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Verifies <see cref="GalaxyHierarchyIndex.ObjectViewsByTagName"/> is OrdinalIgnoreCase and supports O(1) lookups.</summary>
|
||||
[Fact]
|
||||
public void ObjectViewsByTagName_IsCaseInsensitive_AndLookupsAreO1()
|
||||
{
|
||||
GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" };
|
||||
GalaxyObject mixer = new() { GobjectId = 2, ParentGobjectId = 1, ContainedName = "Mixer_001", BrowseName = "Mixer_001", TagName = "Plant.Mixer_001" };
|
||||
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, mixer]);
|
||||
|
||||
Assert.True(index.ObjectViewsByTagName.TryGetValue("Plant.Mixer_001", out GalaxyObjectView? exact));
|
||||
Assert.NotNull(exact);
|
||||
Assert.Equal(2, exact!.Object.GobjectId);
|
||||
|
||||
// Case-insensitive lookup must hit the same entry.
|
||||
Assert.True(index.ObjectViewsByTagName.TryGetValue("plant.mixer_001", out GalaxyObjectView? lower));
|
||||
Assert.NotNull(lower);
|
||||
Assert.Same(exact, lower);
|
||||
|
||||
Assert.False(index.ObjectViewsByTagName.ContainsKey("Plant.Missing"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies <see cref="GalaxyHierarchyIndex.ObjectViewsByContainedPath"/> is OrdinalIgnoreCase.</summary>
|
||||
[Fact]
|
||||
public void ObjectViewsByContainedPath_IsCaseInsensitive()
|
||||
{
|
||||
GalaxyObject root = new() { GobjectId = 1, ParentGobjectId = 0, IsArea = true, ContainedName = "Plant", BrowseName = "Plant", TagName = "Plant" };
|
||||
GalaxyObject lineA = new() { GobjectId = 2, ParentGobjectId = 1, IsArea = true, ContainedName = "Line_A", BrowseName = "Line_A", TagName = "Plant.Line_A" };
|
||||
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build([root, lineA]);
|
||||
|
||||
Assert.True(index.ObjectViewsByContainedPath.TryGetValue("Plant/Line_A", out GalaxyObjectView? exact));
|
||||
Assert.NotNull(exact);
|
||||
Assert.Equal(2, exact!.Object.GobjectId);
|
||||
|
||||
Assert.True(index.ObjectViewsByContainedPath.TryGetValue("plant/line_a", out GalaxyObjectView? lower));
|
||||
Assert.NotNull(lower);
|
||||
Assert.Same(exact, lower);
|
||||
|
||||
Assert.False(index.ObjectViewsByContainedPath.ContainsKey("Plant/Missing"));
|
||||
}
|
||||
|
||||
/// <summary>Verifies children sort areas-first, then by display name (case-insensitive).</summary>
|
||||
[Fact]
|
||||
public void ChildrenByParent_SortsAreasFirstThenByDisplayName()
|
||||
|
||||
Reference in New Issue
Block a user