Add Galaxy repository API and clients

This commit is contained in:
Joseph Doherty
2026-04-29 07:27:00 -04:00
parent 047d875fe6
commit 133c83029b
103 changed files with 22788 additions and 39 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,307 @@
// <auto-generated>
// Generated by the protocol buffer compiler. DO NOT EDIT!
// source: galaxy_repository.proto
// </auto-generated>
#pragma warning disable 0414, 1591, 8981, 0612
#region Designer generated code
using grpc = global::Grpc.Core;
namespace MxGateway.Contracts.Proto.Galaxy {
/// <summary>
/// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
/// database). Lets clients enumerate the deployed object hierarchy and each
/// object's dynamic attributes so they know what tag references to subscribe
/// to via the MxAccessGateway service.
/// </summary>
public static partial class GalaxyRepository
{
static readonly string __ServiceName = "galaxy_repository.v1.GalaxyRepository";
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static void __Helper_SerializeMessage(global::Google.Protobuf.IMessage message, grpc::SerializationContext context)
{
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
if (message is global::Google.Protobuf.IBufferMessage)
{
context.SetPayloadLength(message.CalculateSize());
global::Google.Protobuf.MessageExtensions.WriteTo(message, context.GetBufferWriter());
context.Complete();
return;
}
#endif
context.Complete(global::Google.Protobuf.MessageExtensions.ToByteArray(message));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static class __Helper_MessageCache<T>
{
public static readonly bool IsBufferMessage = global::System.Reflection.IntrospectionExtensions.GetTypeInfo(typeof(global::Google.Protobuf.IBufferMessage)).IsAssignableFrom(typeof(T));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static T __Helper_DeserializeMessage<T>(grpc::DeserializationContext context, global::Google.Protobuf.MessageParser<T> parser) where T : global::Google.Protobuf.IMessage<T>
{
#if !GRPC_DISABLE_PROTOBUF_BUFFER_SERIALIZATION
if (__Helper_MessageCache<T>.IsBufferMessage)
{
return parser.ParseFrom(context.PayloadAsReadOnlySequence());
}
#endif
return parser.ParseFrom(context.PayloadAsNewBuffer());
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest> __Marshaller_galaxy_repository_v1_TestConnectionRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> __Marshaller_galaxy_repository_v1_TestConnectionReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest> __Marshaller_galaxy_repository_v1_GetLastDeployTimeRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> __Marshaller_galaxy_repository_v1_GetLastDeployTimeReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest> __Marshaller_galaxy_repository_v1_DiscoverHierarchyRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> __Marshaller_galaxy_repository_v1_DiscoverHierarchyReply = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest> __Marshaller_galaxy_repository_v1_WatchDeployEventsRequest = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Marshaller<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> __Marshaller_galaxy_repository_v1_DeployEvent = grpc::Marshallers.Create(__Helper_SerializeMessage, context => __Helper_DeserializeMessage(context, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent.Parser));
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> __Method_TestConnection = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply>(
grpc::MethodType.Unary,
__ServiceName,
"TestConnection",
__Marshaller_galaxy_repository_v1_TestConnectionRequest,
__Marshaller_galaxy_repository_v1_TestConnectionReply);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> __Method_GetLastDeployTime = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply>(
grpc::MethodType.Unary,
__ServiceName,
"GetLastDeployTime",
__Marshaller_galaxy_repository_v1_GetLastDeployTimeRequest,
__Marshaller_galaxy_repository_v1_GetLastDeployTimeReply);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> __Method_DiscoverHierarchy = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply>(
grpc::MethodType.Unary,
__ServiceName,
"DiscoverHierarchy",
__Marshaller_galaxy_repository_v1_DiscoverHierarchyRequest,
__Marshaller_galaxy_repository_v1_DiscoverHierarchyReply);
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
static readonly grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> __Method_WatchDeployEvents = new grpc::Method<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent>(
grpc::MethodType.ServerStreaming,
__ServiceName,
"WatchDeployEvents",
__Marshaller_galaxy_repository_v1_WatchDeployEventsRequest,
__Marshaller_galaxy_repository_v1_DeployEvent);
/// <summary>Service descriptor</summary>
public static global::Google.Protobuf.Reflection.ServiceDescriptor Descriptor
{
get { return global::MxGateway.Contracts.Proto.Galaxy.GalaxyRepositoryReflection.Descriptor.Services[0]; }
}
/// <summary>Base class for server-side implementations of GalaxyRepository</summary>
[grpc::BindServiceMethod(typeof(GalaxyRepository), "BindService")]
public abstract partial class GalaxyRepositoryBase
{
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
/// <summary>
/// Server-stream of deploy events. The server emits the current state immediately
/// on subscribe (so clients can bootstrap their cache without waiting for the next
/// deploy), then emits one event each time the gateway's hierarchy cache observes
/// a new galaxy.time_of_last_deploy. The sequence field is monotonically
/// increasing per server start; gaps indicate the per-subscriber buffer dropped
/// older events because the client was too slow.
/// </summary>
/// <param name="request">The request received from the client.</param>
/// <param name="responseStream">Used for sending responses back to the client.</param>
/// <param name="context">The context of the server-side call handler being invoked.</param>
/// <returns>A task indicating completion of the handler.</returns>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::System.Threading.Tasks.Task WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::IServerStreamWriter<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> responseStream, grpc::ServerCallContext context)
{
throw new grpc::RpcException(new grpc::Status(grpc::StatusCode.Unimplemented, ""));
}
}
/// <summary>Client for GalaxyRepository</summary>
public partial class GalaxyRepositoryClient : grpc::ClientBase<GalaxyRepositoryClient>
{
/// <summary>Creates a new client for GalaxyRepository</summary>
/// <param name="channel">The channel to use to make remote calls.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public GalaxyRepositoryClient(grpc::ChannelBase channel) : base(channel)
{
}
/// <summary>Creates a new client for GalaxyRepository that uses a custom <c>CallInvoker</c>.</summary>
/// <param name="callInvoker">The callInvoker to use to make remote calls.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public GalaxyRepositoryClient(grpc::CallInvoker callInvoker) : base(callInvoker)
{
}
/// <summary>Protected parameterless constructor to allow creation of test doubles.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected GalaxyRepositoryClient() : base()
{
}
/// <summary>Protected constructor to allow creation of configured clients.</summary>
/// <param name="configuration">The client configuration.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected GalaxyRepositoryClient(ClientBaseConfiguration configuration) : base(configuration)
{
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return TestConnection(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply TestConnection(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_TestConnection, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> TestConnectionAsync(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return TestConnectionAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply> TestConnectionAsync(global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_TestConnection, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return GetLastDeployTime(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply GetLastDeployTime(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_GetLastDeployTime, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> GetLastDeployTimeAsync(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return GetLastDeployTimeAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply> GetLastDeployTimeAsync(global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_GetLastDeployTime, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return DiscoverHierarchy(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply DiscoverHierarchy(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options)
{
return CallInvoker.BlockingUnaryCall(__Method_DiscoverHierarchy, null, options, request);
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> DiscoverHierarchyAsync(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return DiscoverHierarchyAsync(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncUnaryCall<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply> DiscoverHierarchyAsync(global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncUnaryCall(__Method_DiscoverHierarchy, null, options, request);
}
/// <summary>
/// Server-stream of deploy events. The server emits the current state immediately
/// on subscribe (so clients can bootstrap their cache without waiting for the next
/// deploy), then emits one event each time the gateway's hierarchy cache observes
/// a new galaxy.time_of_last_deploy. The sequence field is monotonically
/// increasing per server start; gaps indicate the per-subscriber buffer dropped
/// older events because the client was too slow.
/// </summary>
/// <param name="request">The request to send to the server.</param>
/// <param name="headers">The initial metadata to send with the call. This parameter is optional.</param>
/// <param name="deadline">An optional deadline for the call. The call will be cancelled if deadline is hit.</param>
/// <param name="cancellationToken">An optional token for canceling the call.</param>
/// <returns>The call object.</returns>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::Metadata headers = null, global::System.DateTime? deadline = null, global::System.Threading.CancellationToken cancellationToken = default(global::System.Threading.CancellationToken))
{
return WatchDeployEvents(request, new grpc::CallOptions(headers, deadline, cancellationToken));
}
/// <summary>
/// Server-stream of deploy events. The server emits the current state immediately
/// on subscribe (so clients can bootstrap their cache without waiting for the next
/// deploy), then emits one event each time the gateway's hierarchy cache observes
/// a new galaxy.time_of_last_deploy. The sequence field is monotonically
/// increasing per server start; gaps indicate the per-subscriber buffer dropped
/// older events because the client was too slow.
/// </summary>
/// <param name="request">The request to send to the server.</param>
/// <param name="options">The options for the call.</param>
/// <returns>The call object.</returns>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public virtual grpc::AsyncServerStreamingCall<global::MxGateway.Contracts.Proto.Galaxy.DeployEvent> WatchDeployEvents(global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest request, grpc::CallOptions options)
{
return CallInvoker.AsyncServerStreamingCall(__Method_WatchDeployEvents, null, options, request);
}
/// <summary>Creates a new instance of client from given <c>ClientBaseConfiguration</c>.</summary>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
protected override GalaxyRepositoryClient NewInstance(ClientBaseConfiguration configuration)
{
return new GalaxyRepositoryClient(configuration);
}
}
/// <summary>Creates service definition that can be registered with a server</summary>
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public static grpc::ServerServiceDefinition BindService(GalaxyRepositoryBase serviceImpl)
{
return grpc::ServerServiceDefinition.CreateBuilder()
.AddMethod(__Method_TestConnection, serviceImpl.TestConnection)
.AddMethod(__Method_GetLastDeployTime, serviceImpl.GetLastDeployTime)
.AddMethod(__Method_DiscoverHierarchy, serviceImpl.DiscoverHierarchy)
.AddMethod(__Method_WatchDeployEvents, serviceImpl.WatchDeployEvents).Build();
}
/// <summary>Register service method with a service binder with or without implementation. Useful when customizing the service binding logic.
/// Note: this method is part of an experimental API that can change or be removed without any prior notice.</summary>
/// <param name="serviceBinder">Service methods will be bound by calling <c>AddMethod</c> on this object.</param>
/// <param name="serviceImpl">An object implementing the server-side handling logic.</param>
[global::System.CodeDom.Compiler.GeneratedCode("grpc_csharp_plugin", null)]
public static void BindService(grpc::ServiceBinderBase serviceBinder, GalaxyRepositoryBase serviceImpl)
{
serviceBinder.AddMethod(__Method_TestConnection, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.Galaxy.TestConnectionRequest, global::MxGateway.Contracts.Proto.Galaxy.TestConnectionReply>(serviceImpl.TestConnection));
serviceBinder.AddMethod(__Method_GetLastDeployTime, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeRequest, global::MxGateway.Contracts.Proto.Galaxy.GetLastDeployTimeReply>(serviceImpl.GetLastDeployTime));
serviceBinder.AddMethod(__Method_DiscoverHierarchy, serviceImpl == null ? null : new grpc::UnaryServerMethod<global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyRequest, global::MxGateway.Contracts.Proto.Galaxy.DiscoverHierarchyReply>(serviceImpl.DiscoverHierarchy));
serviceBinder.AddMethod(__Method_WatchDeployEvents, serviceImpl == null ? null : new grpc::ServerStreamingServerMethod<global::MxGateway.Contracts.Proto.Galaxy.WatchDeployEventsRequest, global::MxGateway.Contracts.Proto.Galaxy.DeployEvent>(serviceImpl.WatchDeployEvents));
}
}
}
#endregion
@@ -8,6 +8,7 @@
<Compile Remove="Generated\**\*.cs" />
<Protobuf Include="Protos\mxaccess_gateway.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
<Protobuf Include="Protos\mxaccess_worker.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcServices="None" />
<Protobuf Include="Protos\galaxy_repository.proto" ProtoRoot="Protos" OutputDir="Generated" GrpcOutputDir="Generated" GrpcServices="Both" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,89 @@
syntax = "proto3";
package galaxy_repository.v1;
option csharp_namespace = "MxGateway.Contracts.Proto.Galaxy";
import "google/protobuf/timestamp.proto";
// Read-only browse over the AVEVA System Platform Galaxy Repository (ZB SQL
// database). Lets clients enumerate the deployed object hierarchy and each
// object's dynamic attributes so they know what tag references to subscribe
// to via the MxAccessGateway service.
service GalaxyRepository {
rpc TestConnection(TestConnectionRequest) returns (TestConnectionReply);
rpc GetLastDeployTime(GetLastDeployTimeRequest) returns (GetLastDeployTimeReply);
rpc DiscoverHierarchy(DiscoverHierarchyRequest) returns (DiscoverHierarchyReply);
// Server-stream of deploy events. The server emits the current state immediately
// on subscribe (so clients can bootstrap their cache without waiting for the next
// deploy), then emits one event each time the gateway's hierarchy cache observes
// a new galaxy.time_of_last_deploy. The sequence field is monotonically
// increasing per server start; gaps indicate the per-subscriber buffer dropped
// older events because the client was too slow.
rpc WatchDeployEvents(WatchDeployEventsRequest) returns (stream DeployEvent);
}
message TestConnectionRequest {}
message TestConnectionReply {
bool ok = 1;
}
message GetLastDeployTimeRequest {}
message GetLastDeployTimeReply {
bool present = 1;
google.protobuf.Timestamp time_of_last_deploy = 2;
}
message DiscoverHierarchyRequest {}
message DiscoverHierarchyReply {
repeated GalaxyObject objects = 1;
}
message WatchDeployEventsRequest {
// Optional. When set, the bootstrap event is suppressed if the cached deploy
// time matches this value. Future events are still emitted normally.
google.protobuf.Timestamp last_seen_deploy_time = 1;
}
message DeployEvent {
// Monotonically increasing per server start. Gaps indicate dropped events.
uint64 sequence = 1;
// Server wall-clock when the cache observed the deploy.
google.protobuf.Timestamp observed_at = 2;
// Galaxy.time_of_last_deploy. Absent only when the Galaxy table reports null.
google.protobuf.Timestamp time_of_last_deploy = 3;
bool time_of_last_deploy_present = 4;
int32 object_count = 5;
int32 attribute_count = 6;
}
message GalaxyObject {
int32 gobject_id = 1;
string tag_name = 2;
string contained_name = 3;
string browse_name = 4;
int32 parent_gobject_id = 5;
bool is_area = 6;
int32 category_id = 7;
int32 hosted_by_gobject_id = 8;
repeated string template_chain = 9;
repeated GalaxyAttribute attributes = 10;
}
message GalaxyAttribute {
string attribute_name = 1;
string full_tag_reference = 2;
int32 mx_data_type = 3;
string data_type_name = 4;
bool is_array = 5;
int32 array_dimension = 6;
bool array_dimension_present = 7;
int32 mx_attribute_category = 8;
int32 security_classification = 9;
bool is_historized = 10;
bool is_alarm = 11;
}
@@ -0,0 +1,68 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.IntegrationTests.Galaxy;
public sealed class GalaxyRepositoryLiveTests
{
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task TestConnection_AgainstZb_Succeeds()
{
GalaxyRepository repository = CreateRepository();
bool ok = await repository.TestConnectionAsync(CancellationToken.None);
Assert.True(ok, "TestConnectionAsync should return true against the ZB database.");
}
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
{
GalaxyRepository repository = CreateRepository();
DateTime? lastDeploy = await repository.GetLastDeployTimeAsync(CancellationToken.None);
Assert.NotNull(lastDeploy);
}
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
{
GalaxyRepository repository = CreateRepository();
List<GalaxyHierarchyRow> rows = await repository.GetHierarchyAsync(CancellationToken.None);
Assert.NotEmpty(rows);
Assert.All(rows, row =>
{
Assert.True(row.GobjectId > 0);
Assert.False(string.IsNullOrEmpty(row.TagName));
Assert.False(string.IsNullOrEmpty(row.BrowseName));
});
}
[LiveGalaxyRepositoryFact]
[Trait("Category", "LiveGalaxy")]
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
{
GalaxyRepository repository = CreateRepository();
List<GalaxyAttributeRow> rows = await repository.GetAttributesAsync(CancellationToken.None);
Assert.NotEmpty(rows);
Assert.All(rows, row =>
{
Assert.True(row.GobjectId > 0);
Assert.False(string.IsNullOrEmpty(row.AttributeName));
Assert.False(string.IsNullOrEmpty(row.FullTagReference));
});
}
private static GalaxyRepository CreateRepository() => new(new GalaxyRepositoryOptions
{
ConnectionString = LiveGalaxyRepositoryFactAttribute.ConnectionString,
CommandTimeoutSeconds = 30,
});
}
@@ -0,0 +1,25 @@
namespace MxGateway.IntegrationTests.Galaxy;
public sealed class LiveGalaxyRepositoryFactAttribute : FactAttribute
{
public const string EnableVariableName = "MXGATEWAY_RUN_LIVE_GALAXY_TESTS";
public const string ConnectionStringVariableName = "MXGATEWAY_LIVE_GALAXY_CONN";
public LiveGalaxyRepositoryFactAttribute()
{
if (!Enabled)
{
Skip = $"Set {EnableVariableName}=1 to run live Galaxy Repository tests.";
}
}
public static bool Enabled =>
string.Equals(
Environment.GetEnvironmentVariable(EnableVariableName),
"1",
StringComparison.Ordinal);
public static string ConnectionString =>
Environment.GetEnvironmentVariable(ConnectionStringVariableName)
?? "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
}
@@ -251,6 +251,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
_metrics,
_loggerFactory.CreateLogger<MxAccessGatewayService>());
}
@@ -23,6 +23,9 @@
<li class="nav-item">
<NavLink class="nav-link" href="events">Events</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="galaxy">Galaxy</NavLink>
</li>
<li class="nav-item">
<NavLink class="nav-link" href="settings">Settings</NavLink>
</li>
@@ -29,6 +29,26 @@ else
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
</section>
<section class="dashboard-section">
<div class="section-heading d-flex align-items-center gap-2">
<h2>Galaxy Repository</h2>
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
<NavLink class="ms-auto small" href="galaxy">View browse details &rarr;</NavLink>
</div>
<div class="metric-grid compact">
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" />
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" />
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@GalaxyRefreshDetail()" />
</div>
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
{
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Recent Faults</h2>
@@ -36,3 +56,23 @@ else
<FaultList Faults="@Snapshot.Faults" />
</section>
}
@code {
private string? GalaxyRefreshDetail()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastQueriedAt is null)
{
return "never queried";
}
if (galaxy.LastSuccessAt is null)
{
return "no successful refresh yet";
}
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
: null;
}
}
@@ -0,0 +1,195 @@
@page "/galaxy"
@page "/dashboard/galaxy"
@inherits DashboardPageBase
<PageTitle>Dashboard Galaxy</PageTitle>
@if (Snapshot is null)
{
<div class="empty-state">Loading Galaxy summary.</div>
}
else
{
<div class="dashboard-page-header">
<div>
<h1>Galaxy Repository</h1>
<div class="text-secondary">@RefreshHeading()</div>
</div>
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
</div>
<section class="metric-grid">
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" Detail="@DeployAge()" />
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" Detail="dynamic, deployed" />
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" />
</section>
@if (Snapshot.Galaxy.Status == DashboardGalaxyStatus.Unknown)
{
<section class="dashboard-section">
<div class="empty-state">
Galaxy summary has not been collected yet. The dashboard refreshes the
summary every @RefreshIntervalSeconds() seconds via the
<code>GalaxyRepository</code> service.
</div>
</section>
}
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
{
<section class="dashboard-section">
<div class="section-heading">
<h2>Last Error</h2>
</div>
<div class="empty-state">@Snapshot.Galaxy.LastError</div>
</section>
}
<section class="dashboard-section">
<div class="section-heading">
<h2>Object Categories</h2>
</div>
@if (Snapshot.Galaxy.ObjectCategories.Count == 0)
{
<div class="empty-state">No deployed objects observed.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Category</th>
<th scope="col">Category ID</th>
<th scope="col">Objects</th>
</tr>
</thead>
<tbody>
@foreach (DashboardGalaxyCategoryCount row in Snapshot.Galaxy.ObjectCategories)
{
<tr>
<td>@row.CategoryName</td>
<td><code>@row.CategoryId</code></td>
<td>@DashboardDisplay.Count(row.ObjectCount)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Top Templates</h2>
</div>
@if (Snapshot.Galaxy.TopTemplates.Count == 0)
{
<div class="empty-state">No template usage observed.</div>
}
else
{
<div class="table-responsive">
<table class="table table-sm dashboard-table">
<thead>
<tr>
<th scope="col">Template</th>
<th scope="col">Instances</th>
</tr>
</thead>
<tbody>
@foreach (DashboardGalaxyTemplateUsage row in Snapshot.Galaxy.TopTemplates)
{
<tr>
<td><code>@row.TemplateName</code></td>
<td>@DashboardDisplay.Count(row.InstanceCount)</td>
</tr>
}
</tbody>
</table>
</div>
}
</section>
<section class="dashboard-section">
<div class="section-heading">
<h2>Sync Info</h2>
</div>
<div class="table-responsive">
<table class="table table-sm dashboard-table details-table">
<tbody>
<tr><th scope="row">Status</th><td><StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" /></td></tr>
<tr><th scope="row">Last successful refresh</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)</td></tr>
<tr><th scope="row">Last attempt</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastQueriedAt)</td></tr>
<tr><th scope="row">Galaxy <code>time_of_last_deploy</code></th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)</td></tr>
<tr><th scope="row">Refresh interval</th><td>@RefreshIntervalSeconds() seconds</td></tr>
<tr><th scope="row">Connection string</th><td><code>@DashboardDisplay.Text(GalaxyConnectionStringDisplay())</code></td></tr>
<tr><th scope="row">Command timeout</th><td>@CommandTimeoutSeconds() seconds</td></tr>
</tbody>
</table>
</div>
<div class="text-secondary small mt-2">
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
<code>GetLastDeployTime</code> to detect redeployments.
</div>
</section>
}
@code {
[Inject]
private IOptions<MxGateway.Server.Galaxy.GalaxyRepositoryOptions> GalaxyOptions { get; set; } = null!;
private string RefreshHeading()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
return galaxy.LastSuccessAt is null
? "Awaiting first successful refresh"
: $"Refreshed {DashboardDisplay.DateTime(galaxy.LastSuccessAt)}";
}
private string? DeployAge()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastDeployTime is null || galaxy.LastSuccessAt is null)
{
return null;
}
TimeSpan age = galaxy.LastSuccessAt.Value - galaxy.LastDeployTime.Value;
if (age < TimeSpan.Zero)
{
return null;
}
return $"{DashboardDisplay.Duration(age)} ago";
}
private string? LastAttemptDetail()
{
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
if (galaxy.LastQueriedAt is null)
{
return "never queried";
}
if (galaxy.LastSuccessAt is null)
{
return "no successful refresh yet";
}
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
: null;
}
private int RefreshIntervalSeconds() => Math.Max(1, GalaxyOptions.Value.DashboardRefreshIntervalSeconds);
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
private string? GalaxyConnectionStringDisplay() =>
DashboardRedactor.Redact(GalaxyOptions.Value.ConnectionString);
}
@@ -9,7 +9,9 @@
"Ready" or "Healthy" => "text-bg-success",
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "text-bg-info",
"Closed" => "text-bg-secondary",
"Faulted" => "text-bg-danger",
"Stale" => "text-bg-warning",
"Faulted" or "Unavailable" => "text-bg-danger",
"Unknown" => "text-bg-light text-dark border",
_ => "text-bg-light text-dark border"
};
}
@@ -0,0 +1,98 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Projects a <see cref="GalaxyHierarchyCacheEntry"/> into a
/// <see cref="DashboardGalaxySummary"/> for the Blazor pages. Top-templates and
/// per-category breakdowns are computed here rather than stored on the cache so the
/// Galaxy namespace stays free of dashboard-presentation concepts.
/// </summary>
internal static class DashboardGalaxyProjector
{
private const int TopTemplatesLimit = 10;
private static readonly IReadOnlyDictionary<int, string> CategoryNamesById = new Dictionary<int, string>
{
[1] = "WinPlatform",
[3] = "AppEngine",
[4] = "InTouchViewApp",
[10] = "UserDefined",
[11] = "FieldReference",
[13] = "Area",
[17] = "DIObject",
[24] = "DDESuiteLinkClient",
[26] = "OPCClient",
};
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
{
DashboardGalaxyStatus status = entry.Status switch
{
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
_ => DashboardGalaxyStatus.Unknown,
};
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
if (entry.Hierarchy.Count == 0)
{
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
}
else
{
Dictionary<int, int> objectsByCategory = new();
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
foreach (GalaxyHierarchyRow row in entry.Hierarchy)
{
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
objectsByCategory[row.CategoryId] = categoryCount + 1;
if (row.TemplateChain.Count > 0)
{
string immediate = row.TemplateChain[0];
if (!string.IsNullOrWhiteSpace(immediate))
{
templateUsage.TryGetValue(immediate, out int templateCount);
templateUsage[immediate] = templateCount + 1;
}
}
}
topTemplates = templateUsage
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
.Take(TopTemplatesLimit)
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
.ToArray();
objectCategories = objectsByCategory
.OrderByDescending(entry => entry.Value)
.ThenBy(entry => entry.Key)
.Select(entry => new DashboardGalaxyCategoryCount(
entry.Key,
CategoryNamesById.TryGetValue(entry.Key, out string? name) ? name : $"Category {entry.Key}",
entry.Value))
.ToArray();
}
return new DashboardGalaxySummary(
Status: status,
LastQueriedAt: entry.LastQueriedAt,
LastSuccessAt: entry.LastSuccessAt,
LastDeployTime: entry.LastDeployTime,
LastError: entry.LastError,
ObjectCount: entry.ObjectCount,
AreaCount: entry.AreaCount,
AttributeCount: entry.AttributeCount,
HistorizedAttributeCount: entry.HistorizedAttributeCount,
AlarmAttributeCount: entry.AlarmAttributeCount,
TopTemplates: topTemplates,
ObjectCategories: objectCategories);
}
}
@@ -0,0 +1,47 @@
namespace MxGateway.Server.Dashboard;
/// <summary>
/// Snapshot of the Galaxy Repository (ZB) browse state surfaced on the dashboard.
/// Populated by <see cref="GalaxySummaryCache"/> on a background refresh cadence so
/// the dashboard never blocks on SQL.
/// </summary>
public sealed record DashboardGalaxySummary(
DashboardGalaxyStatus Status,
DateTimeOffset? LastQueriedAt,
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastDeployTime,
string? LastError,
int ObjectCount,
int AreaCount,
int AttributeCount,
int HistorizedAttributeCount,
int AlarmAttributeCount,
IReadOnlyList<DashboardGalaxyTemplateUsage> TopTemplates,
IReadOnlyList<DashboardGalaxyCategoryCount> ObjectCategories)
{
public static DashboardGalaxySummary Unknown { get; } = new(
DashboardGalaxyStatus.Unknown,
LastQueriedAt: null,
LastSuccessAt: null,
LastDeployTime: null,
LastError: null,
ObjectCount: 0,
AreaCount: 0,
AttributeCount: 0,
HistorizedAttributeCount: 0,
AlarmAttributeCount: 0,
TopTemplates: Array.Empty<DashboardGalaxyTemplateUsage>(),
ObjectCategories: Array.Empty<DashboardGalaxyCategoryCount>());
}
public enum DashboardGalaxyStatus
{
Unknown = 0,
Healthy = 1,
Stale = 2,
Unavailable = 3,
}
public sealed record DashboardGalaxyTemplateUsage(string TemplateName, int InstanceCount);
public sealed record DashboardGalaxyCategoryCount(int CategoryId, string CategoryName, int ObjectCount);
@@ -12,4 +12,5 @@ public sealed record DashboardSnapshot(
IReadOnlyList<DashboardWorkerSummary> Workers,
IReadOnlyList<DashboardMetricSummary> Metrics,
IReadOnlyList<DashboardFaultSummary> Faults,
EffectiveGatewayConfiguration Configuration);
EffectiveGatewayConfiguration Configuration,
DashboardGalaxySummary Galaxy);
@@ -1,6 +1,7 @@
using System.Runtime.CompilerServices;
using Microsoft.Extensions.Options;
using MxGateway.Server.Configuration;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -14,6 +15,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
private readonly ISessionRegistry _sessionRegistry;
private readonly GatewayMetrics _metrics;
private readonly IGatewayConfigurationProvider _configurationProvider;
private readonly IGalaxyHierarchyCache _galaxyHierarchyCache;
private readonly TimeProvider _timeProvider;
private readonly DateTimeOffset _gatewayStartedAt;
private readonly TimeSpan _snapshotInterval;
@@ -24,12 +26,14 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
ISessionRegistry sessionRegistry,
GatewayMetrics metrics,
IGatewayConfigurationProvider configurationProvider,
IGalaxyHierarchyCache galaxyHierarchyCache,
IOptions<GatewayOptions> options,
TimeProvider? timeProvider = null)
{
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
_galaxyHierarchyCache = galaxyHierarchyCache ?? throw new ArgumentNullException(nameof(galaxyHierarchyCache));
ArgumentNullException.ThrowIfNull(options);
_timeProvider = timeProvider ?? TimeProvider.System;
@@ -65,7 +69,8 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
Workers: workerSummaries,
Metrics: CreateMetricSummaries(metricsSnapshot),
Faults: CreateFaultSummaries(sessions, generatedAt),
Configuration: _configurationProvider.GetEffectiveConfiguration());
Configuration: _configurationProvider.GetEffectiveConfiguration(),
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
}
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
@@ -0,0 +1,17 @@
namespace MxGateway.Server.Galaxy;
public enum GalaxyCacheStatus
{
/// <summary>Cache has never completed a refresh.</summary>
Unknown = 0,
/// <summary>Cache holds data from a recent successful refresh.</summary>
Healthy = 1,
/// <summary>Cache holds data, but the most recent refresh attempt failed
/// or no successful refresh has happened within the staleness threshold.</summary>
Stale = 2,
/// <summary>Latest refresh failed and no prior data is available.</summary>
Unavailable = 3,
}
@@ -0,0 +1,14 @@
namespace MxGateway.Server.Galaxy;
/// <summary>
/// A single Galaxy deploy notification. Published by <see cref="GalaxyHierarchyCache"/>
/// whenever a refresh detects that <c>galaxy.time_of_last_deploy</c> has changed (or on
/// the first successful refresh). Consumed by <see cref="IGalaxyDeployNotifier"/>
/// subscribers (the streaming gRPC RPC).
/// </summary>
public sealed record GalaxyDeployEventInfo(
long Sequence,
DateTimeOffset ObservedAt,
DateTimeOffset? TimeOfLastDeploy,
int ObjectCount,
int AttributeCount);
@@ -0,0 +1,74 @@
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Channel-based fan-out of Galaxy deploy events to streaming gRPC subscribers. Each
/// subscriber gets a private bounded channel so a slow client cannot back-pressure
/// other subscribers or the publisher. When a subscriber's channel is full the oldest
/// event is dropped — clients use the sequence field to detect gaps.
/// </summary>
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
{
private const int SubscriberQueueCapacity = 16;
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
private GalaxyDeployEventInfo? _latest;
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
public void Publish(GalaxyDeployEventInfo info)
{
ArgumentNullException.ThrowIfNull(info);
Volatile.Write(ref _latest, info);
foreach (Channel<GalaxyDeployEventInfo> channel in _subscribers.Values)
{
// BoundedChannelFullMode.DropOldest -> writes never wait; we only fail if the
// channel was completed by the subscriber side, which we ignore.
channel.Writer.TryWrite(info);
}
}
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
Guid subscriberId = Guid.NewGuid();
Channel<GalaxyDeployEventInfo> channel = Channel.CreateBounded<GalaxyDeployEventInfo>(
new BoundedChannelOptions(SubscriberQueueCapacity)
{
FullMode = BoundedChannelFullMode.DropOldest,
SingleReader = true,
SingleWriter = false,
});
_subscribers[subscriberId] = channel;
// Bootstrap: emit the latest known event so subscribers don't need to wait for
// the next deploy to know current state.
GalaxyDeployEventInfo? bootstrap = Volatile.Read(ref _latest);
if (bootstrap is not null)
{
channel.Writer.TryWrite(bootstrap);
}
try
{
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
{
while (channel.Reader.TryRead(out GalaxyDeployEventInfo? next))
{
yield return next;
}
}
}
finally
{
_subscribers.TryRemove(subscriberId, out _);
channel.Writer.TryComplete();
}
}
}
@@ -0,0 +1,186 @@
using Google.Protobuf.WellKnownTypes;
using Microsoft.Data.SqlClient;
using Microsoft.Extensions.Logging;
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Grpc;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
/// entry — the materialized <see cref="DiscoverHierarchyReply"/> is produced once per
/// refresh and reused across requests. Refreshes are deploy-time gated: every tick
/// queries <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy +
/// attributes rowsets are pulled only when that timestamp has advanced.
/// </summary>
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
{
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
private readonly GalaxyRepository _repository;
private readonly IGalaxyDeployNotifier _notifier;
private readonly TimeProvider _timeProvider;
private readonly ILogger<GalaxyHierarchyCache>? _logger;
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
private readonly SemaphoreSlim _refreshGate = new(1, 1);
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
public GalaxyHierarchyCache(
GalaxyRepository repository,
IGalaxyDeployNotifier notifier,
TimeProvider? timeProvider = null,
ILogger<GalaxyHierarchyCache>? logger = null)
{
_repository = repository;
_notifier = notifier;
_timeProvider = timeProvider ?? TimeProvider.System;
_logger = logger;
}
public GalaxyHierarchyCacheEntry Current
{
get
{
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
GalaxyCacheStatus projected = ProjectStatus(snapshot);
return projected == snapshot.Status ? snapshot : snapshot with { Status = projected };
}
}
public async Task RefreshAsync(CancellationToken cancellationToken)
{
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
}
finally
{
_refreshGate.Release();
}
}
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
{
return _firstLoad.Task.WaitAsync(cancellationToken);
}
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
{
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
try
{
DateTime? deployRaw = await _repository.GetLastDeployTimeAsync(cancellationToken).ConfigureAwait(false);
DateTimeOffset? deployTime = deployRaw.HasValue
? new DateTimeOffset(DateTime.SpecifyKind(deployRaw.Value, DateTimeKind.Utc))
: null;
bool hasPriorData = previous.HasData;
bool deployChanged = !hasPriorData || deployTime != previous.LastDeployTime;
if (!deployChanged)
{
// No deploy change — skip heavy queries; just bump LastSuccessAt.
GalaxyHierarchyCacheEntry refreshed = previous with
{
Status = GalaxyCacheStatus.Healthy,
LastQueriedAt = queriedAt,
LastSuccessAt = queriedAt,
LastError = null,
};
Volatile.Write(ref _current, refreshed);
_firstLoad.TrySetResult();
return;
}
Task<List<GalaxyHierarchyRow>> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken);
Task<List<GalaxyAttributeRow>> attributesTask = _repository.GetAttributesAsync(cancellationToken);
await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false);
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
List<GalaxyAttributeRow> attributes = attributesTask.Result;
DiscoverHierarchyReply reply = BuildReply(hierarchy, attributes);
int areaCount = hierarchy.Count(row => row.IsArea);
int historized = attributes.Count(row => row.IsHistorized);
int alarms = attributes.Count(row => row.IsAlarm);
long nextSequence = previous.Sequence + 1;
GalaxyHierarchyCacheEntry next = new(
Status: GalaxyCacheStatus.Healthy,
Sequence: nextSequence,
LastQueriedAt: queriedAt,
LastSuccessAt: queriedAt,
LastDeployTime: deployTime,
LastError: null,
Hierarchy: hierarchy,
Attributes: attributes,
Reply: reply,
ObjectCount: hierarchy.Count,
AreaCount: areaCount,
AttributeCount: attributes.Count,
HistorizedAttributeCount: historized,
AlarmAttributeCount: alarms);
Volatile.Write(ref _current, next);
_firstLoad.TrySetResult();
_notifier.Publish(new GalaxyDeployEventInfo(
Sequence: nextSequence,
ObservedAt: queriedAt,
TimeOfLastDeploy: deployTime,
ObjectCount: hierarchy.Count,
AttributeCount: attributes.Count));
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception exception) when (exception is SqlException or InvalidOperationException)
{
_logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed.");
GalaxyHierarchyCacheEntry failed = previous with
{
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
LastQueriedAt = queriedAt,
LastError = exception.Message,
};
Volatile.Write(ref _current, failed);
_firstLoad.TrySetResult();
}
}
private static DiscoverHierarchyReply BuildReply(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes)
{
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
DiscoverHierarchyReply reply = new();
foreach (GalaxyHierarchyRow row in hierarchy)
{
reply.Objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
}
return reply;
}
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
{
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
{
return snapshot.Status;
}
if (snapshot.LastSuccessAt is { } success
&& _timeProvider.GetUtcNow() - success > StaleThreshold)
{
return GalaxyCacheStatus.Stale;
}
return snapshot.Status;
}
}
@@ -0,0 +1,43 @@
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Immutable snapshot of the Galaxy Repository browse data held by
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same instance —
/// the materialized <see cref="Reply"/> is produced once per refresh and reused.
/// </summary>
public sealed record GalaxyHierarchyCacheEntry(
GalaxyCacheStatus Status,
long Sequence,
DateTimeOffset? LastQueriedAt,
DateTimeOffset? LastSuccessAt,
DateTimeOffset? LastDeployTime,
string? LastError,
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
IReadOnlyList<GalaxyAttributeRow> Attributes,
DiscoverHierarchyReply? Reply,
int ObjectCount,
int AreaCount,
int AttributeCount,
int HistorizedAttributeCount,
int AlarmAttributeCount)
{
public static GalaxyHierarchyCacheEntry Empty { get; } = new(
Status: GalaxyCacheStatus.Unknown,
Sequence: 0,
LastQueriedAt: null,
LastSuccessAt: null,
LastDeployTime: null,
LastError: null,
Hierarchy: Array.Empty<GalaxyHierarchyRow>(),
Attributes: Array.Empty<GalaxyAttributeRow>(),
Reply: null,
ObjectCount: 0,
AreaCount: 0,
AttributeCount: 0,
HistorizedAttributeCount: 0,
AlarmAttributeCount: 0);
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
}
@@ -0,0 +1,56 @@
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Periodically refreshes <see cref="IGalaxyHierarchyCache"/> off the request path. The
/// interval comes from <see cref="GalaxyRepositoryOptions.DashboardRefreshIntervalSeconds"/>;
/// each tick is cheap when the deploy timestamp is unchanged.
/// </summary>
public sealed class GalaxyHierarchyRefreshService(
IGalaxyHierarchyCache cache,
IOptions<GalaxyRepositoryOptions> options,
ILogger<GalaxyHierarchyRefreshService> logger,
TimeProvider? timeProvider = null) : BackgroundService
{
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds));
try
{
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
using PeriodicTimer timer = new(interval, _timeProvider);
try
{
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
{
try
{
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
return;
}
catch (Exception exception)
{
logger.LogWarning(exception, "Galaxy hierarchy cache refresh tick failed.");
}
}
}
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
{
}
}
}
@@ -0,0 +1,35 @@
namespace MxGateway.Server.Galaxy;
/// <summary>
/// One row from <see cref="GalaxyRepository.GetHierarchyAsync"/>: a deployed Galaxy
/// <c>gobject</c> with its hierarchy parent and template-derivation chain.
/// </summary>
public sealed class GalaxyHierarchyRow
{
public int GobjectId { get; init; }
public string TagName { get; init; } = string.Empty;
public string ContainedName { get; init; } = string.Empty;
public string BrowseName { get; init; } = string.Empty;
public int ParentGobjectId { get; init; }
public bool IsArea { get; init; }
public int CategoryId { get; init; }
public int HostedByGobjectId { get; init; }
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
}
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
public sealed class GalaxyAttributeRow
{
public int GobjectId { get; init; }
public string TagName { get; init; } = string.Empty;
public string AttributeName { get; init; } = string.Empty;
public string FullTagReference { get; init; } = string.Empty;
public int MxDataType { get; init; }
public string? DataTypeName { get; init; }
public bool IsArray { get; init; }
public int? ArrayDimension { get; init; }
public int MxAttributeCategory { get; init; }
public int SecurityClassification { get; init; }
public bool IsHistorized { get; init; }
public bool IsAlarm { get; init; }
}
@@ -0,0 +1,218 @@
using Microsoft.Data.SqlClient;
namespace MxGateway.Server.Galaxy;
/// <summary>
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database. Ported from
/// the OtOpcUa project so the row sets stay byte-for-byte identical between the two
/// consumers — the same SQL drives the OPC UA server's address space and this gateway's
/// gRPC browse surface.
/// </summary>
public sealed class GalaxyRepository(GalaxyRepositoryOptions options)
{
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
{
try
{
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is int i && i == 1;
}
catch (SqlException) { return false; }
catch (InvalidOperationException) { return false; }
}
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
{
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn)
{ CommandTimeout = options.CommandTimeoutSeconds };
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
return result is DateTime dt ? dt : null;
}
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
{
List<GalaxyHierarchyRow> rows = new();
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
string[] templateChain = templateChainRaw.Length == 0
? Array.Empty<string>()
: templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries)
.Select(s => s.Trim())
.Where(s => s.Length > 0)
.ToArray();
rows.Add(new GalaxyHierarchyRow
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
TagName = reader.GetString(1),
ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
BrowseName = reader.GetString(3),
ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
CategoryId = Convert.ToInt32(reader.GetValue(6)),
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
TemplateChain = templateChain,
});
}
return rows;
}
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
{
List<GalaxyAttributeRow> rows = new();
using SqlConnection conn = new(options.ConnectionString);
await conn.OpenAsync(ct).ConfigureAwait(false);
using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
while (await reader.ReadAsync(ct).ConfigureAwait(false))
{
rows.Add(new GalaxyAttributeRow
{
GobjectId = Convert.ToInt32(reader.GetValue(0)),
TagName = reader.GetString(1),
AttributeName = reader.GetString(2),
FullTagReference = reader.GetString(3),
MxDataType = Convert.ToInt32(reader.GetValue(4)),
DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
});
}
return rows;
}
private const string HierarchySql = @"
;WITH template_chain AS (
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
FROM gobject g
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
UNION ALL
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
FROM template_chain tc
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
)
SELECT DISTINCT
g.gobject_id,
g.tag_name,
g.contained_name,
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
THEN g.tag_name
ELSE g.contained_name
END AS browse_name,
CASE WHEN g.contained_by_gobject_id = 0
THEN g.area_gobject_id
ELSE g.contained_by_gobject_id
END AS parent_gobject_id,
CASE WHEN td.category_id = 13
THEN 1
ELSE 0
END AS is_area,
td.category_id AS category_id,
g.hosted_by_gobject_id AS hosted_by_gobject_id,
ISNULL(
STUFF((
SELECT '|' + tc.template_tag_name
FROM template_chain tc
WHERE tc.instance_gobject_id = g.gobject_id
ORDER BY tc.depth
FOR XML PATH('')
), 1, 1, ''),
''
) AS template_chain
FROM gobject g
INNER JOIN template_definition td
ON g.template_definition_id = td.template_definition_id
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND g.is_template = 0
AND g.deployed_package_id <> 0
ORDER BY parent_gobject_id, g.tag_name";
private const string AttributesSql = @"
;WITH deployed_package_chain AS (
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
FROM gobject g
INNER JOIN package p ON p.package_id = g.deployed_package_id
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
UNION ALL
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
FROM deployed_package_chain dpc
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
)
SELECT gobject_id, tag_name, attribute_name, full_tag_reference,
mx_data_type, data_type_name, is_array, array_dimension,
mx_attribute_category, security_classification, is_historized, is_alarm
FROM (
SELECT
dpc.gobject_id,
g.tag_name,
da.attribute_name,
g.tag_name + '.' + da.attribute_name
+ CASE WHEN da.is_array = 1 THEN '[]' ELSE '' END
AS full_tag_reference,
da.mx_data_type,
dt.description AS data_type_name,
da.is_array,
CASE WHEN da.is_array = 1
THEN CONVERT(int, CONVERT(varbinary(2),
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
ELSE NULL
END AS array_dimension,
da.mx_attribute_category,
da.security_classification,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'HistoryExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_historized,
CASE WHEN EXISTS (
SELECT 1 FROM deployed_package_chain dpc2
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = da.attribute_name
INNER JOIN primitive_definition pd ON pd.primitive_definition_id = pi.primitive_definition_id AND pd.primitive_name = 'AlarmExtension'
WHERE dpc2.gobject_id = dpc.gobject_id
) THEN 1 ELSE 0 END AS is_alarm,
ROW_NUMBER() OVER (
PARTITION BY dpc.gobject_id, da.attribute_name
ORDER BY dpc.depth
) AS rn
FROM deployed_package_chain dpc
INNER JOIN dynamic_attribute da
ON da.package_id = dpc.package_id
INNER JOIN gobject g
ON g.gobject_id = dpc.gobject_id
INNER JOIN template_definition td
ON td.template_definition_id = g.template_definition_id
LEFT JOIN data_type dt
ON dt.mx_data_type = da.mx_data_type
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
AND da.attribute_name NOT LIKE '[_]%'
AND da.attribute_name NOT LIKE '%.Description'
AND da.mx_attribute_category IN (2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 24)
) ranked
WHERE rn = 1
ORDER BY tag_name, attribute_name";
}
@@ -0,0 +1,21 @@
namespace MxGateway.Server.Galaxy;
/// <summary>
/// Connection settings for the AVEVA System Platform Galaxy Repository (ZB) database.
/// Bound to the <c>MxGateway:Galaxy</c> configuration section.
/// </summary>
public sealed class GalaxyRepositoryOptions
{
public const string SectionName = "MxGateway:Galaxy";
public string ConnectionString { get; init; } =
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
public int CommandTimeoutSeconds { get; init; } = 60;
/// <summary>
/// Interval (seconds) between background refreshes of the dashboard Galaxy summary
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
/// </summary>
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
}
@@ -0,0 +1,23 @@
using Microsoft.Extensions.Options;
namespace MxGateway.Server.Galaxy;
public static class GalaxyRepositoryServiceCollectionExtensions
{
public static IServiceCollection AddGalaxyRepository(this IServiceCollection services)
{
services
.AddOptions<GalaxyRepositoryOptions>()
.BindConfiguration(GalaxyRepositoryOptions.SectionName)
.ValidateOnStart();
services.AddSingleton(sp =>
new GalaxyRepository(sp.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value));
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
services.AddHostedService<GalaxyHierarchyRefreshService>();
return services;
}
}
@@ -0,0 +1,18 @@
namespace MxGateway.Server.Galaxy;
public interface IGalaxyDeployNotifier
{
/// <summary>The most recently published event, or <c>null</c> if no event has fired yet.</summary>
GalaxyDeployEventInfo? Latest { get; }
/// <summary>Publishes a deploy event to all current subscribers and stores it as <see cref="Latest"/>.</summary>
void Publish(GalaxyDeployEventInfo info);
/// <summary>
/// Subscribe to deploy events. The async sequence yields events as they fire. If
/// <see cref="Latest"/> is set, it is yielded first so subscribers can bootstrap their
/// local cache without waiting for the next deploy. Pass a cancellation token to
/// unsubscribe.
/// </summary>
IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(CancellationToken cancellationToken);
}
@@ -0,0 +1,22 @@
namespace MxGateway.Server.Galaxy;
public interface IGalaxyHierarchyCache
{
/// <summary>The latest cache entry. Status freshness is recomputed against the clock.</summary>
GalaxyHierarchyCacheEntry Current { get; }
/// <summary>
/// Forces a refresh against the Galaxy Repository. Performs a cheap
/// <c>time_of_last_deploy</c> probe first and only re-queries the heavy hierarchy +
/// attributes rowsets when the deploy time has changed since the last successful
/// refresh.
/// </summary>
Task RefreshAsync(CancellationToken cancellationToken);
/// <summary>
/// Awaits the first completed refresh attempt (success or failure). Useful for
/// gRPC handlers that want to serve from cache without returning Unavailable on the
/// very first request after gateway start.
/// </summary>
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
}
@@ -3,6 +3,7 @@ using MxGateway.Contracts;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Diagnostics;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
@@ -51,6 +52,7 @@ public static class GatewayApplication
builder.Services.AddWorkerProcessLauncher();
builder.Services.AddGatewaySessions();
builder.Services.AddGatewayDashboard();
builder.Services.AddGalaxyRepository();
return builder;
}
@@ -125,6 +127,7 @@ public static class GatewayApplication
.WithName("LiveHealth");
endpoints.MapGrpcService<MxAccessGatewayService>();
endpoints.MapGrpcService<GalaxyRepositoryGrpcService>();
endpoints.MapGatewayDashboard();
return endpoints;
@@ -0,0 +1,69 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Galaxy;
namespace MxGateway.Server.Grpc;
/// <summary>
/// Maps <see cref="GalaxyHierarchyRow"/> + <see cref="GalaxyAttributeRow"/> rows produced
/// by <see cref="GalaxyRepository"/> into <c>galaxy_repository.v1</c> proto messages.
/// Pure function, separated so it can be unit-tested without a SQL connection.
/// </summary>
public static class GalaxyProtoMapper
{
public static IEnumerable<GalaxyObject> MapHierarchy(
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
IReadOnlyList<GalaxyAttributeRow> attributes)
{
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
.GroupBy(a => a.GobjectId)
.ToDictionary(g => g.Key, g => g.ToList());
foreach (GalaxyHierarchyRow row in hierarchy)
{
yield return MapObject(row, attributesByGobjectId);
}
}
public static GalaxyObject MapObject(
GalaxyHierarchyRow row,
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
{
GalaxyObject obj = new()
{
GobjectId = row.GobjectId,
TagName = row.TagName,
ContainedName = row.ContainedName,
BrowseName = row.BrowseName,
ParentGobjectId = row.ParentGobjectId,
IsArea = row.IsArea,
CategoryId = row.CategoryId,
HostedByGobjectId = row.HostedByGobjectId,
};
obj.TemplateChain.AddRange(row.TemplateChain);
if (attributesByGobjectId.TryGetValue(row.GobjectId, out List<GalaxyAttributeRow>? attrs))
{
foreach (GalaxyAttributeRow attr in attrs)
{
obj.Attributes.Add(MapAttribute(attr));
}
}
return obj;
}
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
{
AttributeName = row.AttributeName,
FullTagReference = row.FullTagReference,
MxDataType = row.MxDataType,
DataTypeName = row.DataTypeName ?? string.Empty,
IsArray = row.IsArray,
ArrayDimension = row.ArrayDimension ?? 0,
ArrayDimensionPresent = row.ArrayDimension.HasValue,
MxAttributeCategory = row.MxAttributeCategory,
SecurityClassification = row.SecurityClassification,
IsHistorized = row.IsHistorized,
IsAlarm = row.IsAlarm,
};
}
@@ -0,0 +1,158 @@
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
using Microsoft.Data.SqlClient;
using MxGateway.Contracts.Proto.Galaxy;
using GalaxyDb = MxGateway.Server.Galaxy;
using ProtoGalaxyRepository = MxGateway.Contracts.Proto.Galaxy.GalaxyRepository;
namespace MxGateway.Server.Grpc;
/// <summary>
/// gRPC surface that exposes the Galaxy Repository to clients. <c>DiscoverHierarchy</c>
/// and <c>GetLastDeployTime</c> serve from <see cref="GalaxyDb.IGalaxyHierarchyCache"/>
/// so many clients share a single SQL pull. <c>WatchDeployEvents</c> streams events
/// from <see cref="GalaxyDb.IGalaxyDeployNotifier"/>. <c>TestConnection</c> remains a
/// direct SQL probe since callers use it as a health check.
/// </summary>
public sealed class GalaxyRepositoryGrpcService(
GalaxyDb.GalaxyRepository repository,
GalaxyDb.IGalaxyHierarchyCache cache,
GalaxyDb.IGalaxyDeployNotifier notifier,
ILogger<GalaxyRepositoryGrpcService> logger) : ProtoGalaxyRepository.GalaxyRepositoryBase
{
private static readonly TimeSpan FirstLoadWaitBudget = TimeSpan.FromSeconds(5);
public override async Task<TestConnectionReply> TestConnection(
TestConnectionRequest request,
ServerCallContext context)
{
bool ok = await repository.TestConnectionAsync(context.CancellationToken).ConfigureAwait(false);
return new TestConnectionReply { Ok = ok };
}
public override async Task<GetLastDeployTimeReply> GetLastDeployTime(
GetLastDeployTimeRequest request,
ServerCallContext context)
{
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData)
{
throw new RpcException(new Status(
StatusCode.Unavailable,
ResolveUnavailableMessage(entry)));
}
GetLastDeployTimeReply reply = new() { Present = entry.LastDeployTime.HasValue };
if (entry.LastDeployTime.HasValue)
{
reply.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(entry.LastDeployTime.Value);
}
return reply;
}
public override async Task<DiscoverHierarchyReply> DiscoverHierarchy(
DiscoverHierarchyRequest request,
ServerCallContext context)
{
await WaitForCacheBootstrap(context.CancellationToken).ConfigureAwait(false);
GalaxyDb.GalaxyHierarchyCacheEntry entry = cache.Current;
if (!entry.HasData || entry.Reply is null)
{
throw new RpcException(new Status(
StatusCode.Unavailable,
ResolveUnavailableMessage(entry)));
}
// Same materialized reply is shared across all clients — gRPC serialization is
// read-only and the entry is replaced atomically on the next refresh.
return entry.Reply;
}
public override async Task WatchDeployEvents(
WatchDeployEventsRequest request,
IServerStreamWriter<DeployEvent> responseStream,
ServerCallContext context)
{
DateTimeOffset? lastSeen = request.LastSeenDeployTime?.ToDateTimeOffset();
await foreach (GalaxyDb.GalaxyDeployEventInfo info in notifier
.SubscribeAsync(context.CancellationToken)
.ConfigureAwait(false))
{
// Suppress the initial bootstrap event when the client already knows about
// this deploy time. We only suppress the first one — subsequent events fire
// on actual changes, so they always pass.
if (lastSeen is { } seen && info.TimeOfLastDeploy == seen)
{
lastSeen = null;
continue;
}
lastSeen = null;
await responseStream.WriteAsync(MapDeployEvent(info), context.CancellationToken).ConfigureAwait(false);
}
}
private async Task WaitForCacheBootstrap(CancellationToken cancellationToken)
{
if (cache.Current.HasData || cache.Current.Status == GalaxyDb.GalaxyCacheStatus.Unavailable)
{
return;
}
using CancellationTokenSource budget = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
budget.CancelAfter(FirstLoadWaitBudget);
try
{
await cache.WaitForFirstLoadAsync(budget.Token).ConfigureAwait(false);
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (OperationCanceledException)
{
// Budget elapsed; fall through and let the caller see the current
// (possibly Unknown/Unavailable) entry.
}
}
private static DeployEvent MapDeployEvent(GalaxyDb.GalaxyDeployEventInfo info)
{
DeployEvent ev = new()
{
Sequence = (ulong)info.Sequence,
ObservedAt = Timestamp.FromDateTimeOffset(info.ObservedAt),
ObjectCount = info.ObjectCount,
AttributeCount = info.AttributeCount,
TimeOfLastDeployPresent = info.TimeOfLastDeploy.HasValue,
};
if (info.TimeOfLastDeploy.HasValue)
{
ev.TimeOfLastDeploy = Timestamp.FromDateTimeOffset(info.TimeOfLastDeploy.Value);
}
return ev;
}
private static string ResolveUnavailableMessage(GalaxyDb.GalaxyHierarchyCacheEntry entry) => entry.Status switch
{
GalaxyDb.GalaxyCacheStatus.Unknown => "Galaxy cache has not completed its initial load yet.",
GalaxyDb.GalaxyCacheStatus.Unavailable => "Galaxy repository is unavailable.",
_ => "Galaxy cache has no data available.",
};
[System.Diagnostics.CodeAnalysis.SuppressMessage(
"Style",
"IDE0051:Remove unused private members",
Justification = "Kept for parity with prior SQL exception mapping; future direct-SQL paths reuse it.")]
private RpcException MapSqlException(SqlException exception)
{
logger.LogWarning(exception, "Galaxy repository query failed.");
return new RpcException(new Status(
StatusCode.Unavailable,
"Galaxy repository is unavailable."));
}
}
@@ -1,6 +1,8 @@
using System.Diagnostics;
using Grpc.Core;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -13,6 +15,7 @@ public sealed class MxAccessGatewayService(
MxAccessGrpcRequestValidator requestValidator,
MxAccessGrpcMapper mapper,
IEventStreamService eventStreamService,
GatewayMetrics metrics,
ILogger<MxAccessGatewayService> logger) : MxAccessGateway.MxAccessGatewayBase
{
public override async Task<OpenSessionReply> OpenSession(
@@ -110,7 +113,9 @@ public sealed class MxAccessGatewayService(
.WithCancellation(context.CancellationToken)
.ConfigureAwait(false))
{
Stopwatch stopwatch = Stopwatch.StartNew();
await responseStream.WriteAsync(publicEvent).ConfigureAwait(false);
metrics.RecordEventStreamSend(publicEvent.Family.ToString(), stopwatch.Elapsed);
}
}
catch (Exception exception) when (exception is not RpcException)
@@ -219,19 +219,6 @@ public sealed class GatewayMetrics : IDisposable
}
}
public void SetGrpcEventStreamQueueDepth(int depth)
{
if (depth < 0)
{
throw new ArgumentOutOfRangeException(nameof(depth), depth, "Queue depth cannot be negative.");
}
lock (_syncRoot)
{
_grpcEventStreamQueueDepth = depth;
}
}
public void AdjustGrpcEventStreamQueueDepth(int delta)
{
lock (_syncRoot)
@@ -7,6 +7,7 @@
<ItemGroup>
<PackageReference Include="Grpc.AspNetCore" Version="2.76.0" />
<PackageReference Include="Microsoft.Data.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.Data.SqlClient" Version="6.0.2" />
<PackageReference Include="Polly.Core" Version="8.6.6" />
</ItemGroup>
@@ -1,4 +1,5 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
namespace MxGateway.Server.Security.Authorization;
@@ -12,6 +13,10 @@ public sealed class GatewayGrpcScopeResolver
CloseSessionRequest => GatewayScopes.SessionClose,
StreamEventsRequest => GatewayScopes.EventsRead,
MxCommandRequest commandRequest => ResolveCommandScope(commandRequest.Command?.Kind ?? MxCommandKind.Unspecified),
TestConnectionRequest or
GetLastDeployTimeRequest or
DiscoverHierarchyRequest or
WatchDeployEventsRequest => GatewayScopes.MetadataRead,
_ => GatewayScopes.Admin
};
}
+5
View File
@@ -43,6 +43,11 @@
},
"Protocol": {
"WorkerProtocolVersion": 1
},
"Galaxy": {
"ConnectionString": "Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;",
"CommandTimeoutSeconds": 60,
"DashboardRefreshIntervalSeconds": 30
}
}
}
@@ -0,0 +1,90 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyDeployNotifierTests
{
[Fact]
public async Task SubscribeAsync_NoLatestEvent_BlocksUntilPublish()
{
GalaxyDeployNotifier notifier = new();
using CancellationTokenSource cts = new();
IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
ValueTask<bool> moveNext = enumerator.MoveNextAsync();
Assert.False(moveNext.IsCompleted);
GalaxyDeployEventInfo published = new(
Sequence: 1,
ObservedAt: DateTimeOffset.UtcNow,
TimeOfLastDeploy: DateTimeOffset.UtcNow,
ObjectCount: 5,
AttributeCount: 25);
notifier.Publish(published);
Assert.True(await moveNext.AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(published, enumerator.Current);
await cts.CancelAsync();
await enumerator.DisposeAsync();
}
[Fact]
public async Task SubscribeAsync_WithLatestEvent_BootstrapsImmediately()
{
GalaxyDeployNotifier notifier = new();
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, DateTimeOffset.UtcNow, 3, 9);
notifier.Publish(first);
using CancellationTokenSource cts = new();
await using IAsyncEnumerator<GalaxyDeployEventInfo> enumerator = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
Assert.True(await enumerator.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(first, enumerator.Current);
await cts.CancelAsync();
}
[Fact]
public async Task Publish_FansOutToAllSubscribers()
{
GalaxyDeployNotifier notifier = new();
using CancellationTokenSource cts = new();
await using IAsyncEnumerator<GalaxyDeployEventInfo> a = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
await using IAsyncEnumerator<GalaxyDeployEventInfo> b = notifier
.SubscribeAsync(cts.Token)
.GetAsyncEnumerator(cts.Token);
GalaxyDeployEventInfo info = new(1, DateTimeOffset.UtcNow, null, 0, 0);
notifier.Publish(info);
Assert.True(await a.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.True(await b.MoveNextAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(1)));
Assert.Same(info, a.Current);
Assert.Same(info, b.Current);
await cts.CancelAsync();
}
[Fact]
public void Latest_TracksMostRecentPublish()
{
GalaxyDeployNotifier notifier = new();
Assert.Null(notifier.Latest);
GalaxyDeployEventInfo first = new(1, DateTimeOffset.UtcNow, null, 0, 0);
GalaxyDeployEventInfo second = new(2, DateTimeOffset.UtcNow, null, 0, 0);
notifier.Publish(first);
notifier.Publish(second);
Assert.Same(second, notifier.Latest);
}
}
@@ -0,0 +1,74 @@
using MxGateway.Server.Galaxy;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyHierarchyCacheTests
{
[Fact]
public void Current_BeforeAnyRefresh_ReturnsEmpty()
{
GalaxyDeployNotifier notifier = new();
GalaxyHierarchyCache cache = CreateCache(notifier, new ManualTimeProvider());
GalaxyHierarchyCacheEntry entry = cache.Current;
Assert.Equal(GalaxyCacheStatus.Unknown, entry.Status);
Assert.False(entry.HasData);
Assert.Equal(0, entry.ObjectCount);
Assert.Null(entry.Reply);
}
[Fact]
public async Task RefreshAsync_WhenSqlIsUnreachable_MarksUnavailableAndDoesNotPublish()
{
GalaxyDeployNotifier notifier = new();
ManualTimeProvider clock = new(DateTimeOffset.Parse("2026-04-28T12:00:00Z"));
GalaxyHierarchyCache cache = CreateCache(notifier, clock);
await cache.RefreshAsync(CancellationToken.None);
Assert.Equal(GalaxyCacheStatus.Unavailable, cache.Current.Status);
Assert.False(string.IsNullOrWhiteSpace(cache.Current.LastError));
Assert.Null(notifier.Latest);
Assert.True(cache.WaitForFirstLoadAsync(CancellationToken.None).IsCompletedSuccessfully);
}
[Fact]
public void HasData_OnHealthyEntry_IsTrue()
{
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
LastSuccessAt = DateTimeOffset.UtcNow,
ObjectCount = 1,
};
Assert.True(entry.HasData);
}
[Fact]
public void HasData_OnUnknownEntry_IsFalse()
{
Assert.False(GalaxyHierarchyCacheEntry.Empty.HasData);
}
private static GalaxyHierarchyCache CreateCache(GalaxyDeployNotifier notifier, TimeProvider clock)
{
GalaxyRepositoryOptions options = new()
{
ConnectionString = "Server=127.0.0.1,65500;Database=ZB;Connection Timeout=1;Encrypt=False;",
CommandTimeoutSeconds = 1,
};
GalaxyRepository repository = new(options);
return new GalaxyHierarchyCache(repository, notifier, clock);
}
private sealed class ManualTimeProvider(DateTimeOffset start = default) : TimeProvider
{
private DateTimeOffset _now = start == default ? DateTimeOffset.UtcNow : start;
public override DateTimeOffset GetUtcNow() => _now;
public void Advance(TimeSpan duration) => _now += duration;
}
}
@@ -0,0 +1,109 @@
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Grpc;
namespace MxGateway.Tests.Galaxy;
public sealed class GalaxyProtoMapperTests
{
[Fact]
public void MapAttribute_PreservesAllScalarFields()
{
GalaxyAttributeRow row = new()
{
GobjectId = 42,
TagName = "Pump_001",
AttributeName = "Speed",
FullTagReference = "Pump_001.Speed",
MxDataType = 3,
DataTypeName = "Float",
IsArray = false,
ArrayDimension = null,
MxAttributeCategory = 5,
SecurityClassification = 2,
IsHistorized = true,
IsAlarm = false,
};
GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row);
Assert.Equal("Speed", proto.AttributeName);
Assert.Equal("Pump_001.Speed", proto.FullTagReference);
Assert.Equal(3, proto.MxDataType);
Assert.Equal("Float", proto.DataTypeName);
Assert.False(proto.IsArray);
Assert.Equal(0, proto.ArrayDimension);
Assert.False(proto.ArrayDimensionPresent);
Assert.Equal(5, proto.MxAttributeCategory);
Assert.Equal(2, proto.SecurityClassification);
Assert.True(proto.IsHistorized);
Assert.False(proto.IsAlarm);
}
[Fact]
public void MapAttribute_ArrayDimensionPresentFlag_DistinguishesNullFromZero()
{
GalaxyAttributeRow withDim = new() { ArrayDimension = 0, IsArray = true };
GalaxyAttributeRow withoutDim = new() { ArrayDimension = null, IsArray = false };
Assert.True(GalaxyProtoMapper.MapAttribute(withDim).ArrayDimensionPresent);
Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withDim).ArrayDimension);
Assert.False(GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimensionPresent);
Assert.Equal(0, GalaxyProtoMapper.MapAttribute(withoutDim).ArrayDimension);
}
[Fact]
public void MapAttribute_NullDataTypeName_BecomesEmptyString()
{
GalaxyAttributeRow row = new() { DataTypeName = null };
GalaxyAttribute proto = GalaxyProtoMapper.MapAttribute(row);
Assert.Equal(string.Empty, proto.DataTypeName);
}
[Fact]
public void MapHierarchy_GroupsAttributesByGobjectId()
{
List<GalaxyHierarchyRow> hierarchy =
[
new() { GobjectId = 1, TagName = "A", BrowseName = "A", TemplateChain = ["RootTpl"] },
new() { GobjectId = 2, TagName = "B", BrowseName = "B", ParentGobjectId = 1 },
new() { GobjectId = 3, TagName = "C", BrowseName = "C", ParentGobjectId = 1 },
];
List<GalaxyAttributeRow> attributes =
[
new() { GobjectId = 1, AttributeName = "X", FullTagReference = "A.X" },
new() { GobjectId = 2, AttributeName = "Y1", FullTagReference = "B.Y1" },
new() { GobjectId = 2, AttributeName = "Y2", FullTagReference = "B.Y2" },
];
List<GalaxyObject> result = GalaxyProtoMapper.MapHierarchy(hierarchy, attributes).ToList();
Assert.Equal(3, result.Count);
Assert.Single(result[0].Attributes);
Assert.Equal("X", result[0].Attributes[0].AttributeName);
Assert.Equal(2, result[1].Attributes.Count);
Assert.Empty(result[2].Attributes);
}
[Fact]
public void MapObject_CopiesTemplateChain()
{
GalaxyHierarchyRow row = new()
{
GobjectId = 5,
TagName = "Engine_001",
ContainedName = "Engine",
BrowseName = "Engine",
TemplateChain = ["EngineTpl", "AppEngineBase"],
};
GalaxyObject proto = GalaxyProtoMapper.MapObject(
row,
new Dictionary<int, List<GalaxyAttributeRow>>());
Assert.Equal(new[] { "EngineTpl", "AppEngineBase" }, proto.TemplateChain);
}
}
@@ -2,6 +2,7 @@ using Microsoft.Extensions.Options;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Configuration;
using MxGateway.Server.Dashboard;
using MxGateway.Server.Galaxy;
using MxGateway.Server.Metrics;
using MxGateway.Server.Sessions;
using MxGateway.Server.Workers;
@@ -171,6 +172,51 @@ public sealed class DashboardSnapshotServiceTests
Assert.Equal("session-newer", Assert.Single(snapshot.Faults).SessionId);
}
[Fact]
public void GetSnapshot_ProjectsGalaxySummaryFromHierarchyCache()
{
GalaxyHierarchyCacheEntry entry = GalaxyHierarchyCacheEntry.Empty with
{
Status = GalaxyCacheStatus.Healthy,
Sequence = 7,
LastQueriedAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastSuccessAt = DateTimeOffset.Parse("2026-04-28T11:30:00Z"),
LastDeployTime = DateTimeOffset.Parse("2026-04-28T09:00:00Z"),
Hierarchy =
[
new GalaxyHierarchyRow { GobjectId = 1, TagName = "Pump_001", BrowseName = "Pump_001", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
new GalaxyHierarchyRow { GobjectId = 2, TagName = "Pump_002", BrowseName = "Pump_002", CategoryId = 10, IsArea = false, TemplateChain = ["$Pump"] },
new GalaxyHierarchyRow { GobjectId = 3, TagName = "Area_A", BrowseName = "Area_A", CategoryId = 13, IsArea = true, TemplateChain = ["$Area"] },
],
Attributes =
[
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Speed", IsHistorized = true },
new GalaxyAttributeRow { GobjectId = 1, AttributeName = "Status", IsAlarm = true },
],
ObjectCount = 3,
AreaCount = 1,
AttributeCount = 2,
HistorizedAttributeCount = 1,
AlarmAttributeCount = 1,
};
using GatewayMetrics metrics = new();
DashboardSnapshotService service = CreateService(
new SessionRegistry(),
metrics,
galaxyHierarchyCache: new StubGalaxyHierarchyCache(entry));
DashboardSnapshot snapshot = service.GetSnapshot();
Assert.Equal(DashboardGalaxyStatus.Healthy, snapshot.Galaxy.Status);
Assert.Equal(3, snapshot.Galaxy.ObjectCount);
Assert.Equal(1, snapshot.Galaxy.AreaCount);
Assert.Equal(2, snapshot.Galaxy.AttributeCount);
Assert.Equal("$Pump", Assert.Single(snapshot.Galaxy.TopTemplates, t => t.TemplateName == "$Pump").TemplateName);
Assert.Equal(2, snapshot.Galaxy.TopTemplates.First(t => t.TemplateName == "$Pump").InstanceCount);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "UserDefined" && c.ObjectCount == 2);
Assert.Contains(snapshot.Galaxy.ObjectCategories, c => c.CategoryName == "Area" && c.ObjectCount == 1);
}
[Fact]
public async Task WatchSnapshotsAsync_WhenSubscriberCancels_DisposesCleanly()
{
@@ -200,7 +246,8 @@ public sealed class DashboardSnapshotServiceTests
private static DashboardSnapshotService CreateService(
SessionRegistry registry,
GatewayMetrics metrics,
GatewayOptions? options = null)
GatewayOptions? options = null,
IGalaxyHierarchyCache? galaxyHierarchyCache = null)
{
GatewayOptions resolvedOptions = options ?? new GatewayOptions
{
@@ -215,9 +262,19 @@ public sealed class DashboardSnapshotServiceTests
registry,
metrics,
configurationProvider,
galaxyHierarchyCache ?? new StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry.Empty),
Options.Create(resolvedOptions));
}
private sealed class StubGalaxyHierarchyCache(GalaxyHierarchyCacheEntry current) : IGalaxyHierarchyCache
{
public GalaxyHierarchyCacheEntry Current { get; } = current;
public Task RefreshAsync(CancellationToken cancellationToken) => Task.CompletedTask;
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
private static GatewaySession CreateSession(
string sessionId,
string? clientIdentity,
@@ -174,6 +174,7 @@ public sealed class GatewayEndToEndFakeWorkerSmokeTests
new MxAccessGrpcRequestValidator(),
mapper,
eventStreamService,
_metrics,
NullLogger<MxAccessGatewayService>.Instance);
}
@@ -1,3 +1,4 @@
using System.Diagnostics.Metrics;
using System.Runtime.CompilerServices;
using Google.Protobuf.WellKnownTypes;
using Grpc.Core;
@@ -5,6 +6,7 @@ using Microsoft.Extensions.Logging.Abstractions;
using MxGateway.Contracts;
using MxGateway.Contracts.Proto;
using MxGateway.Server.Grpc;
using MxGateway.Server.Metrics;
using MxGateway.Server.Security.Authentication;
using MxGateway.Server.Security.Authorization;
using MxGateway.Server.Sessions;
@@ -163,6 +165,50 @@ public sealed class MxAccessGatewayServiceTests
Assert.Equal("session-1", sessionManager.LastReadEventsSessionId);
}
[Fact]
public async Task StreamEvents_WhenEventIsWritten_RecordsSendDuration()
{
using GatewayMetrics metrics = new();
using MeterListener listener = new();
List<string> families = [];
listener.InstrumentPublished = (instrument, meterListener) =>
{
if (instrument.Meter.Name == GatewayMetrics.MeterName
&& instrument.Name == "mxgateway.events.stream_send.duration")
{
meterListener.EnableMeasurementEvents(instrument);
}
};
listener.SetMeasurementEventCallback<double>(
(instrument, measurement, tags, _) =>
{
if (instrument.Name != "mxgateway.events.stream_send.duration")
{
return;
}
foreach (KeyValuePair<string, object?> tag in tags)
{
if (tag.Key == "family" && tag.Value is string family)
{
families.Add(family);
}
}
});
listener.Start();
FakeSessionManager sessionManager = new();
sessionManager.Events.Add(CreateWorkerEvent("session-1", workerSequence: 2));
MxAccessGatewayService service = CreateService(sessionManager, metrics: metrics);
TestServerStreamWriter<MxEvent> writer = new();
await service.StreamEvents(
new StreamEventsRequest { SessionId = "session-1" },
writer,
new TestServerCallContext());
Assert.Equal([MxEventFamily.OnDataChange.ToString()], families);
}
[Fact]
public async Task CloseSession_WithBlankSessionId_ThrowsInvalidArgument()
{
@@ -178,7 +224,8 @@ public sealed class MxAccessGatewayServiceTests
private static MxAccessGatewayService CreateService(
FakeSessionManager sessionManager,
IGatewayRequestIdentityAccessor? identityAccessor = null)
IGatewayRequestIdentityAccessor? identityAccessor = null,
GatewayMetrics? metrics = null)
{
return new MxAccessGatewayService(
sessionManager,
@@ -186,6 +233,7 @@ public sealed class MxAccessGatewayServiceTests
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new FakeEventStreamService(sessionManager),
metrics ?? new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance);
}
@@ -18,7 +18,7 @@ public sealed class GatewayMetricsTests
metrics.EventReceived("session-1", "OnDataChange");
metrics.EventReceived("session-1", "OnDataChange");
metrics.SetWorkerEventQueueDepth(7);
metrics.SetGrpcEventStreamQueueDepth(3);
metrics.AdjustGrpcEventStreamQueueDepth(3);
metrics.QueueOverflow("session-events");
metrics.Fault("CommandTimeout");
metrics.WorkerKilled("CommandTimeout");
@@ -1,4 +1,5 @@
using MxGateway.Contracts.Proto;
using MxGateway.Contracts.Proto.Galaxy;
using MxGateway.Server.Security.Authorization;
namespace MxGateway.Tests.Security.Authorization;
@@ -9,6 +10,9 @@ public sealed class GatewayGrpcScopeResolverTests
[InlineData(typeof(OpenSessionRequest), GatewayScopes.SessionOpen)]
[InlineData(typeof(CloseSessionRequest), GatewayScopes.SessionClose)]
[InlineData(typeof(StreamEventsRequest), GatewayScopes.EventsRead)]
[InlineData(typeof(TestConnectionRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(GetLastDeployTimeRequest), GatewayScopes.MetadataRead)]
[InlineData(typeof(DiscoverHierarchyRequest), GatewayScopes.MetadataRead)]
public void ResolveRequiredScope_KnownRpcRequest_ReturnsExpectedScope(
Type requestType,
string expectedScope)