Compare commits

...

10 Commits

Author SHA1 Message Date
Joseph Doherty 83a4d41fce docs: align design doc test-plan with InvalidArgument error mapping 2026-05-28 13:30:19 -04:00
Joseph Doherty 0d6193cdc4 docs: note BrowseChildren in gateway overview and client READMEs 2026-05-28 13:25:46 -04:00
Joseph Doherty 8cd3e1c20e client/go: regenerate protos for BrowseChildren 2026-05-28 13:22:06 -04:00
Joseph Doherty 5c28458624 client/rust: regenerate protos for BrowseChildren 2026-05-28 13:19:54 -04:00
Joseph Doherty 0b389f5a97 docs: document BrowseChildren RPC and lazy browse architecture 2026-05-28 13:19:08 -04:00
Joseph Doherty 108c4bb118 client/python: regenerate protos for BrowseChildren 2026-05-28 13:18:25 -04:00
Joseph Doherty cf54a278e1 docs: record lazy-browse stays wire-only; align error mapping 2026-05-28 13:18:23 -04:00
Joseph Doherty 81b2aacfe2 client/dotnet: live smoke for BrowseChildren 2026-05-28 13:17:29 -04:00
Joseph Doherty 5932fe2fd3 dashboard: surface lazy-load errors via BrowseLoadState.Error 2026-05-28 13:15:26 -04:00
Joseph Doherty 310dfab8b4 dashboard: lazy-load BrowsePage via DashboardBrowseService 2026-05-28 13:10:10 -04:00
25 changed files with 1128 additions and 53 deletions
+22
View File
@@ -196,6 +196,28 @@ dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-las
dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY dotnet run --project clients/dotnet/ZB.MOM.WW.MxGateway.Client.Cli -- galaxy-discover --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY
``` ```
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildrenAsync` to walk one level at a
time instead of paging the full hierarchy. Pass an empty request for root objects;
subsequent calls supply `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Each child's `ChildHasChildren[i]` tells you whether to
draw an expand triangle. Filter fields match `DiscoverHierarchy`. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```csharp
BrowseChildrenReply roots = await repository.BrowseChildrenAsync(
new BrowseChildrenRequest());
for (int i = 0; i < roots.Children.Count; i++)
{
GalaxyObject child = roots.Children[i];
bool hasChildren = roots.ChildHasChildren[i];
Console.WriteLine($"{child.TagName} expand={hasChildren}");
}
```
### Watching deploy events ### Watching deploy events
`WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The `WatchDeployEventsAsync` opens the `WatchDeployEvents` server-streaming RPC. The
@@ -0,0 +1,34 @@
using Grpc.Core;
using Grpc.Net.Client;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
namespace ZB.MOM.WW.MxGateway.Client.Tests;
/// <summary>
/// Live smoke tests for the BrowseChildren RPC. Skipped by default; set
/// MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to run against a real gateway.
/// </summary>
public sealed class BrowseChildrenSmokeTests
{
/// <summary>
/// Verifies that BrowseChildren returns a non-zero cache sequence and
/// a consistent children/child-has-children count from a live gateway.
/// </summary>
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
{
string? apiKey = Environment.GetEnvironmentVariable("MXGATEWAY_API_KEY");
string endpoint = Environment.GetEnvironmentVariable("MXGATEWAY_ENDPOINT") ?? "http://localhost:5120";
Assert.False(string.IsNullOrEmpty(apiKey), "MXGATEWAY_API_KEY must be set.");
using GrpcChannel channel = GrpcChannel.ForAddress(endpoint);
GalaxyRepository.GalaxyRepositoryClient client = new(channel);
Metadata headers = new() { { "authorization", $"Bearer {apiKey}" } };
BrowseChildrenReply reply = await client.BrowseChildrenAsync(new BrowseChildrenRequest(), headers);
Assert.True(reply.CacheSequence > 0UL);
Assert.Equal(reply.Children.Count, reply.ChildHasChildren.Count);
}
}
+22
View File
@@ -121,6 +121,28 @@ reports `present=false` (no deploy recorded). `DiscoverHierarchy` returns
the generated `*GalaxyObject` slice with each object's dynamic attributes the generated `*GalaxyObject` slice with each object's dynamic attributes
populated for direct contract access. populated for direct contract access.
### Browsing lazily
For UI trees or OPC UA bridges, use `BrowseChildren` to walk one level at a
time instead of loading the full hierarchy. Pass an empty request for root
objects; subsequent calls set `ParentGobjectId`, `ParentTagName`, or
`ParentContainedPath`. Filter fields match `DiscoverHierarchy`. Each response
pairs `Children` with `ChildHasChildren` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```go
import pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated/galaxy_repository/v1"
reply, err := galaxy.BrowseChildren(ctx, &pb.BrowseChildrenRequest{})
if err != nil {
return err
}
for i, child := range reply.GetChildren() {
fmt.Printf("%s expand=%v\n", child.GetTagName(), reply.GetChildHasChildren()[i])
}
```
### Watching deploy events ### Watching deploy events
`WatchDeployEvents` opens a server-streaming subscription. The server emits a `WatchDeployEvents` opens a server-streaming subscription. The server emits a
@@ -824,6 +824,260 @@ func (x *GalaxyAttribute) GetIsAlarm() bool {
return false return false
} }
type BrowseChildrenRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Parent selector. Empty oneof returns root objects (parent_gobject_id == 0).
//
// Types that are valid to be assigned to Parent:
//
// *BrowseChildrenRequest_ParentGobjectId
// *BrowseChildrenRequest_ParentTagName
// *BrowseChildrenRequest_ParentContainedPath
Parent isBrowseChildrenRequest_Parent `protobuf_oneof:"parent"`
// Maximum number of direct children to return. Server default 500; cap 5000.
PageSize int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"`
// Opaque token returned by a previous BrowseChildren response. Bound to the
// cache sequence, parent selector, and the filter set; a mismatch returns
// InvalidArgument.
PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"`
// --- Filter parity with DiscoverHierarchy. AND-combined. ---
CategoryIds []int32 `protobuf:"varint,6,rep,packed,name=category_ids,json=categoryIds,proto3" json:"category_ids,omitempty"`
TemplateChainContains []string `protobuf:"bytes,7,rep,name=template_chain_contains,json=templateChainContains,proto3" json:"template_chain_contains,omitempty"`
TagNameGlob string `protobuf:"bytes,8,opt,name=tag_name_glob,json=tagNameGlob,proto3" json:"tag_name_glob,omitempty"`
IncludeAttributes *bool `protobuf:"varint,9,opt,name=include_attributes,json=includeAttributes,proto3,oneof" json:"include_attributes,omitempty"`
AlarmBearingOnly bool `protobuf:"varint,10,opt,name=alarm_bearing_only,json=alarmBearingOnly,proto3" json:"alarm_bearing_only,omitempty"`
HistorizedOnly bool `protobuf:"varint,11,opt,name=historized_only,json=historizedOnly,proto3" json:"historized_only,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenRequest) Reset() {
*x = BrowseChildrenRequest{}
mi := &file_galaxy_repository_proto_msgTypes[10]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenRequest) ProtoMessage() {}
func (x *BrowseChildrenRequest) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[10]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenRequest.ProtoReflect.Descriptor instead.
func (*BrowseChildrenRequest) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{10}
}
func (x *BrowseChildrenRequest) GetParent() isBrowseChildrenRequest_Parent {
if x != nil {
return x.Parent
}
return nil
}
func (x *BrowseChildrenRequest) GetParentGobjectId() int32 {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentGobjectId); ok {
return x.ParentGobjectId
}
}
return 0
}
func (x *BrowseChildrenRequest) GetParentTagName() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentTagName); ok {
return x.ParentTagName
}
}
return ""
}
func (x *BrowseChildrenRequest) GetParentContainedPath() string {
if x != nil {
if x, ok := x.Parent.(*BrowseChildrenRequest_ParentContainedPath); ok {
return x.ParentContainedPath
}
}
return ""
}
func (x *BrowseChildrenRequest) GetPageSize() int32 {
if x != nil {
return x.PageSize
}
return 0
}
func (x *BrowseChildrenRequest) GetPageToken() string {
if x != nil {
return x.PageToken
}
return ""
}
func (x *BrowseChildrenRequest) GetCategoryIds() []int32 {
if x != nil {
return x.CategoryIds
}
return nil
}
func (x *BrowseChildrenRequest) GetTemplateChainContains() []string {
if x != nil {
return x.TemplateChainContains
}
return nil
}
func (x *BrowseChildrenRequest) GetTagNameGlob() string {
if x != nil {
return x.TagNameGlob
}
return ""
}
func (x *BrowseChildrenRequest) GetIncludeAttributes() bool {
if x != nil && x.IncludeAttributes != nil {
return *x.IncludeAttributes
}
return false
}
func (x *BrowseChildrenRequest) GetAlarmBearingOnly() bool {
if x != nil {
return x.AlarmBearingOnly
}
return false
}
func (x *BrowseChildrenRequest) GetHistorizedOnly() bool {
if x != nil {
return x.HistorizedOnly
}
return false
}
type isBrowseChildrenRequest_Parent interface {
isBrowseChildrenRequest_Parent()
}
type BrowseChildrenRequest_ParentGobjectId struct {
ParentGobjectId int32 `protobuf:"varint,1,opt,name=parent_gobject_id,json=parentGobjectId,proto3,oneof"`
}
type BrowseChildrenRequest_ParentTagName struct {
ParentTagName string `protobuf:"bytes,2,opt,name=parent_tag_name,json=parentTagName,proto3,oneof"`
}
type BrowseChildrenRequest_ParentContainedPath struct {
ParentContainedPath string `protobuf:"bytes,3,opt,name=parent_contained_path,json=parentContainedPath,proto3,oneof"`
}
func (*BrowseChildrenRequest_ParentGobjectId) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentTagName) isBrowseChildrenRequest_Parent() {}
func (*BrowseChildrenRequest_ParentContainedPath) isBrowseChildrenRequest_Parent() {}
type BrowseChildrenReply struct {
state protoimpl.MessageState `protogen:"open.v1"`
// Direct children matching the filter, sorted areas-first then by
// case-insensitive display name (same order as the dashboard tree).
Children []*GalaxyObject `protobuf:"bytes,1,rep,name=children,proto3" json:"children,omitempty"`
// Non-empty when another page of siblings is available.
NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"`
// Total matching direct children of the parent (post-filter).
TotalChildCount int32 `protobuf:"varint,3,opt,name=total_child_count,json=totalChildCount,proto3" json:"total_child_count,omitempty"`
// Parallel array, indexed with `children`. True when the child has at least
// one matching descendant under the same filter set. Lets a UI choose
// whether to draw an expand triangle without an extra round trip.
ChildHasChildren []bool `protobuf:"varint,4,rep,packed,name=child_has_children,json=childHasChildren,proto3" json:"child_has_children,omitempty"`
// Cache sequence this reply was projected from. Clients may pass it back as
// part of the page_token contract. Mismatch on the next page -> InvalidArgument.
CacheSequence uint64 `protobuf:"varint,5,opt,name=cache_sequence,json=cacheSequence,proto3" json:"cache_sequence,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BrowseChildrenReply) Reset() {
*x = BrowseChildrenReply{}
mi := &file_galaxy_repository_proto_msgTypes[11]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BrowseChildrenReply) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BrowseChildrenReply) ProtoMessage() {}
func (x *BrowseChildrenReply) ProtoReflect() protoreflect.Message {
mi := &file_galaxy_repository_proto_msgTypes[11]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use BrowseChildrenReply.ProtoReflect.Descriptor instead.
func (*BrowseChildrenReply) Descriptor() ([]byte, []int) {
return file_galaxy_repository_proto_rawDescGZIP(), []int{11}
}
func (x *BrowseChildrenReply) GetChildren() []*GalaxyObject {
if x != nil {
return x.Children
}
return nil
}
func (x *BrowseChildrenReply) GetNextPageToken() string {
if x != nil {
return x.NextPageToken
}
return ""
}
func (x *BrowseChildrenReply) GetTotalChildCount() int32 {
if x != nil {
return x.TotalChildCount
}
return 0
}
func (x *BrowseChildrenReply) GetChildHasChildren() []bool {
if x != nil {
return x.ChildHasChildren
}
return nil
}
func (x *BrowseChildrenReply) GetCacheSequence() uint64 {
if x != nil {
return x.CacheSequence
}
return 0
}
var File_galaxy_repository_proto protoreflect.FileDescriptor var File_galaxy_repository_proto protoreflect.FileDescriptor
const file_galaxy_repository_proto_rawDesc = "" + const file_galaxy_repository_proto_rawDesc = "" +
@@ -897,12 +1151,35 @@ const file_galaxy_repository_proto_rawDesc = "" +
"\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" + "\x17security_classification\x18\t \x01(\x05R\x16securityClassification\x12#\n" +
"\ris_historized\x18\n" + "\ris_historized\x18\n" +
" \x01(\bR\fisHistorized\x12\x19\n" + " \x01(\bR\fisHistorized\x12\x19\n" +
"\bis_alarm\x18\v \x01(\bR\aisAlarm2\xcc\x03\n" + "\bis_alarm\x18\v \x01(\bR\aisAlarm\"\x8c\x04\n" +
"\x15BrowseChildrenRequest\x12,\n" +
"\x11parent_gobject_id\x18\x01 \x01(\x05H\x00R\x0fparentGobjectId\x12(\n" +
"\x0fparent_tag_name\x18\x02 \x01(\tH\x00R\rparentTagName\x124\n" +
"\x15parent_contained_path\x18\x03 \x01(\tH\x00R\x13parentContainedPath\x12\x1b\n" +
"\tpage_size\x18\x04 \x01(\x05R\bpageSize\x12\x1d\n" +
"\n" +
"page_token\x18\x05 \x01(\tR\tpageToken\x12!\n" +
"\fcategory_ids\x18\x06 \x03(\x05R\vcategoryIds\x126\n" +
"\x17template_chain_contains\x18\a \x03(\tR\x15templateChainContains\x12\"\n" +
"\rtag_name_glob\x18\b \x01(\tR\vtagNameGlob\x122\n" +
"\x12include_attributes\x18\t \x01(\bH\x01R\x11includeAttributes\x88\x01\x01\x12,\n" +
"\x12alarm_bearing_only\x18\n" +
" \x01(\bR\x10alarmBearingOnly\x12'\n" +
"\x0fhistorized_only\x18\v \x01(\bR\x0ehistorizedOnlyB\b\n" +
"\x06parentB\x15\n" +
"\x13_include_attributes\"\xfe\x01\n" +
"\x13BrowseChildrenReply\x12>\n" +
"\bchildren\x18\x01 \x03(\v2\".galaxy_repository.v1.GalaxyObjectR\bchildren\x12&\n" +
"\x0fnext_page_token\x18\x02 \x01(\tR\rnextPageToken\x12*\n" +
"\x11total_child_count\x18\x03 \x01(\x05R\x0ftotalChildCount\x12,\n" +
"\x12child_has_children\x18\x04 \x03(\bR\x10childHasChildren\x12%\n" +
"\x0ecache_sequence\x18\x05 \x01(\x04R\rcacheSequence2\xb6\x04\n" +
"\x10GalaxyRepository\x12h\n" + "\x10GalaxyRepository\x12h\n" +
"\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" + "\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n" +
"\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" + "\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n" +
"\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" + "\x11DiscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n" +
"\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01B-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3" "\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n" +
"\x0eBrowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3"
var ( var (
file_galaxy_repository_proto_rawDescOnce sync.Once file_galaxy_repository_proto_rawDescOnce sync.Once
@@ -916,7 +1193,7 @@ func file_galaxy_repository_proto_rawDescGZIP() []byte {
return file_galaxy_repository_proto_rawDescData return file_galaxy_repository_proto_rawDescData
} }
var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_galaxy_repository_proto_msgTypes = make([]protoimpl.MessageInfo, 12)
var file_galaxy_repository_proto_goTypes = []any{ var file_galaxy_repository_proto_goTypes = []any{
(*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest (*TestConnectionRequest)(nil), // 0: galaxy_repository.v1.TestConnectionRequest
(*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply (*TestConnectionReply)(nil), // 1: galaxy_repository.v1.TestConnectionReply
@@ -928,30 +1205,35 @@ var file_galaxy_repository_proto_goTypes = []any{
(*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent (*DeployEvent)(nil), // 7: galaxy_repository.v1.DeployEvent
(*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject (*GalaxyObject)(nil), // 8: galaxy_repository.v1.GalaxyObject
(*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute (*GalaxyAttribute)(nil), // 9: galaxy_repository.v1.GalaxyAttribute
(*timestamppb.Timestamp)(nil), // 10: google.protobuf.Timestamp (*BrowseChildrenRequest)(nil), // 10: galaxy_repository.v1.BrowseChildrenRequest
(*wrapperspb.Int32Value)(nil), // 11: google.protobuf.Int32Value (*BrowseChildrenReply)(nil), // 11: galaxy_repository.v1.BrowseChildrenReply
(*timestamppb.Timestamp)(nil), // 12: google.protobuf.Timestamp
(*wrapperspb.Int32Value)(nil), // 13: google.protobuf.Int32Value
} }
var file_galaxy_repository_proto_depIdxs = []int32{ var file_galaxy_repository_proto_depIdxs = []int32{
10, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp 12, // 0: galaxy_repository.v1.GetLastDeployTimeReply.time_of_last_deploy:type_name -> google.protobuf.Timestamp
11, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value 13, // 1: galaxy_repository.v1.DiscoverHierarchyRequest.max_depth:type_name -> google.protobuf.Int32Value
8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject 8, // 2: galaxy_repository.v1.DiscoverHierarchyReply.objects:type_name -> galaxy_repository.v1.GalaxyObject
10, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp 12, // 3: galaxy_repository.v1.WatchDeployEventsRequest.last_seen_deploy_time:type_name -> google.protobuf.Timestamp
10, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp 12, // 4: galaxy_repository.v1.DeployEvent.observed_at:type_name -> google.protobuf.Timestamp
10, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp 12, // 5: galaxy_repository.v1.DeployEvent.time_of_last_deploy:type_name -> google.protobuf.Timestamp
9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute 9, // 6: galaxy_repository.v1.GalaxyObject.attributes:type_name -> galaxy_repository.v1.GalaxyAttribute
0, // 7: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest 8, // 7: galaxy_repository.v1.BrowseChildrenReply.children:type_name -> galaxy_repository.v1.GalaxyObject
2, // 8: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest 0, // 8: galaxy_repository.v1.GalaxyRepository.TestConnection:input_type -> galaxy_repository.v1.TestConnectionRequest
4, // 9: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest 2, // 9: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:input_type -> galaxy_repository.v1.GetLastDeployTimeRequest
6, // 10: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest 4, // 10: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:input_type -> galaxy_repository.v1.DiscoverHierarchyRequest
1, // 11: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply 6, // 11: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:input_type -> galaxy_repository.v1.WatchDeployEventsRequest
3, // 12: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply 10, // 12: galaxy_repository.v1.GalaxyRepository.BrowseChildren:input_type -> galaxy_repository.v1.BrowseChildrenRequest
5, // 13: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply 1, // 13: galaxy_repository.v1.GalaxyRepository.TestConnection:output_type -> galaxy_repository.v1.TestConnectionReply
7, // 14: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent 3, // 14: galaxy_repository.v1.GalaxyRepository.GetLastDeployTime:output_type -> galaxy_repository.v1.GetLastDeployTimeReply
11, // [11:15] is the sub-list for method output_type 5, // 15: galaxy_repository.v1.GalaxyRepository.DiscoverHierarchy:output_type -> galaxy_repository.v1.DiscoverHierarchyReply
7, // [7:11] is the sub-list for method input_type 7, // 16: galaxy_repository.v1.GalaxyRepository.WatchDeployEvents:output_type -> galaxy_repository.v1.DeployEvent
7, // [7:7] is the sub-list for extension type_name 11, // 17: galaxy_repository.v1.GalaxyRepository.BrowseChildren:output_type -> galaxy_repository.v1.BrowseChildrenReply
7, // [7:7] is the sub-list for extension extendee 13, // [13:18] is the sub-list for method output_type
0, // [0:7] is the sub-list for field type_name 8, // [8:13] is the sub-list for method input_type
8, // [8:8] is the sub-list for extension type_name
8, // [8:8] is the sub-list for extension extendee
0, // [0:8] is the sub-list for field type_name
} }
func init() { file_galaxy_repository_proto_init() } func init() { file_galaxy_repository_proto_init() }
@@ -964,13 +1246,18 @@ func file_galaxy_repository_proto_init() {
(*DiscoverHierarchyRequest_RootTagName)(nil), (*DiscoverHierarchyRequest_RootTagName)(nil),
(*DiscoverHierarchyRequest_RootContainedPath)(nil), (*DiscoverHierarchyRequest_RootContainedPath)(nil),
} }
file_galaxy_repository_proto_msgTypes[10].OneofWrappers = []any{
(*BrowseChildrenRequest_ParentGobjectId)(nil),
(*BrowseChildrenRequest_ParentTagName)(nil),
(*BrowseChildrenRequest_ParentContainedPath)(nil),
}
type x struct{} type x struct{}
out := protoimpl.TypeBuilder{ out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{ File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(), GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)), RawDescriptor: unsafe.Slice(unsafe.StringData(file_galaxy_repository_proto_rawDesc), len(file_galaxy_repository_proto_rawDesc)),
NumEnums: 0, NumEnums: 0,
NumMessages: 10, NumMessages: 12,
NumExtensions: 0, NumExtensions: 0,
NumServices: 1, NumServices: 1,
}, },
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1 // - protoc v7.34.1
// source: galaxy_repository.proto // source: galaxy_repository.proto
@@ -23,6 +23,7 @@ const (
GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime" GalaxyRepository_GetLastDeployTime_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/GetLastDeployTime"
GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy" GalaxyRepository_DiscoverHierarchy_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/DiscoverHierarchy"
GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents" GalaxyRepository_WatchDeployEvents_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/WatchDeployEvents"
GalaxyRepository_BrowseChildren_FullMethodName = "/galaxy_repository.v1.GalaxyRepository/BrowseChildren"
) )
// GalaxyRepositoryClient is the client API for GalaxyRepository service. // GalaxyRepositoryClient is the client API for GalaxyRepository service.
@@ -44,6 +45,11 @@ type GalaxyRepositoryClient interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped // increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow. // older events because the client was too slow.
WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error) WatchDeployEvents(ctx context.Context, in *WatchDeployEventsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[DeployEvent], error)
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error)
} }
type galaxyRepositoryClient struct { type galaxyRepositoryClient struct {
@@ -103,6 +109,16 @@ func (c *galaxyRepositoryClient) WatchDeployEvents(ctx context.Context, in *Watc
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent] type GalaxyRepository_WatchDeployEventsClient = grpc.ServerStreamingClient[DeployEvent]
func (c *galaxyRepositoryClient) BrowseChildren(ctx context.Context, in *BrowseChildrenRequest, opts ...grpc.CallOption) (*BrowseChildrenReply, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(BrowseChildrenReply)
err := c.cc.Invoke(ctx, GalaxyRepository_BrowseChildren_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// GalaxyRepositoryServer is the server API for GalaxyRepository service. // GalaxyRepositoryServer is the server API for GalaxyRepository service.
// All implementations must embed UnimplementedGalaxyRepositoryServer // All implementations must embed UnimplementedGalaxyRepositoryServer
// for forward compatibility. // for forward compatibility.
@@ -122,6 +138,11 @@ type GalaxyRepositoryServer interface {
// increasing per server start; gaps indicate the per-subscriber buffer dropped // increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow. // older events because the client was too slow.
WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error
// Returns the direct children of a parent object (or the root objects when
// `parent` is unset). Designed for OPC UA-style lazy expand: clients walk
// one level at a time instead of paging the full hierarchy. Filters mirror
// DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error)
mustEmbedUnimplementedGalaxyRepositoryServer() mustEmbedUnimplementedGalaxyRepositoryServer()
} }
@@ -144,6 +165,9 @@ func (UnimplementedGalaxyRepositoryServer) DiscoverHierarchy(context.Context, *D
func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error { func (UnimplementedGalaxyRepositoryServer) WatchDeployEvents(*WatchDeployEventsRequest, grpc.ServerStreamingServer[DeployEvent]) error {
return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented") return status.Error(codes.Unimplemented, "method WatchDeployEvents not implemented")
} }
func (UnimplementedGalaxyRepositoryServer) BrowseChildren(context.Context, *BrowseChildrenRequest) (*BrowseChildrenReply, error) {
return nil, status.Error(codes.Unimplemented, "method BrowseChildren not implemented")
}
func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {} func (UnimplementedGalaxyRepositoryServer) mustEmbedUnimplementedGalaxyRepositoryServer() {}
func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {} func (UnimplementedGalaxyRepositoryServer) testEmbeddedByValue() {}
@@ -230,6 +254,24 @@ func _GalaxyRepository_WatchDeployEvents_Handler(srv interface{}, stream grpc.Se
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name. // This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent] type GalaxyRepository_WatchDeployEventsServer = grpc.ServerStreamingServer[DeployEvent]
func _GalaxyRepository_BrowseChildren_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(BrowseChildrenRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: GalaxyRepository_BrowseChildren_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(GalaxyRepositoryServer).BrowseChildren(ctx, req.(*BrowseChildrenRequest))
}
return interceptor(ctx, in, info, handler)
}
// GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service. // GalaxyRepository_ServiceDesc is the grpc.ServiceDesc for GalaxyRepository service.
// It's only intended for direct use with grpc.RegisterService, // It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy) // and not to be introspected or modified (even as a copy)
@@ -249,6 +291,10 @@ var GalaxyRepository_ServiceDesc = grpc.ServiceDesc{
MethodName: "DiscoverHierarchy", MethodName: "DiscoverHierarchy",
Handler: _GalaxyRepository_DiscoverHierarchy_Handler, Handler: _GalaxyRepository_DiscoverHierarchy_Handler,
}, },
{
MethodName: "BrowseChildren",
Handler: _GalaxyRepository_BrowseChildren_Handler,
},
}, },
Streams: []grpc.StreamDesc{ Streams: []grpc.StreamDesc{
{ {
@@ -725,9 +725,10 @@ func (SessionState) EnumDescriptor() ([]byte, []int) {
return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8} return file_mxaccess_gateway_proto_rawDescGZIP(), []int{8}
} }
// Public request shape for QueryActiveAlarms. session_id is currently unused // Public request shape for QueryActiveAlarms.
// (the snapshot is session-less) but reserved so a future per-session view // Clients may leave `session_id` empty; the gateway currently ignores it and
// can be added without a wire break. // serves the session-less central-monitor cache. A future version may use it
// to scope the snapshot to one session.
type QueryActiveAlarmsRequest struct { type QueryActiveAlarmsRequest struct {
state protoimpl.MessageState `protogen:"open.v1"` state protoimpl.MessageState `protogen:"open.v1"`
SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"` SessionId string `protobuf:"bytes,1,opt,name=session_id,json=sessionId,proto3" json:"session_id,omitempty"`
@@ -1,6 +1,6 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT. // Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions: // versions:
// - protoc-gen-go-grpc v1.6.1 // - protoc-gen-go-grpc v1.6.2
// - protoc v7.34.1 // - protoc v7.34.1
// source: mxaccess_gateway.proto // source: mxaccess_gateway.proto
@@ -50,6 +50,9 @@ type MxAccessGatewayClient interface {
// reconnect to seed Part 9 client state, or to reconcile alarms that may // reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can // have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set. // begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error) QueryActiveAlarms(ctx context.Context, in *QueryActiveAlarmsRequest, opts ...grpc.CallOption) (grpc.ServerStreamingClient[ActiveAlarmSnapshot], error)
} }
@@ -180,6 +183,9 @@ type MxAccessGatewayServer interface {
// reconnect to seed Part 9 client state, or to reconcile alarms that may // reconnect to seed Part 9 client state, or to reconcile alarms that may
// have been missed during a transport blip. Streamed so callers can // have been missed during a transport blip. Streamed so callers can
// begin processing without buffering the full set. // begin processing without buffering the full set.
// `QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
// snapshot to alarms whose `alarm_full_reference` starts with the given
// prefix; an empty prefix returns the full set.
QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error QueryActiveAlarms(*QueryActiveAlarmsRequest, grpc.ServerStreamingServer[ActiveAlarmSnapshot]) error
mustEmbedUnimplementedMxAccessGatewayServer() mustEmbedUnimplementedMxAccessGatewayServer()
} }
+23
View File
@@ -116,6 +116,29 @@ gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-deploy-time --endpoint localh
gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json" gradle :zb-mom-ww-mxgateway-cli:run --args="galaxy-discover --endpoint localhost:5000 --api-key-env MXGATEWAY_API_KEY --plaintext --json"
``` ```
### Browsing lazily
For UI trees or OPC UA bridges, use `browseChildren` to walk one level at a
time instead of loading the full hierarchy with `discoverHierarchy`. Pass a
default request for root objects; subsequent calls set `parentGobjectId`,
`parentTagName`, or `parentContainedPath`. Filter fields match
`DiscoverHierarchy`. Each response pairs `getChildrenList()` with
`getChildHasChildrenList()` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics. This snippet documents the API as it appears once
the Java client is regenerated on the Windows host.
```java
BrowseChildrenReply reply = galaxy.browseChildren(
BrowseChildrenRequest.newBuilder().build());
List<GalaxyObject> children = reply.getChildrenList();
List<Boolean> hasChildren = reply.getChildHasChildrenList();
for (int i = 0; i < children.size(); i++) {
System.out.printf("%s expand=%b%n", children.get(i).getTagName(), hasChildren.get(i));
}
```
### Watching deploy events ### Watching deploy events
`GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway `GalaxyRepository.WatchDeployEvents` is a server-streaming RPC: the gateway
+19
View File
@@ -138,6 +138,25 @@ The methods return native Python types (`bool`, `datetime | None`, and a
into the hierarchy without learning the underlying stub class. The into the hierarchy without learning the underlying stub class. The
service requires the `metadata:read` scope on the API key. service requires the `metadata:read` scope on the API key.
### Browsing lazily
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
time instead of loading the full hierarchy with `discover_hierarchy`. Pass an
empty request for root objects; subsequent calls set `parent_gobject_id`,
`parent_tag_name`, or `parent_contained_path`. Filter fields match
`DiscoverHierarchy`. Each response pairs `children` with `child_has_children` so
you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```python
from zb_mom_ww_mxgateway.generated import galaxy_repository_pb2 as galaxy_pb2
reply = await galaxy.browse_children(galaxy_pb2.BrowseChildrenRequest())
for child, has_children in zip(reply.children, reply.child_has_children):
print(child.tag_name, "expand=" + str(has_children))
```
### Watching deploy events ### Watching deploy events
`GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming `GalaxyRepositoryClient.watch_deploy_events` opens a server-streaming
@@ -26,7 +26,7 @@ from google.protobuf import timestamp_pb2 as google_dot_protobuf_dot_timestamp__
from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2 from google.protobuf import wrappers_pb2 as google_dot_protobuf_dot_wrappers__pb2
DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\x32\xcc\x03\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x42-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3') DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x17galaxy_repository.proto\x12\x14galaxy_repository.v1\x1a\x1fgoogle/protobuf/timestamp.proto\x1a\x1egoogle/protobuf/wrappers.proto\"\x17\n\x15TestConnectionRequest\"!\n\x13TestConnectionReply\x12\n\n\x02ok\x18\x01 \x01(\x08\"\x1a\n\x18GetLastDeployTimeRequest\"b\n\x16GetLastDeployTimeReply\x12\x0f\n\x07present\x18\x01 \x01(\x08\x12\x37\n\x13time_of_last_deploy\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\x87\x03\n\x18\x44iscoverHierarchyRequest\x12\x11\n\tpage_size\x18\x01 \x01(\x05\x12\x12\n\npage_token\x18\x02 \x01(\t\x12\x19\n\x0froot_gobject_id\x18\x03 \x01(\x05H\x00\x12\x17\n\rroot_tag_name\x18\x04 \x01(\tH\x00\x12\x1d\n\x13root_contained_path\x18\x05 \x01(\tH\x00\x12.\n\tmax_depth\x18\x06 \x01(\x0b\x32\x1b.google.protobuf.Int32Value\x12\x14\n\x0c\x63\x61tegory_ids\x18\x07 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x08 \x03(\t\x12\x15\n\rtag_name_glob\x18\t \x01(\t\x12\x1f\n\x12include_attributes\x18\n \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\x0b \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0c \x01(\x08\x42\x06\n\x04rootB\x15\n\x13_include_attributes\"\x82\x01\n\x16\x44iscoverHierarchyReply\x12\x33\n\x07objects\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x1a\n\x12total_object_count\x18\x03 \x01(\x05\"U\n\x18WatchDeployEventsRequest\x12\x39\n\x15last_seen_deploy_time\x18\x01 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\"\xdd\x01\n\x0b\x44\x65ployEvent\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12/\n\x0bobserved_at\x18\x02 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12\x37\n\x13time_of_last_deploy\x18\x03 \x01(\x0b\x32\x1a.google.protobuf.Timestamp\x12#\n\x1btime_of_last_deploy_present\x18\x04 \x01(\x08\x12\x14\n\x0cobject_count\x18\x05 \x01(\x05\x12\x17\n\x0f\x61ttribute_count\x18\x06 \x01(\x05\"\x93\x02\n\x0cGalaxyObject\x12\x12\n\ngobject_id\x18\x01 \x01(\x05\x12\x10\n\x08tag_name\x18\x02 \x01(\t\x12\x16\n\x0e\x63ontained_name\x18\x03 \x01(\t\x12\x13\n\x0b\x62rowse_name\x18\x04 \x01(\t\x12\x19\n\x11parent_gobject_id\x18\x05 \x01(\x05\x12\x0f\n\x07is_area\x18\x06 \x01(\x08\x12\x13\n\x0b\x63\x61tegory_id\x18\x07 \x01(\x05\x12\x1c\n\x14hosted_by_gobject_id\x18\x08 \x01(\x05\x12\x16\n\x0etemplate_chain\x18\t \x03(\t\x12\x39\n\nattributes\x18\n \x03(\x0b\x32%.galaxy_repository.v1.GalaxyAttribute\"\xa8\x02\n\x0fGalaxyAttribute\x12\x16\n\x0e\x61ttribute_name\x18\x01 \x01(\t\x12\x1a\n\x12\x66ull_tag_reference\x18\x02 \x01(\t\x12\x14\n\x0cmx_data_type\x18\x03 \x01(\x05\x12\x16\n\x0e\x64\x61ta_type_name\x18\x04 \x01(\t\x12\x10\n\x08is_array\x18\x05 \x01(\x08\x12\x17\n\x0f\x61rray_dimension\x18\x06 \x01(\x05\x12\x1f\n\x17\x61rray_dimension_present\x18\x07 \x01(\x08\x12\x1d\n\x15mx_attribute_category\x18\x08 \x01(\x05\x12\x1f\n\x17security_classification\x18\t \x01(\x05\x12\x15\n\ris_historized\x18\n \x01(\x08\x12\x10\n\x08is_alarm\x18\x0b \x01(\x08\"\xdc\x02\n\x15\x42rowseChildrenRequest\x12\x1b\n\x11parent_gobject_id\x18\x01 \x01(\x05H\x00\x12\x19\n\x0fparent_tag_name\x18\x02 \x01(\tH\x00\x12\x1f\n\x15parent_contained_path\x18\x03 \x01(\tH\x00\x12\x11\n\tpage_size\x18\x04 \x01(\x05\x12\x12\n\npage_token\x18\x05 \x01(\t\x12\x14\n\x0c\x63\x61tegory_ids\x18\x06 \x03(\x05\x12\x1f\n\x17template_chain_contains\x18\x07 \x03(\t\x12\x15\n\rtag_name_glob\x18\x08 \x01(\t\x12\x1f\n\x12include_attributes\x18\t \x01(\x08H\x01\x88\x01\x01\x12\x1a\n\x12\x61larm_bearing_only\x18\n \x01(\x08\x12\x17\n\x0fhistorized_only\x18\x0b \x01(\x08\x42\x08\n\x06parentB\x15\n\x13_include_attributes\"\xb3\x01\n\x13\x42rowseChildrenReply\x12\x34\n\x08\x63hildren\x18\x01 \x03(\x0b\x32\".galaxy_repository.v1.GalaxyObject\x12\x17\n\x0fnext_page_token\x18\x02 \x01(\t\x12\x19\n\x11total_child_count\x18\x03 \x01(\x05\x12\x1a\n\x12\x63hild_has_children\x18\x04 \x03(\x08\x12\x16\n\x0e\x63\x61\x63he_sequence\x18\x05 \x01(\x04\x32\xb6\x04\n\x10GalaxyRepository\x12h\n\x0eTestConnection\x12+.galaxy_repository.v1.TestConnectionRequest\x1a).galaxy_repository.v1.TestConnectionReply\x12q\n\x11GetLastDeployTime\x12..galaxy_repository.v1.GetLastDeployTimeRequest\x1a,.galaxy_repository.v1.GetLastDeployTimeReply\x12q\n\x11\x44iscoverHierarchy\x12..galaxy_repository.v1.DiscoverHierarchyRequest\x1a,.galaxy_repository.v1.DiscoverHierarchyReply\x12h\n\x11WatchDeployEvents\x12..galaxy_repository.v1.WatchDeployEventsRequest\x1a!.galaxy_repository.v1.DeployEvent0\x01\x12h\n\x0e\x42rowseChildren\x12+.galaxy_repository.v1.BrowseChildrenRequest\x1a).galaxy_repository.v1.BrowseChildrenReplyB-\xaa\x02*ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxyb\x06proto3')
_globals = globals() _globals = globals()
_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals)
@@ -54,6 +54,10 @@ if not _descriptor._USE_C_DESCRIPTORS:
_globals['_GALAXYOBJECT']._serialized_end=1416 _globals['_GALAXYOBJECT']._serialized_end=1416
_globals['_GALAXYATTRIBUTE']._serialized_start=1419 _globals['_GALAXYATTRIBUTE']._serialized_start=1419
_globals['_GALAXYATTRIBUTE']._serialized_end=1715 _globals['_GALAXYATTRIBUTE']._serialized_end=1715
_globals['_GALAXYREPOSITORY']._serialized_start=1718 _globals['_BROWSECHILDRENREQUEST']._serialized_start=1718
_globals['_GALAXYREPOSITORY']._serialized_end=2178 _globals['_BROWSECHILDRENREQUEST']._serialized_end=2066
_globals['_BROWSECHILDRENREPLY']._serialized_start=2069
_globals['_BROWSECHILDRENREPLY']._serialized_end=2248
_globals['_GALAXYREPOSITORY']._serialized_start=2251
_globals['_GALAXYREPOSITORY']._serialized_end=2817
# @@protoc_insertion_point(module_scope) # @@protoc_insertion_point(module_scope)
@@ -65,6 +65,11 @@ class GalaxyRepositoryStub(object):
request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString, request_serializer=galaxy__repository__pb2.WatchDeployEventsRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.DeployEvent.FromString, response_deserializer=galaxy__repository__pb2.DeployEvent.FromString,
_registered_method=True) _registered_method=True)
self.BrowseChildren = channel.unary_unary(
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
request_serializer=galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
response_deserializer=galaxy__repository__pb2.BrowseChildrenReply.FromString,
_registered_method=True)
class GalaxyRepositoryServicer(object): class GalaxyRepositoryServicer(object):
@@ -111,6 +116,16 @@ class GalaxyRepositoryServicer(object):
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!') raise NotImplementedError('Method not implemented!')
def BrowseChildren(self, request, context):
"""Returns the direct children of a parent object (or the root objects when
`parent` is unset). Designed for OPC UA-style lazy expand: clients walk
one level at a time instead of paging the full hierarchy. Filters mirror
DiscoverHierarchy exactly. Backed by the same shared hierarchy cache.
"""
context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!')
raise NotImplementedError('Method not implemented!')
def add_GalaxyRepositoryServicer_to_server(servicer, server): def add_GalaxyRepositoryServicer_to_server(servicer, server):
rpc_method_handlers = { rpc_method_handlers = {
@@ -134,6 +149,11 @@ def add_GalaxyRepositoryServicer_to_server(servicer, server):
request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString, request_deserializer=galaxy__repository__pb2.WatchDeployEventsRequest.FromString,
response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString, response_serializer=galaxy__repository__pb2.DeployEvent.SerializeToString,
), ),
'BrowseChildren': grpc.unary_unary_rpc_method_handler(
servicer.BrowseChildren,
request_deserializer=galaxy__repository__pb2.BrowseChildrenRequest.FromString,
response_serializer=galaxy__repository__pb2.BrowseChildrenReply.SerializeToString,
),
} }
generic_handler = grpc.method_handlers_generic_handler( generic_handler = grpc.method_handlers_generic_handler(
'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers) 'galaxy_repository.v1.GalaxyRepository', rpc_method_handlers)
@@ -263,3 +283,30 @@ class GalaxyRepository(object):
timeout, timeout,
metadata, metadata,
_registered_method=True) _registered_method=True)
@staticmethod
def BrowseChildren(request,
target,
options=(),
channel_credentials=None,
call_credentials=None,
insecure=False,
compression=None,
wait_for_ready=None,
timeout=None,
metadata=None):
return grpc.experimental.unary_unary(
request,
target,
'/galaxy_repository.v1.GalaxyRepository/BrowseChildren',
galaxy__repository__pb2.BrowseChildrenRequest.SerializeToString,
galaxy__repository__pb2.BrowseChildrenReply.FromString,
options,
channel_credentials,
insecure,
call_credentials,
compression,
wait_for_ready,
timeout,
metadata,
_registered_method=True)
@@ -135,6 +135,9 @@ class MxAccessGatewayServicer(object):
reconnect to seed Part 9 client state, or to reconcile alarms that may reconnect to seed Part 9 client state, or to reconcile alarms that may
have been missed during a transport blip. Streamed so callers can have been missed during a transport blip. Streamed so callers can
begin processing without buffering the full set. begin processing without buffering the full set.
`QueryActiveAlarmsRequest.alarm_filter_prefix` optionally narrows the
snapshot to alarms whose `alarm_full_reference` starts with the given
prefix; an empty prefix returns the full set.
""" """
context.set_code(grpc.StatusCode.UNIMPLEMENTED) context.set_code(grpc.StatusCode.UNIMPLEMENTED)
context.set_details('Method not implemented!') context.set_details('Method not implemented!')
+19
View File
@@ -138,6 +138,25 @@ cargo run -p mxgw-cli -- galaxy last-deploy-time --endpoint http://localhost:500
cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json cargo run -p mxgw-cli -- galaxy discover-hierarchy --endpoint http://localhost:5000 --api-key-env MXGATEWAY_API_KEY --json
``` ```
### Browsing lazily
For UI trees or OPC UA bridges, use `browse_children` to walk one level at a
time instead of paging the full hierarchy. Pass a default request for root
objects; subsequent calls set `parent_gobject_id`, `parent_tag_name`, or
`parent_contained_path`. Filter fields match `discover_hierarchy`. Each response
pairs `children` with `child_has_children` so you know which nodes to expand. See
[Galaxy Repository](../../docs/GalaxyRepository.md#browsechildren) for full
request and filter semantics.
```rust
use zb_mom_ww_mxgateway_client::generated::galaxy_repository::v1::BrowseChildrenRequest;
let reply = galaxy.browse_children(BrowseChildrenRequest::default()).await?.into_inner();
for (child, has_children) in reply.children.iter().zip(reply.child_has_children.iter()) {
println!("{} expand={}", child.tag_name, has_children);
}
```
### Watching deploy events ### Watching deploy events
`watch_deploy_events` opens the `WatchDeployEvents` server stream. The `watch_deploy_events` opens the `WatchDeployEvents` server stream. The
+11 -3
View File
@@ -234,9 +234,10 @@ mod tests {
GalaxyRepository, GalaxyRepositoryServer, GalaxyRepository, GalaxyRepositoryServer,
}; };
use crate::generated::galaxy_repository::v1::{ use crate::generated::galaxy_repository::v1::{
DeployEvent, DiscoverHierarchyReply, DiscoverHierarchyRequest, GalaxyAttribute, BrowseChildrenReply, BrowseChildrenRequest, DeployEvent, DiscoverHierarchyReply,
GalaxyObject, GetLastDeployTimeReply, GetLastDeployTimeRequest, TestConnectionReply, DiscoverHierarchyRequest, GalaxyAttribute, GalaxyObject, GetLastDeployTimeReply,
TestConnectionRequest, WatchDeployEventsRequest, GetLastDeployTimeRequest, TestConnectionReply, TestConnectionRequest,
WatchDeployEventsRequest,
}; };
type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>; type DeployEventTx = mpsc::Sender<Result<DeployEvent, Status>>;
@@ -306,6 +307,13 @@ mod tests {
})) }))
} }
async fn browse_children(
&self,
_request: Request<BrowseChildrenRequest>,
) -> Result<Response<BrowseChildrenReply>, Status> {
Err(Status::unimplemented("browse_children not implemented in FakeGalaxy"))
}
type WatchDeployEventsStream = type WatchDeployEventsStream =
Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>; Pin<Box<dyn tokio_stream::Stream<Item = Result<DeployEvent, Status>> + Send + 'static>>;
+13
View File
@@ -362,6 +362,19 @@ Dashboard access should require API-key-backed dashboard authentication with
is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is is enabled by default through `Dashboard:AllowAnonymousLocalhost`; the bypass is
limited to loopback requests. limited to loopback requests.
## Lazy Browse Is Wire-Only
Decision: the gateway continues to pull the full Galaxy hierarchy on each
deploy. `BrowseChildren` and the lazy dashboard render only avoid sending and
DOM-materializing the full tree — they do not push laziness into SQL or cache
loading.
Rationale: snapshot persistence and the dashboard summary both depend on a
fully-materialized cache. Lazy SQL would increase per-click latency on a
deployment-heavy box, multiply per-session SQL connections, and complicate the
cold-start path. Wire-side laziness solves the actual pain (oversized gRPC
replies and a heavy DOM) without disturbing the materialization model.
## Later Revisit Items ## Later Revisit Items
These are explicit post-v1 revisit items, not open blockers: These are explicit post-v1 revisit items, not open blockers:
+68 -4
View File
@@ -36,6 +36,7 @@ The service is defined in
| `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. | | `GetLastDeployTime` | Returns the cached `galaxy.time_of_last_deploy`. Served from the shared hierarchy cache; refreshed in the background. |
| `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). | | `DiscoverHierarchy` | Returns one page of the deployed hierarchy plus each returned object's attributes (configured and built-in — see [Built-in vs configured attributes](#built-in-vs-configured-attributes)). **Served from cache** — see [Hierarchy Cache](#hierarchy-cache). |
| `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). | | `WatchDeployEvents` | **Server-streaming.** The server emits the current state immediately on subscribe (so clients can bootstrap without waiting), then emits one event per detected deploy change. See [Deploy Notifications](#deploy-notifications). |
| `BrowseChildren` | Returns the direct children of one parent object (or root objects when `parent` is unset). Filters mirror `DiscoverHierarchy`. Includes a per-child `has_children` hint so UIs can draw expand triangles without an extra round trip. **Served from cache.** |
`DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size` `DiscoverHierarchy` is a paged unary RPC. The raw request accepts `page_size`
and `page_token`; the server defaults omitted page size to 1000 objects and and `page_token`; the server defaults omitted page size to 1000 objects and
@@ -52,6 +53,57 @@ alarm-only, historized-only, and `include_attributes = false` for a skeleton
tree. All filters are applied with AND semantics, and `total_object_count` tree. All filters are applied with AND semantics, and `total_object_count`
reports the post-filter count. reports the post-filter count.
### BrowseChildren
`BrowseChildren` is an OPC UA-style lazy expand: clients that walk one level at
a time — UI trees, OPC UA address-space bridges — call it instead of paging the
full hierarchy with `DiscoverHierarchy`.
**Parent selection.** The `parent` oneof accepts `parent_gobject_id`,
`parent_tag_name`, or `parent_contained_path`. An empty oneof returns root
objects — those whose `parent_gobject_id` is 0.
**Filters.** Category ids, template-chain substring, tag-name glob, alarm-only,
historized-only, and `include_attributes` all behave identically to
`DiscoverHierarchy` and are AND-combined. One important difference applies to
`alarm_bearing_only` and `historized_only`: an ancestor that does not itself
carry a matching attribute is still returned when one of its descendants does.
This is intentional — without it a UI tree cannot navigate to the matching
leaves. `DiscoverHierarchy`'s flat-list semantics filter out such intermediate
ancestors; `BrowseChildren` retains them so the path to each match remains
traversable.
**`child_has_children` hint.** The reply carries a boolean parallel to
`children`, set true when the child has at least one matching descendant under
the same filter set. UIs can use this to decide whether to draw an expand
triangle without issuing a follow-up `BrowseChildren` call. Because the hint is
computed against the *filtered* descendant set, a branch that contains no
matching objects gets `false`, not `true`.
**Paging.** Default page size is 500; the server caps any requested size at
5000. Page tokens encode `(cache_sequence, parent_id, filter_signature,
offset)`. A token from a different cache generation or a different filter set
returns `InvalidArgument`. The error messages reference "DiscoverHierarchy
page_token" because `BrowseChildren` reuses the same encoding and validation
path — if you see that wording in a `BrowseChildren` context it is expected.
**Errors.**
| Condition | Status code |
|-----------|-------------|
| Unknown parent | `NotFound` |
| First load not yet complete after 5 s | `Unavailable` |
| Stale or filter-mismatched page token | `InvalidArgument` |
| Missing `metadata:read` scope | `PermissionDenied` |
| No API key | `Unauthenticated` |
**Authorization.** Same `metadata:read` scope as the other Galaxy RPCs.
`browse_subtrees` API-key constraints intersect with the result set.
**Sort order.** Areas first, then `OrdinalIgnoreCase` by display name
(`browse_name``contained_name``tag_name`). Matches the dashboard tree so
server and dashboard views are consistent.
## Hierarchy Cache ## Hierarchy Cache
The gateway holds a single shared `IGalaxyHierarchyCache` The gateway holds a single shared `IGalaxyHierarchyCache`
@@ -271,9 +323,13 @@ fields cannot express null. Use it to distinguish "no dimension reported" from
```text ```text
gRPC client(s) gRPC client(s)
-> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/) -> GalaxyRepositoryGrpcService (src/ZB.MOM.WW.MxGateway.Server/Grpc/)
DiscoverHierarchy, GetLastDeployTime -> IGalaxyHierarchyCache.Current DiscoverHierarchy, GetLastDeployTime, BrowseChildren -> IGalaxyHierarchyCache.Current
WatchDeployEvents -> IGalaxyDeployNotifier WatchDeployEvents -> IGalaxyDeployNotifier
TestConnection -> GalaxyRepository (direct SQL) TestConnection -> GalaxyRepository (direct SQL)
Dashboard (Blazor)
-> IDashboardBrowseService (DashboardBrowseService)
-> GalaxyBrowseProjector over IGalaxyHierarchyCache.Current
GalaxyHierarchyRefreshService (BackgroundService) GalaxyHierarchyRefreshService (BackgroundService)
-> IGalaxyHierarchyCache.RefreshAsync -> IGalaxyHierarchyCache.RefreshAsync
@@ -309,9 +365,17 @@ Component breakdown:
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to (`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyProtoMapper.cs`) converts row models to
proto messages. Used by the cache during refresh to materialize the reply proto messages. Used by the cache during refresh to materialize the reply
once. once.
- `GalaxyBrowseProjector`
(`src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyBrowseProjector.cs`) projects one level
of children out of an immutable cache entry. Memoizes the filtered child list
per cache-entry instance so repeated paging is an O(pageSize) slice rather than an
O(siblings) filter scan. The memo is keyed on the cache entry reference, so a new
entry from the background refresh makes the stale memo unreachable and it is
collected with it. `DashboardBrowseService` wraps this projector to drive the
dashboard's lazy-expand tree.
- `GalaxyRepositoryGrpcService` - `GalaxyRepositoryGrpcService`
(`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements (`src/ZB.MOM.WW.MxGateway.Server/Grpc/GalaxyRepositoryGrpcService.cs`) implements
the four RPCs. the five RPCs.
## Configuration ## Configuration
+4 -2
View File
@@ -102,12 +102,14 @@ message BrowseChildrenReply {
| Condition | Status | | Condition | Status |
|---|---| |---|---|
| Unknown `parent_gobject_id` / `parent_tag_name` / `parent_contained_path` | `NotFound` | | Unknown `parent_gobject_id` / `parent_tag_name` / `parent_contained_path` | `NotFound` |
| Stale `page_token` (cache deployed forward) | `FailedPrecondition`; current `cache_sequence` in trailers | | Stale `page_token` (cache deployed forward) | `InvalidArgument`; current `cache_sequence` in trailers |
| Filter set differs between pages of the same token | `InvalidArgument` | | Filter set differs between pages of the same token | `InvalidArgument` |
| First load not complete within 5s | `Unavailable` | | First load not complete within 5s | `Unavailable` |
| API key missing `metadata:read` scope | `PermissionDenied` | | API key missing `metadata:read` scope | `PermissionDenied` |
| No API key | `Unauthenticated` | | No API key | `Unauthenticated` |
Stale and filter-changed page tokens both surface as `InvalidArgument` — same contract as `DiscoverHierarchy`, since `BrowseChildren` reuses the same token encoding (`sequence:filter-signature:offset`).
`browse_subtrees` API-key constraints intersect with the request as today. `browse_subtrees` API-key constraints intersect with the request as today.
## Server projection ## Server projection
@@ -224,7 +226,7 @@ Unit tests (no live MXAccess / Galaxy required):
- Ordering matches `DashboardBrowseTreeBuilder` byte-for-byte. - Ordering matches `DashboardBrowseTreeBuilder` byte-for-byte.
- Sibling pagination across multiple pages. - Sibling pagination across multiple pages.
- Page-token round trip (serialize → deserialize → same offset). - Page-token round trip (serialize → deserialize → same offset).
- Stale `page_token``FailedPrecondition`. - Stale `page_token``InvalidArgument`.
- Unknown parent → `NotFound`. - Unknown parent → `NotFound`.
- Filter change between pages of the same token → `InvalidArgument`. - Filter change between pages of the same token → `InvalidArgument`.
- `GalaxyRepositoryGrpcServiceTests` — new `BrowseChildren` happy path, - `GalaxyRepositoryGrpcServiceTests` — new `BrowseChildren` happy path,
+3
View File
@@ -65,6 +65,9 @@ Detailed follow-up docs:
- `docs/GalaxyRepository.md` covers the read-only Galaxy Repository browse - `docs/GalaxyRepository.md` covers the read-only Galaxy Repository browse
RPCs that let clients enumerate the deployed object hierarchy and dynamic RPCs that let clients enumerate the deployed object hierarchy and dynamic
attributes before subscribing via the MXAccess gateway service. attributes before subscribing via the MXAccess gateway service.
`DiscoverHierarchy` returns paged flat results; `BrowseChildren` returns
the direct children of one parent for OPC UA-style lazy tree walks — see
[Galaxy Repository](docs/GalaxyRepository.md#browsechildren).
Implementation style guides: Implementation style guides:
@@ -2,6 +2,8 @@
@implements IAsyncDisposable @implements IAsyncDisposable
@inject IGalaxyHierarchyCache GalaxyCache @inject IGalaxyHierarchyCache GalaxyCache
@inject IDashboardLiveDataService LiveData @inject IDashboardLiveDataService LiveData
@inject IDashboardBrowseService BrowseService
@inject IGalaxyDeployNotifier DeployNotifier
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy @using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
@using ZB.MOM.WW.MxGateway.Server.Galaxy @using ZB.MOM.WW.MxGateway.Server.Galaxy
@@ -71,12 +73,18 @@
} }
else else
{ {
@if (!string.IsNullOrEmpty(_staleBanner))
{
<div class="alert alert-info browse-stale-banner" role="status"
@onclick="ClearStaleBanner">@_staleBanner</div>
}
<div class="browse-tree"> <div class="browse-tree">
@foreach (DashboardBrowseNode root in _roots) @foreach (DashboardBrowseNode root in _roots)
{ {
<BrowseTreeNodeView Node="root" <BrowseTreeNodeView Node="root"
OnAddTag="AddTagAsync" OnAddTag="AddTagAsync"
OnTagContextMenu="OnTagContextMenu" /> OnTagContextMenu="OnTagContextMenu"
OnLoadChildren="LoadChildrenAsync" />
} }
</div> </div>
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div> <div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
@@ -186,7 +194,11 @@
@code { @code {
private const int SearchResultLimit = 300; private const int SearchResultLimit = 300;
private IReadOnlyList<DashboardBrowseNode> _roots = []; private List<DashboardBrowseNode> _roots = [];
private ulong _cacheSequence;
private string? _staleBanner;
private CancellationTokenSource _deployCts = new();
private Task? _deployTask;
private string _search = string.Empty; private string _search = string.Empty;
private IReadOnlyList<GalaxyAttribute> _searchMatches = []; private IReadOnlyList<GalaxyAttribute> _searchMatches = [];
private readonly List<string> _subscribed = []; private readonly List<string> _subscribed = [];
@@ -210,8 +222,63 @@
/// <inheritdoc /> /// <inheritdoc />
protected override void OnInitialized() protected override void OnInitialized()
{ {
_roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects); BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
_roots = [.. roots.Nodes];
_cacheSequence = roots.CacheSequence;
_pollTask = PollLoopAsync(); _pollTask = PollLoopAsync();
_deployTask = SubscribeToDeployEventsAsync();
}
private async Task LoadChildrenAsync(DashboardBrowseNode node)
{
BrowseLevelResult result = BrowseService.GetChildren(node.Object.GobjectId, new BrowseFilterArgs());
if (!string.IsNullOrEmpty(result.Error))
{
throw new InvalidOperationException(result.Error);
}
node.Children.Clear();
foreach (DashboardBrowseNode child in result.Nodes)
{
node.Children.Add(child);
}
// First expand interaction also dismisses the stale banner — the user
// is clearly engaging with the tree, no need to keep nagging.
_staleBanner = null;
await InvokeAsync(StateHasChanged);
}
private async Task SubscribeToDeployEventsAsync()
{
try
{
await foreach (GalaxyDeployEventInfo info in DeployNotifier
.SubscribeAsync(_deployCts.Token)
.ConfigureAwait(false))
{
// First Latest replay echoes the sequence we already projected
// from — skip those to avoid a spurious "redeployed" banner.
if (info.Sequence == 0 || (ulong)info.Sequence == _cacheSequence)
{
continue;
}
BrowseLevelResult roots = BrowseService.GetRoots(new BrowseFilterArgs());
_roots = [.. roots.Nodes];
_cacheSequence = roots.CacheSequence;
_staleBanner = "Galaxy redeployed — tree refreshed.";
await InvokeAsync(StateHasChanged);
}
}
catch (OperationCanceledException)
{
}
}
private void ClearStaleBanner()
{
_staleBanner = null;
} }
private string HeaderLine() private string HeaderLine()
@@ -405,6 +472,7 @@
public async ValueTask DisposeAsync() public async ValueTask DisposeAsync()
{ {
await _cts.CancelAsync(); await _cts.CancelAsync();
await _deployCts.CancelAsync();
if (_pollTask is not null) if (_pollTask is not null)
{ {
try try
@@ -415,8 +483,19 @@
{ {
} }
} }
if (_deployTask is not null)
{
try
{
await _deployTask;
}
catch (OperationCanceledException)
{
}
}
_cts.Dispose(); _cts.Dispose();
_deployCts.Dispose();
GC.SuppressFinalize(this); GC.SuppressFinalize(this);
} }
} }
@@ -2,15 +2,21 @@
@* @*
Recursive Browse hierarchy node. Renders one Galaxy object, its child Recursive Browse hierarchy node. Renders one Galaxy object, its child
objects (recursively), and its attributes as right-clickable tag rows. objects (recursively, lazy-loaded on first expand), and its attributes as
Expansion state is local; children render only while expanded. right-clickable tag rows. Expansion state is local; children render only
while expanded.
The expand triangle is shown whenever the server's child_has_children
projector hint is set (HasChildrenHint), even before children have been
loaded — clicking it triggers OnLoadChildren so the parent page can fill
in Node.Children, then the view re-renders.
*@ *@
<div class="tree-node"> <div class="tree-node">
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")"> <div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
@if (Node.HasChildren) @if (ShowToggle())
{ {
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle"> <button type="button" class="tree-toggle" @onclick="ToggleAsync" aria-label="Toggle">
@(_expanded ? "▾" : "▸") @(_expanded ? "▾" : "▸")
</button> </button>
} }
@@ -18,7 +24,7 @@
{ {
<span class="tree-toggle tree-toggle-empty"></span> <span class="tree-toggle tree-toggle-empty"></span>
} }
<span class="tree-label" @onclick="Toggle"> <span class="tree-label" @onclick="ToggleAsync">
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span> <span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
<span class="tree-name">@Node.DisplayName</span> <span class="tree-name">@Node.DisplayName</span>
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName) @if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
@@ -31,9 +37,27 @@
@if (_expanded) @if (_expanded)
{ {
<div class="tree-children"> <div class="tree-children">
@if (Node.LoadState == BrowseLoadState.Loading)
{
<div class="tree-load-status text-secondary">
<span class="tree-toggle tree-toggle-empty"></span>
<span>⌛ Loading…</span>
</div>
}
else if (Node.LoadState == BrowseLoadState.Error)
{
<div class="tree-load-status text-danger">
<span class="tree-toggle tree-toggle-empty"></span>
<span>Failed to load: @Node.LoadError</span>
</div>
}
@foreach (DashboardBrowseNode child in Node.Children) @foreach (DashboardBrowseNode child in Node.Children)
{ {
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" /> <BrowseTreeNodeView Node="child"
OnAddTag="OnAddTag"
OnTagContextMenu="OnTagContextMenu"
OnLoadChildren="OnLoadChildren" />
} }
@foreach (GalaxyAttribute attr in Node.Attributes) @foreach (GalaxyAttribute attr in Node.Attributes)
{ {
@@ -75,13 +99,52 @@
[Parameter] [Parameter]
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; } public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
/// <summary>
/// Invoked on first expand when the projector hint says this node has children
/// but they have not been fetched yet. The callback is expected to populate
/// <see cref="DashboardBrowseNode.Children"/> on the node it receives and then
/// trigger a re-render.
/// </summary>
[Parameter]
public Func<DashboardBrowseNode, Task>? OnLoadChildren { get; set; }
private bool _expanded; private bool _expanded;
private void Toggle() // The triangle is shown whenever the projector says children exist (even
// pre-load), or attributes are already present, or already-loaded children
// are sitting on the node.
private bool ShowToggle()
{ {
if (Node.HasChildren) return Node.HasChildrenHint
|| Node.Attributes.Count > 0
|| Node.Children.Count > 0;
}
private async Task ToggleAsync()
{
if (!ShowToggle())
{ {
_expanded = !_expanded; return;
}
_expanded = !_expanded;
if (_expanded
&& Node.HasChildrenHint
&& Node.LoadState == BrowseLoadState.NotLoaded
&& OnLoadChildren is not null)
{
Node.LoadState = BrowseLoadState.Loading;
try
{
await OnLoadChildren(Node);
Node.LoadState = BrowseLoadState.Loaded;
}
catch (Exception ex)
{
Node.LoadState = BrowseLoadState.Error;
Node.LoadError = ex.Message;
}
} }
} }
@@ -29,6 +29,33 @@ public sealed class DashboardBrowseNode
/// <summary>True when the node has child objects or attributes to expand.</summary> /// <summary>True when the node has child objects or attributes to expand.</summary>
public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0; public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0;
/// <summary>Whether this node has at least one matching descendant, per the
/// server's <c>child_has_children</c> projector hint. Controls whether the UI
/// shows an expand triangle before children have actually loaded.</summary>
public bool HasChildrenHint { get; init; }
/// <summary>The lazy-load state for this node's children.</summary>
public BrowseLoadState LoadState { get; set; } = BrowseLoadState.NotLoaded;
/// <summary>Short error string if the last load attempt failed; null otherwise.</summary>
public string? LoadError { get; set; }
}
/// <summary>Lazy-load lifecycle of a browse node's children.</summary>
public enum BrowseLoadState
{
/// <summary>Children have not been requested yet.</summary>
NotLoaded,
/// <summary>A load is in progress.</summary>
Loading,
/// <summary>Children have been loaded into <see cref="DashboardBrowseNode.Children"/>.</summary>
Loaded,
/// <summary>The last load attempt failed; see <see cref="DashboardBrowseNode.LoadError"/>.</summary>
Error,
} }
/// <summary> /// <summary>
@@ -0,0 +1,82 @@
using Grpc.Core;
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Default <see cref="IDashboardBrowseService"/>. Delegates to
/// <see cref="GalaxyBrowseProjector"/> via the shared
/// <see cref="IGalaxyHierarchyCache"/>; no SQL hop, no gRPC self-call. Translates
/// the projector's <see cref="RpcException"/> on unknown parent into a friendly
/// <see cref="BrowseLevelResult.Error"/> so the Blazor circuit does not see an
/// unhandled exception.
/// </summary>
public sealed class DashboardBrowseService(IGalaxyHierarchyCache cache) : IDashboardBrowseService
{
/// <inheritdoc />
public ulong CurrentCacheSequence => (ulong)cache.Current.Sequence;
/// <inheritdoc />
public BrowseLevelResult GetRoots(BrowseFilterArgs filter)
=> ProjectLevel(parentId: null, filter);
/// <inheritdoc />
public BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter)
=> ProjectLevel(parentId: parentGobjectId, filter);
private BrowseLevelResult ProjectLevel(int? parentId, BrowseFilterArgs filter)
{
ArgumentNullException.ThrowIfNull(filter);
GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData)
{
return new BrowseLevelResult(
Array.Empty<DashboardBrowseNode>(),
0,
(ulong)entry.Sequence,
Error: "Galaxy hierarchy is not loaded yet.");
}
BrowseChildrenRequest request = new()
{
TagNameGlob = filter.TagNameGlob ?? string.Empty,
AlarmBearingOnly = filter.AlarmBearingOnly,
HistorizedOnly = filter.HistorizedOnly,
};
if (parentId is int pid)
{
request.ParentGobjectId = pid;
}
try
{
GalaxyBrowseChildrenResult result = GalaxyBrowseProjector.ProjectChildren(
entry,
request,
browseSubtreeGlobs: null,
offset: 0,
pageSize: int.MaxValue);
List<DashboardBrowseNode> nodes = new(result.Children.Count);
for (int i = 0; i < result.Children.Count; i++)
{
nodes.Add(new DashboardBrowseNode
{
Object = result.Children[i],
HasChildrenHint = result.ChildHasChildren[i],
});
}
return new BrowseLevelResult(nodes, result.TotalChildCount, (ulong)entry.Sequence);
}
catch (RpcException ex) when (ex.StatusCode == StatusCode.NotFound)
{
return new BrowseLevelResult(
Array.Empty<DashboardBrowseNode>(),
0,
(ulong)entry.Sequence,
Error: ex.Status.Detail);
}
}
}
@@ -25,6 +25,7 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>(); services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
services.AddSingleton<HubTokenService>(); services.AddSingleton<HubTokenService>();
services.AddScoped<Hubs.DashboardHubConnectionFactory>(); services.AddScoped<Hubs.DashboardHubConnectionFactory>();
services.AddScoped<IDashboardBrowseService, DashboardBrowseService>();
services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>(); services.AddSingleton<Hubs.IDashboardEventBroadcaster, Hubs.DashboardEventBroadcaster>();
services.AddHostedService<Hubs.DashboardSnapshotPublisher>(); services.AddHostedService<Hubs.DashboardSnapshotPublisher>();
services.AddHostedService<Hubs.AlarmsHubPublisher>(); services.AddHostedService<Hubs.AlarmsHubPublisher>();
@@ -0,0 +1,44 @@
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// In-process facade over <see cref="GalaxyBrowseProjector"/> for the dashboard's
/// BrowsePage. Provides one-level-at-a-time browse without going through the
/// gRPC stack. Backed by the same shared <see cref="IGalaxyHierarchyCache"/> the
/// gRPC service uses, so dashboard and external clients render identical results.
/// </summary>
public interface IDashboardBrowseService
{
/// <summary>Returns root browse nodes (objects with no parent).</summary>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
/// <summary>Returns the direct children of the given parent gobject id.</summary>
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
/// <param name="filter">Filter arguments forwarded to the projector.</param>
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
ulong CurrentCacheSequence { get; }
}
/// <summary>Filter arguments forwarded into the projector.</summary>
/// <param name="TagNameGlob">Optional tag-name glob filter (case-insensitive).</param>
/// <param name="AlarmBearingOnly">When true, only return objects with at least one alarm-bearing attribute.</param>
/// <param name="HistorizedOnly">When true, only return objects with at least one historized attribute.</param>
public sealed record BrowseFilterArgs(
string? TagNameGlob = null,
bool AlarmBearingOnly = false,
bool HistorizedOnly = false);
/// <summary>One level of browse data plus the cache sequence it was projected from.</summary>
/// <param name="Nodes">The direct-child nodes for the requested parent (or roots when no parent given).</param>
/// <param name="TotalCount">Total matching sibling count, post-filter.</param>
/// <param name="CacheSequence">The cache entry sequence this result was projected from.</param>
/// <param name="Error">Friendly error string if the projection failed; null on success.</param>
public sealed record BrowseLevelResult(
IReadOnlyList<DashboardBrowseNode> Nodes,
int TotalCount,
ulong CacheSequence,
string? Error = null);
@@ -0,0 +1,156 @@
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Galaxy;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Coverage for <see cref="DashboardBrowseService"/> — the in-process facade the
/// Blazor BrowsePage uses to walk the Galaxy hierarchy one level at a time. The
/// service must surface the projector's <c>child_has_children</c> hint, expose the
/// current cache sequence, and translate the projector's
/// <see cref="Grpc.Core.RpcException"/> on unknown parent into a friendly error
/// rather than propagating it into the Blazor circuit.
/// </summary>
public sealed class DashboardBrowseServiceTests
{
/// <summary>Verifies that <see cref="DashboardBrowseService.GetRoots"/> returns root-level
/// objects with the <c>HasChildrenHint</c> projector bit set, and reports the cache
/// sequence of the entry it projected from.</summary>
[Fact]
public void GetRoots_ReturnsRootObjects_WithHasChildrenHint()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetRoots(new BrowseFilterArgs());
Assert.Single(result.Nodes);
Assert.Equal("Plant", result.Nodes[0].Object.TagName);
Assert.True(result.Nodes[0].HasChildrenHint);
Assert.Equal(11UL, result.CacheSequence);
Assert.Null(result.Error);
}
/// <summary>Verifies that <see cref="DashboardBrowseService.GetChildren"/> returns the
/// direct children of the requested parent and that leaf nodes report
/// <c>HasChildrenHint == false</c>.</summary>
[Fact]
public void GetChildren_ByParentGobjectId_ReturnsDirectChildren()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 1, new BrowseFilterArgs());
Assert.Single(result.Nodes);
Assert.Equal("Mixer_001", result.Nodes[0].Object.TagName);
Assert.False(result.Nodes[0].HasChildrenHint);
Assert.Null(result.Error);
}
/// <summary>Verifies that an unknown parent id does not surface the projector's
/// <see cref="Grpc.Core.RpcException"/> — the service catches the NotFound and
/// returns an empty <see cref="BrowseLevelResult"/> with the error string set.</summary>
[Fact]
public void GetChildren_UnknownParent_ReturnsEmptyResultWithErrorFlag()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 999, new BrowseFilterArgs());
Assert.Empty(result.Nodes);
Assert.NotNull(result.Error);
Assert.False(string.IsNullOrEmpty(result.Error));
}
/// <summary>Verifies that swapping the cache's <c>Current</c> entry (as the refresh loop
/// does after a deploy bump) causes subsequent queries to observe the new sequence.</summary>
[Fact]
public void CacheSequence_AdvancesAfterRefresh_NewQueriesReflectIt()
{
StubGalaxyHierarchyCache cache = new(CreateEntry(CreateObjects(), sequence: 11));
DashboardBrowseService service = new(cache);
BrowseLevelResult before = service.GetRoots(new BrowseFilterArgs());
Assert.Equal(11UL, before.CacheSequence);
cache.Current = CreateEntry(CreateObjects(), sequence: 12);
BrowseLevelResult after = service.GetRoots(new BrowseFilterArgs());
Assert.Equal(12UL, after.CacheSequence);
}
/// <summary>Verifies that <see cref="DashboardBrowseService.GetChildren"/> returns an
/// empty node list with a non-empty <c>Error</c> when the cache has not yet loaded
/// (i.e. <see cref="GalaxyHierarchyCacheEntry.HasData"/> is false).</summary>
[Fact]
public void GetChildren_CacheNotLoaded_ReturnsErrorResult()
{
StubGalaxyHierarchyCache cache = new(GalaxyHierarchyCacheEntry.Empty);
DashboardBrowseService service = new(cache);
BrowseLevelResult result = service.GetChildren(parentGobjectId: 1, new BrowseFilterArgs());
Assert.Empty(result.Nodes);
Assert.False(string.IsNullOrEmpty(result.Error));
}
private static IReadOnlyList<GalaxyObject> CreateObjects()
{
// Fixture: an Area "Plant" (id 1, parent 0, IsArea=true) containing one
// Instance "Mixer_001" (id 2, parent 1). Both with no attributes — the
// service is exercised through the projector, which only needs id /
// parent / IsArea / display name to build the level slice.
return
[
new GalaxyObject
{
GobjectId = 1,
ParentGobjectId = 0,
TagName = "Plant",
BrowseName = "Plant",
IsArea = true,
},
new GalaxyObject
{
GobjectId = 2,
ParentGobjectId = 1,
TagName = "Mixer_001",
BrowseName = "Mixer_001",
IsArea = false,
},
];
}
private static GalaxyHierarchyCacheEntry CreateEntry(IReadOnlyList<GalaxyObject> objects, long sequence)
{
return GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = sequence,
LastSuccessAt = DateTimeOffset.UtcNow,
Objects = objects,
Index = GalaxyHierarchyIndex.Build(objects),
DashboardSummary = DashboardGalaxySummary.Unknown with
{
Status = DashboardGalaxyStatus.Healthy,
ObjectCount = objects.Count,
},
ObjectCount = objects.Count,
};
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry initial) : IGalaxyHierarchyCache
{
/// <summary>Mutable so a single test can swap the entry mid-flight.</summary>
public GalaxyHierarchyCacheEntry Current { get; set; } = initial;
/// <inheritdoc />
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
/// <inheritdoc />
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}