diff --git a/clients/dotnet/README.md b/clients/dotnet/README.md index af7deee..87455aa 100644 --- a/clients/dotnet/README.md +++ b/clients/dotnet/README.md @@ -244,6 +244,19 @@ foreach (LazyBrowseNode root in roots) and is safe under concurrent callers. To refresh after a Galaxy redeploy, call `BrowseAsync` again from the root. +The CLI counterpart is `galaxy-browse`. Without `--parent` it walks the root +objects and eagerly expands `--depth` further levels into an indented tree; with +`--parent ` it fetches exactly one level of children for that object +(`--depth` is ignored there). Filter flags map onto `BrowseChildrenOptions`: +`--category-ids` and `--template-contains` are comma-separated lists, +`--tag-name-glob` / `--alarm-bearing-only` / `--historized-only` are scalar, and +`--include-attributes` overrides the server default for attribute population. + +```powershell +dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-browse --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --depth 1 +dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-browse --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --parent 42 --json +``` + ### Watching deploy events `WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/IMxGatewayCliClient.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/IMxGatewayCliClient.cs index a286f0e..5f0b67d 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/IMxGatewayCliClient.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/IMxGatewayCliClient.cs @@ -105,4 +105,16 @@ public interface IMxGatewayCliClient : IAsyncDisposable IAsyncEnumerable GalaxyWatchDeployEventsAsync( WatchDeployEventsRequest request, CancellationToken cancellationToken); + + /// + /// Fetches one page of direct children of a Galaxy parent (or the root + /// objects when the parent selector is unset), the primitive that backs the + /// lazy-browse helper. + /// + /// The browse-children request. + /// Cancellation token for the operation. + /// The browse-children reply. + Task GalaxyBrowseChildrenAsync( + BrowseChildrenRequest request, + CancellationToken cancellationToken); } diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs index 6601dcb..568ad8c 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayCliClientAdapter.cs @@ -100,6 +100,14 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken); } + /// + public Task GalaxyBrowseChildrenAsync( + BrowseChildrenRequest request, + CancellationToken cancellationToken) + { + return _galaxyClient.Value.BrowseChildrenRawAsync(request, cancellationToken); + } + /// public async ValueTask DisposeAsync() { diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs index 8f2ff53..1c3cfc7 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli/MxGatewayClientCli.cs @@ -144,6 +144,8 @@ public static class MxGatewayClientCli .ConfigureAwait(false), "galaxy-discover" => await GalaxyDiscoverAsync(arguments, client, standardOutput, cancellation.Token) .ConfigureAwait(false), + "galaxy-browse" => await GalaxyBrowseAsync(arguments, client, standardOutput, standardError, cancellation.Token) + .ConfigureAwait(false), "galaxy-watch" => await GalaxyWatchAsync(arguments, client, standardOutput, cancellation.Token) .ConfigureAwait(false), _ => WriteUnknownCommand(command, standardError), @@ -1607,6 +1609,270 @@ public static class MxGatewayClientCli return aggregate; } + /// + /// Per-request page size for the galaxy-browse single-level walks. Mirrors + /// the library's BrowseChildrenPageSize so the CLI and the + /// lazy-browse helper page identically. + /// + private const int BrowseChildrenCliPageSize = 500; + + /// + /// Drives the lazy-browse Galaxy surface from the CLI. Without + /// --parent it walks the root objects and eagerly expands + /// --depth further levels (each level reuses the same + /// , like the library helper). With + /// --parent it fetches exactly one level of children for that + /// gobject id via a parent-scoped BrowseChildren request; --depth + /// is not meaningful there and a warning is emitted if combined, mirroring + /// the Go/Rust CLIs. + /// + private static async Task GalaxyBrowseAsync( + CliArguments arguments, + IMxGatewayCliClient client, + TextWriter output, + TextWriter standardError, + CancellationToken cancellationToken) + { + BrowseChildrenOptions options = ParseBrowseChildrenOptions(arguments); + bool json = arguments.HasFlag("json"); + int parent = arguments.GetInt32("parent", -1); + int depth = arguments.GetInt32("depth", 0); + + // A specific parent → one level of children via the parent-scoped RPC. + if (parent >= 0) + { + if (depth > 0) + { + standardError.WriteLine("warning: --depth is ignored when --parent is specified."); + } + + IReadOnlyList children = await BrowseOneLevelAsync( + client, + options, + parent, + cancellationToken) + .ConfigureAwait(false); + + if (json) + { + output.WriteLine(JsonSerializer.Serialize( + new + { + command = "galaxy-browse", + parentId = parent, + children = children.Select(GalaxyObjectToJsonElement).ToArray(), + }, + JsonOptions)); + return 0; + } + + output.WriteLine(children.Count.ToString(CultureInfo.InvariantCulture)); + foreach (GalaxyObject child in children) + { + output.WriteLine(FormatGalaxyObject(child, level: 0, hasChildrenHint: null)); + } + + return 0; + } + + // No parent → walk the root objects, eagerly expanding --depth levels. + IReadOnlyList roots = await BrowseTreeAsync( + client, + options, + parentGobjectId: 0, + remainingDepth: depth, + cancellationToken) + .ConfigureAwait(false); + + if (json) + { + output.WriteLine(JsonSerializer.Serialize( + new + { + command = "galaxy-browse", + nodes = roots.Select(BrowseTreeNodeToJson).ToArray(), + }, + JsonOptions)); + return 0; + } + + output.WriteLine(roots.Count.ToString(CultureInfo.InvariantCulture)); + foreach (BrowseTreeNode node in roots) + { + WriteBrowseTreeNode(output, node, level: 0); + } + + return 0; + } + + /// + /// One node in the eagerly-expanded galaxy-browse tree: the Galaxy object, + /// the server's has-children hint, and any children fetched up to the + /// requested depth. + /// + private sealed record BrowseTreeNode( + GalaxyObject Object, + bool HasChildrenHint, + IReadOnlyList Children); + + /// + /// Fetches the direct children of + /// (0 = root) and recursively expands + /// further levels. Paging is followed to completion at each level. + /// + private static async Task> BrowseTreeAsync( + IMxGatewayCliClient client, + BrowseChildrenOptions options, + int parentGobjectId, + int remainingDepth, + CancellationToken cancellationToken) + { + List nodes = []; + string pageToken = string.Empty; + HashSet seenPageTokens = new(StringComparer.Ordinal); + do + { + BrowseChildrenRequest request = BuildBrowseChildrenRequest(options, parentGobjectId, pageToken); + BrowseChildrenReply reply = await client.GalaxyBrowseChildrenAsync(request, cancellationToken) + .ConfigureAwait(false); + + for (int i = 0; i < reply.Children.Count; i++) + { + GalaxyObject child = reply.Children[i]; + bool hint = i < reply.ChildHasChildren.Count && reply.ChildHasChildren[i]; + IReadOnlyList grandChildren = remainingDepth > 0 + ? await BrowseTreeAsync(client, options, child.GobjectId, remainingDepth - 1, cancellationToken) + .ConfigureAwait(false) + : []; + nodes.Add(new BrowseTreeNode(child, hint, grandChildren)); + } + + 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 nodes; + } + + /// Fetches exactly one level of children for a parent gobject id, paging to completion. + private static async Task> BrowseOneLevelAsync( + IMxGatewayCliClient client, + BrowseChildrenOptions options, + int parentGobjectId, + CancellationToken cancellationToken) + { + List children = []; + string pageToken = string.Empty; + HashSet seenPageTokens = new(StringComparer.Ordinal); + do + { + BrowseChildrenRequest request = BuildBrowseChildrenRequest(options, parentGobjectId, pageToken); + BrowseChildrenReply reply = await client.GalaxyBrowseChildrenAsync(request, cancellationToken) + .ConfigureAwait(false); + + children.AddRange(reply.Children); + 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 children; + } + + private static BrowseChildrenOptions ParseBrowseChildrenOptions(CliArguments arguments) + { + return new BrowseChildrenOptions + { + CategoryIds = ParseOptionalInt32List(arguments.GetOptional("category-ids")), + TemplateChainContains = ParseOptionalStringList(arguments.GetOptional("template-contains")), + TagNameGlob = arguments.GetOptional("tag-name-glob"), + AlarmBearingOnly = arguments.HasFlag("alarm-bearing-only"), + HistorizedOnly = arguments.HasFlag("historized-only"), + // Tri-state: only override the server default when the flag is present. + IncludeAttributes = arguments.HasFlag("include-attributes") ? true : null, + }; + } + + private static BrowseChildrenRequest BuildBrowseChildrenRequest( + BrowseChildrenOptions options, + int parentGobjectId, + string pageToken) + { + BrowseChildrenRequest request = new() + { + PageSize = BrowseChildrenCliPageSize, + PageToken = pageToken, + ParentGobjectId = parentGobjectId, + 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; + } + + private static void WriteBrowseTreeNode(TextWriter output, BrowseTreeNode node, int level) + { + output.WriteLine(FormatGalaxyObject(node.Object, level, node.HasChildrenHint)); + foreach (BrowseTreeNode child in node.Children) + { + WriteBrowseTreeNode(output, child, level + 1); + } + } + + private static string FormatGalaxyObject(GalaxyObject galaxyObject, int level, bool? hasChildrenHint) + { + string indent = new(' ', level * 2); + string suffix = hasChildrenHint is null + ? $"(attrs={galaxyObject.Attributes.Count})" + : $"(attrs={galaxyObject.Attributes.Count}, hasChildren={hasChildrenHint.Value})"; + return $"{indent}{galaxyObject.GobjectId}\t{galaxyObject.TagName}\t{galaxyObject.BrowseName}\t{suffix}"; + } + + private static object BrowseTreeNodeToJson(BrowseTreeNode node) + { + return new + { + @object = GalaxyObjectToJsonElement(node.Object), + hasChildren = node.HasChildrenHint, + children = node.Children.Select(BrowseTreeNodeToJson).ToArray(), + }; + } + + private static JsonElement GalaxyObjectToJsonElement(GalaxyObject galaxyObject) + { + return JsonDocument.Parse(ProtobufJsonFormatter.Format(galaxyObject)).RootElement.Clone(); + } + + private static IReadOnlyList ParseOptionalInt32List(string? value) + { + return string.IsNullOrWhiteSpace(value) ? [] : ParseInt32List(value); + } + + private static IReadOnlyList ParseOptionalStringList(string? value) + { + return string.IsNullOrWhiteSpace(value) ? [] : ParseStringList(value); + } + private static async Task GalaxyWatchAsync( CliArguments arguments, IMxGatewayCliClient client, @@ -1736,6 +2002,7 @@ public static class MxGatewayClientCli or "galaxy-test-connection" or "galaxy-last-deploy" or "galaxy-discover" + or "galaxy-browse" or "galaxy-watch"; } @@ -1797,6 +2064,7 @@ public static class MxGatewayClientCli writer.WriteLine("mxgw-dotnet galaxy-test-connection [--json]"); writer.WriteLine("mxgw-dotnet galaxy-last-deploy [--json]"); writer.WriteLine("mxgw-dotnet galaxy-discover [--json]"); + writer.WriteLine("mxgw-dotnet galaxy-browse [--parent ] [--depth ] [--category-ids ] [--template-contains ] [--tag-name-glob ] [--alarm-bearing-only] [--historized-only] [--include-attributes] [--json]"); writer.WriteLine("mxgw-dotnet galaxy-watch [--last-seen-deploy-time ] [--max-events ] [--json]"); } } diff --git a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs index a63ec30..c049ea2 100644 --- a/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs +++ b/clients/dotnet/ZB.MOM.WW.MxGateway.Client.Tests/MxGatewayClientCliTests.cs @@ -360,6 +360,146 @@ public sealed class MxGatewayClientCliTests Assert.Equal(string.Empty, error.ToString()); } + /// + /// Verifies galaxy-browse walks root objects and eagerly expands one further + /// level when --depth 1 is passed, printing an indented tree. + /// + [Fact] + public async Task RunAsync_GalaxyBrowse_TextTreeExpandsToDepth() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new(); + // Root level (parent 0): one area with a child hint. + fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue( + [ + new BrowseChildrenReply + { + Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } }, + ChildHasChildren = { true }, + }, + ]); + // Children of gobject 10. + fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue( + [ + new BrowseChildrenReply + { + Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } }, + }, + ]); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "galaxy-browse", + "--endpoint", + "http://localhost:5000", + "--api-key", + "test-api-key", + "--depth", + "1", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(0, exitCode); + string text = output.ToString(); + Assert.Contains("Area_001", text); + Assert.Contains("Tank_001", text); + // Children are indented beneath their parent (two-space indent per level). + Assert.Matches(@"\n \d+\tTank_001", text); + // Root fetched with the parent oneof unset; child fetch used parent 10. + Assert.Contains( + fakeClient.GalaxyBrowseChildrenRequests, + request => request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId + && request.ParentGobjectId == 10); + Assert.Equal(string.Empty, error.ToString()); + } + + /// + /// Verifies galaxy-browse --json emits a nested JSON document and forwards + /// the filter flags onto the BrowseChildren request. + /// + [Fact] + public async Task RunAsync_GalaxyBrowse_JsonForwardsFilters() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new(); + fakeClient.GalaxyBrowseChildrenReplies[0] = new Queue( + [ + new BrowseChildrenReply + { + Children = { new GalaxyObject { GobjectId = 10, TagName = "Area_001", BrowseName = "Area" } }, + }, + ]); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "galaxy-browse", + "--endpoint", + "http://localhost:5000", + "--api-key", + "test-api-key", + "--tag-name-glob", + "Area*", + "--alarm-bearing-only", + "--json", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(0, exitCode); + using System.Text.Json.JsonDocument document = System.Text.Json.JsonDocument.Parse(output.ToString()); + Assert.Equal("galaxy-browse", document.RootElement.GetProperty("command").GetString()); + Assert.True(document.RootElement.GetProperty("nodes").GetArrayLength() >= 1); + BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests); + Assert.Equal("Area*", request.TagNameGlob); + Assert.True(request.AlarmBearingOnly); + Assert.Equal(string.Empty, error.ToString()); + } + + /// + /// Verifies galaxy-browse --parent fetches exactly one level of children for + /// the supplied gobject id via a parent-scoped BrowseChildren request. + /// + [Fact] + public async Task RunAsync_GalaxyBrowse_ParentFetchesSingleLevel() + { + using var output = new StringWriter(); + using var error = new StringWriter(); + FakeCliClient fakeClient = new(); + fakeClient.GalaxyBrowseChildrenReplies[10] = new Queue( + [ + new BrowseChildrenReply + { + Children = { new GalaxyObject { GobjectId = 20, TagName = "Tank_001", BrowseName = "Tank" } }, + }, + ]); + + int exitCode = await MxGatewayClientCli.RunAsync( + [ + "galaxy-browse", + "--endpoint", + "http://localhost:5000", + "--api-key", + "test-api-key", + "--parent", + "10", + ], + output, + error, + _ => fakeClient); + + Assert.Equal(0, exitCode); + Assert.Contains("Tank_001", output.ToString()); + BrowseChildrenRequest request = Assert.Single(fakeClient.GalaxyBrowseChildrenRequests); + Assert.Equal(BrowseChildrenRequest.ParentOneofCase.ParentGobjectId, request.ParentCase); + Assert.Equal(10, request.ParentGobjectId); + Assert.Equal(string.Empty, error.ToString()); + } + /// Verifies that galaxy-watch command prints text output for deploy events. [Fact] public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents() @@ -1051,5 +1191,33 @@ public sealed class MxGatewayClientCliTests yield return deployEvent; } } + + /// List of received galaxy browse-children requests, in call order. + public List GalaxyBrowseChildrenRequests { get; } = []; + + /// + /// Per-parent browse-children replies keyed by parent_gobject_id + /// (0 = root). Each parent's queue is dequeued in page order; an absent + /// or exhausted queue yields an empty reply. + /// + public Dictionary> GalaxyBrowseChildrenReplies { get; } = []; + + /// + public Task GalaxyBrowseChildrenAsync( + BrowseChildrenRequest request, + CancellationToken cancellationToken) + { + GalaxyBrowseChildrenRequests.Add(request); + int parentId = request.ParentCase == BrowseChildrenRequest.ParentOneofCase.ParentGobjectId + ? request.ParentGobjectId + : 0; + if (GalaxyBrowseChildrenReplies.TryGetValue(parentId, out Queue? queue) + && queue.TryDequeue(out BrowseChildrenReply? reply)) + { + return Task.FromResult(reply); + } + + return Task.FromResult(new BrowseChildrenReply()); + } } }