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