Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a1156960b9 |
@@ -100,7 +100,7 @@ When source code changes, build and test the affected component before reporting
|
|||||||
## Design Sources To Consult Before Non-Trivial Changes
|
## Design Sources To Consult Before Non-Trivial Changes
|
||||||
|
|
||||||
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
- `gateway.md` — top-level architecture, command/event surface, IPC envelope, STA thread model, fault handling.
|
||||||
- `glauth.md` — shared GLAuth LDAP server (`10.100.0.35:3893`, base DN `dc=zb,dc=local`, source of truth `scadaproj/infra/glauth/`) used for dev authn. Dashboard test users (`multi-role`/`password` = Administrator, `gw-viewer`/`password` = Viewer) and the role→capability mapping live there.
|
- `glauth.md` — local LDAP server (GLAuth on `localhost:3893`, base DN `dc=zb,dc=local`) used for dev authn. Pre-provisioned users (`admin/admin123`, `readonly/readonly123`, etc.) and the role→capability mapping live there.
|
||||||
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
- `docs/DesignDecisions.md` — v1 choices (MXAccess COM target `LMXProxyServerClass` from `C:\Program Files (x86)\ArchestrA\Framework\Bin\ArchestrA.MXAccess.dll`, API-key-in-SQLite auth, fail-fast event backpressure, etc.).
|
||||||
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
- `docs/GatewayProcessDesign.md`, `docs/MxAccessWorkerInstanceDesign.md`, `docs/WorkerFrameProtocol.md`, `docs/WorkerProcessLauncher.md` — detailed component designs.
|
||||||
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
- `docs/GatewayConfiguration.md` — full `MxGateway:*` options bound by `GatewayOptions` and validated at startup by `GatewayOptionsValidator`.
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
/// <summary>Returns whether the named flag was present in the arguments.</summary>
|
||||||
/// <param name="name">The flag name (without '--' prefix).</param>
|
/// <param name="name">The flag name (without '--' prefix).</param>
|
||||||
|
/// <returns>True if the flag was present; otherwise false.</returns>
|
||||||
public bool HasFlag(string name)
|
public bool HasFlag(string name)
|
||||||
{
|
{
|
||||||
return _flags.Contains(name);
|
return _flags.Contains(name);
|
||||||
@@ -51,6 +52,7 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
/// <summary>Returns the value for a named argument, or <c>null</c> if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <returns>The argument value, or null if the argument was not provided.</returns>
|
||||||
public string? GetOptional(string name)
|
public string? GetOptional(string name)
|
||||||
{
|
{
|
||||||
return _values.TryGetValue(name, out string? value)
|
return _values.TryGetValue(name, out string? value)
|
||||||
@@ -60,6 +62,7 @@ internal sealed class CliArguments
|
|||||||
|
|
||||||
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
/// <summary>Returns the value for a required named argument, or throws if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
|
/// <returns>The argument value.</returns>
|
||||||
public string GetRequired(string name)
|
public string GetRequired(string name)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -74,6 +77,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns an int32 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
/// <param name="defaultValue">The default value if the argument is absent; if <c>null</c>, the argument is required.</param>
|
||||||
|
/// <returns>The parsed int32 value, or the default if absent.</returns>
|
||||||
public int GetInt32(string name, int? defaultValue = null)
|
public int GetInt32(string name, int? defaultValue = null)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -93,6 +97,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns a uint32 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
/// <returns>The parsed uint32 value, or the default if absent.</returns>
|
||||||
public uint GetUInt32(string name, uint defaultValue)
|
public uint GetUInt32(string name, uint defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -104,6 +109,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
/// <summary>Parses and returns a uint64 argument, or the default value if absent.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
/// <returns>The parsed uint64 value, or the default if absent.</returns>
|
||||||
public ulong GetUInt64(string name, ulong defaultValue)
|
public ulong GetUInt64(string name, ulong defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
@@ -115,6 +121,7 @@ internal sealed class CliArguments
|
|||||||
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
/// <summary>Parses and returns a TimeSpan argument, or the default value if absent. Supports "ms", "s", and standard TimeSpan format.</summary>
|
||||||
/// <param name="name">The argument name (without '--' prefix).</param>
|
/// <param name="name">The argument name (without '--' prefix).</param>
|
||||||
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
/// <param name="defaultValue">The default value if the argument is absent.</param>
|
||||||
|
/// <returns>The parsed TimeSpan value, or the default if absent.</returns>
|
||||||
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
public TimeSpan GetDuration(string name, TimeSpan defaultValue)
|
||||||
{
|
{
|
||||||
string? value = GetOptional(name);
|
string? value = GetOptional(name);
|
||||||
|
|||||||
@@ -100,7 +100,8 @@ internal sealed class MxGatewayCliClientAdapter : IMxGatewayCliClient
|
|||||||
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
return _galaxyClient.Value.WatchDeployEventsRawAsync(request, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Disposes the galaxy client (if created) and the underlying gateway client.</summary>
|
||||||
|
/// <returns>A value task that completes when both clients are disposed.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_galaxyClient.IsValueCreated)
|
if (_galaxyClient.IsValueCreated)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ internal static class MxGatewayCliSecretRedactor
|
|||||||
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
/// <summary>Replaces occurrences of the API key in the value with a redacted placeholder.</summary>
|
||||||
/// <param name="value">The message text to redact.</param>
|
/// <param name="value">The message text to redact.</param>
|
||||||
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
/// <param name="apiKey">The API key to remove; no redaction if null or empty.</param>
|
||||||
|
/// <returns>The message text with any API key occurrence replaced by <c>[redacted]</c>.</returns>
|
||||||
public static string Redact(string value, string? apiKey)
|
public static string Redact(string value, string? apiKey)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
if (string.IsNullOrEmpty(value) || string.IsNullOrEmpty(apiKey))
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ public static class MxGatewayClientCli
|
|||||||
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
/// <param name="args">Command-line arguments (command name followed by options).</param>
|
||||||
/// <param name="standardOutput">TextWriter for command output.</param>
|
/// <param name="standardOutput">TextWriter for command output.</param>
|
||||||
/// <param name="standardError">TextWriter for error messages.</param>
|
/// <param name="standardError">TextWriter for error messages.</param>
|
||||||
|
/// <returns>The process exit code (0 for success, 1 for error).</returns>
|
||||||
public static int Run(
|
public static int Run(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
@@ -38,6 +39,7 @@ public static class MxGatewayClientCli
|
|||||||
/// <param name="standardError">TextWriter for error messages.</param>
|
/// <param name="standardError">TextWriter for error messages.</param>
|
||||||
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
/// <param name="clientFactory">Optional factory to create the gateway client; defaults to MxGatewayClient.Create.</param>
|
||||||
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
/// <param name="standardInput">Optional TextReader for batch-mode stdin; defaults to <see cref="Console.In"/>.</param>
|
||||||
|
/// <returns>A task that resolves to the process exit code (0 for success, 1 for error).</returns>
|
||||||
public static Task<int> RunAsync(
|
public static Task<int> RunAsync(
|
||||||
string[] args,
|
string[] args,
|
||||||
TextWriter standardOutput,
|
TextWriter standardOutput,
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public sealed class BrowseChildrenSmokeTests
|
|||||||
/// Verifies that BrowseChildren returns a non-zero cache sequence and
|
/// Verifies that BrowseChildren returns a non-zero cache sequence and
|
||||||
/// a consistent children/child-has-children count from a live gateway.
|
/// a consistent children/child-has-children count from a live gateway.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
|
[Fact(Skip = "Set MXGATEWAY_API_KEY and MXGATEWAY_ENDPOINT to enable.")]
|
||||||
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
|
public async Task BrowseChildren_LiveGateway_ReturnsRootsWithCacheSequence()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -8,14 +8,10 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions options) : IGalaxyRepositoryClientTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the raw gRPC client; always null for the fake.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
public GalaxyRepository.GalaxyRepositoryClient? RawClient => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -66,11 +62,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
public Queue<Exception> DiscoverHierarchyExceptions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The TestConnectionRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<TestConnectionReply> TestConnectionAsync(
|
public Task<TestConnectionReply> TestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -84,11 +76,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
return Task.FromResult(TestConnectionReply);
|
return Task.FromResult(TestConnectionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The GetLastDeployTimeRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
public Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
GetLastDeployTimeRequest request,
|
GetLastDeployTimeRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -102,11 +90,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
return Task.FromResult(GetLastDeployTimeReply);
|
return Task.FromResult(GetLastDeployTimeReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The DiscoverHierarchyRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
public Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -135,11 +119,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
|
/// <summary>Queue of exceptions to throw from BrowseChildren; dequeued in FIFO order.</summary>
|
||||||
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
|
public Queue<Exception> BrowseChildrenExceptions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and either throws a queued exception or returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The BrowseChildrenRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<BrowseChildrenReply> BrowseChildrenAsync(
|
public Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -177,11 +157,7 @@ internal sealed class FakeGalaxyRepositoryTransport(MxGatewayClientOptions optio
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
public Func<CancellationToken, Task>? WatchDeployEventsBeforeYield { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the request and streams events, checking for queued exceptions and calling WatchDeployEventsBeforeYield before each event.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The WatchDeployEventsRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
|
|||||||
@@ -11,14 +11,10 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
private readonly Queue<MxCommandReply> _invokeReplies = new();
|
||||||
private readonly List<MxEvent> _events = [];
|
private readonly List<MxEvent> _events = [];
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets null, since this is a test fake without a real gRPC client.
|
|
||||||
/// </summary>
|
|
||||||
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
public MxAccessGateway.MxAccessGatewayClient? RawClient => null;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -102,11 +98,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public Queue<Exception> InvokeExceptions { get; } = new();
|
public Queue<Exception> InvokeExceptions { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the OpenSessionAsync call is recorded and returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The OpenSessionRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<OpenSessionReply> OpenSessionAsync(
|
public Task<OpenSessionReply> OpenSessionAsync(
|
||||||
OpenSessionRequest request,
|
OpenSessionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -120,11 +112,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
return Task.FromResult(OpenSessionReply);
|
return Task.FromResult(OpenSessionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the CloseSessionAsync call is recorded and returns the configured reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The CloseSessionRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<CloseSessionReply> CloseSessionAsync(
|
public Task<CloseSessionReply> CloseSessionAsync(
|
||||||
CloseSessionRequest request,
|
CloseSessionRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -138,11 +126,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
return Task.FromResult(CloseSessionReply);
|
return Task.FromResult(CloseSessionReply);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the InvokeAsync call is recorded and returns the next enqueued reply.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The MxCommandRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<MxCommandReply> InvokeAsync(
|
public Task<MxCommandReply> InvokeAsync(
|
||||||
MxCommandRequest request,
|
MxCommandRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -156,11 +140,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
return Task.FromResult(_invokeReplies.Dequeue());
|
return Task.FromResult(_invokeReplies.Dequeue());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Verifies that the StreamEventsAsync call is recorded and yields all enqueued events.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The StreamEventsRequest to process.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -193,11 +173,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
_events.Add(gatewayEvent);
|
_events.Add(gatewayEvent);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the acknowledge call and returns the next enqueued reply (or default).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The acknowledge alarm request.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
public Task<AcknowledgeAlarmReply> AcknowledgeAlarmAsync(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -218,11 +194,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the query call and yields each enqueued snapshot.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The query active alarms request.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
@@ -251,11 +223,7 @@ internal sealed class FakeGatewayTransport(MxGatewayClientOptions options) : IMx
|
|||||||
_activeAlarmSnapshots.Add(snapshot);
|
_activeAlarmSnapshots.Add(snapshot);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Records the stream-alarms call and yields each enqueued feed message.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">The stream alarms request.</param>
|
|
||||||
/// <param name="callOptions">Call options specifying RPC behavior.</param>
|
|
||||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions)
|
CallOptions callOptions)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
/// Verifies that TestConnectionAsync attaches the API key in request metadata and returns the Ok flag.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
public async Task TestConnectionAsync_AttachesApiKeyMetadataAndReturnsOkFlag()
|
||||||
{
|
{
|
||||||
@@ -27,6 +28,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
/// Verifies that TestConnectionAsync returns false when the server reports NotOk.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
public async Task TestConnectionAsync_ReturnsFalseWhenServerReportsNotOk()
|
||||||
{
|
{
|
||||||
@@ -42,6 +44,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
/// Verifies that GetLastDeployTimeAsync returns null when the server reports not present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
public async Task GetLastDeployTimeAsync_ReturnsNullWhenNotPresent()
|
||||||
{
|
{
|
||||||
@@ -58,6 +61,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
/// Verifies that GetLastDeployTimeAsync returns the timestamp when the server reports it present.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
public async Task GetLastDeployTimeAsync_ReturnsTimestampWhenPresent()
|
||||||
{
|
{
|
||||||
@@ -79,6 +83,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
/// Verifies that DiscoverHierarchyAsync returns the objects from the server reply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
public async Task DiscoverHierarchyAsync_ReturnsObjectsFromReply()
|
||||||
{
|
{
|
||||||
@@ -141,6 +146,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
/// Verifies that DiscoverHierarchyAsync propagates cancellation tokens to the transport.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
public async Task DiscoverHierarchyAsync_PropagatesCancellationToTransport()
|
||||||
{
|
{
|
||||||
@@ -161,6 +167,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
public async Task DiscoverHierarchyAsync_WithRepeatedPageToken_ThrowsProtocolError()
|
||||||
{
|
{
|
||||||
@@ -184,6 +191,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
|
/// Verifies that DiscoverHierarchyAsync maps typed filter options correctly to the request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
public async Task DiscoverHierarchyAsync_WithOptions_MapsTypedFilters()
|
||||||
{
|
{
|
||||||
@@ -218,6 +226,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
/// Verifies that TestConnectionAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
public async Task TestConnectionAsync_RetriesOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -235,6 +244,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
/// Verifies that DiscoverHierarchyAsync retries on transient gRPC failures.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
public async Task DiscoverHierarchyAsync_RetriesOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -251,6 +261,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
/// Verifies that WatchDeployEventsAsync delivers the bootstrap event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
public async Task WatchDeployEventsAsync_DeliversBootstrapEvent()
|
||||||
{
|
{
|
||||||
@@ -287,6 +298,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
/// Verifies that WatchDeployEventsAsync delivers multiple events in order.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
public async Task WatchDeployEventsAsync_DeliversMultipleEventsInOrder()
|
||||||
{
|
{
|
||||||
@@ -325,6 +337,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
/// Verifies that WatchDeployEventsAsync stops iteration cleanly when cancelled.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
public async Task WatchDeployEventsAsync_CancellationStopsIterationCleanly()
|
||||||
{
|
{
|
||||||
@@ -369,6 +382,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
/// Verifies that WatchDeployEventsAsync throws ObjectDisposedException after the client is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
public async Task WatchDeployEventsAsync_ThrowsAfterDisposal()
|
||||||
{
|
{
|
||||||
@@ -384,6 +398,7 @@ public sealed class GalaxyRepositoryClientTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
/// Verifies that TestConnectionAsync throws ObjectDisposedException after the client is disposed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
public async Task TestConnectionAsync_ThrowsAfterDisposal()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// Verifies that calling BrowseAsync with no parent returns the root nodes
|
/// Verifies that calling BrowseAsync with no parent returns the root nodes
|
||||||
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
|
/// from the first BrowseChildren reply and surfaces the per-child has-children hint.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Browse_NoParent_ReturnsRoots()
|
public async Task Browse_NoParent_ReturnsRoots()
|
||||||
{
|
{
|
||||||
@@ -36,6 +37,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
|
/// Verifies that ExpandAsync populates Children and marks the node expanded after one RPC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_PopulatesChildrenAndMarksExpanded()
|
public async Task Expand_PopulatesChildrenAndMarksExpanded()
|
||||||
{
|
{
|
||||||
@@ -62,6 +64,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
|
/// Verifies that a second ExpandAsync call is a no-op and issues no additional RPC.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_CalledTwice_NoSecondRpc()
|
public async Task Expand_CalledTwice_NoSecondRpc()
|
||||||
{
|
{
|
||||||
@@ -86,6 +89,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
|
/// Verifies that an RPC failure (NotFound) during expand is wrapped in MxGatewayException.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
|
public async Task Expand_UnknownParent_ThrowsMxGatewayException()
|
||||||
{
|
{
|
||||||
@@ -113,6 +117,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
|
/// Verifies that ExpandAsync drains multi-page sibling replies and forwards the page token.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_MultiPageSiblings_GathersAllPages()
|
public async Task Expand_MultiPageSiblings_GathersAllPages()
|
||||||
{
|
{
|
||||||
@@ -147,6 +152,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
/// Verifies that ten concurrent ExpandAsync calls issue exactly one RPC, not ten.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
public async Task Expand_CalledConcurrently_OnlyFiresOneRpc()
|
||||||
{
|
{
|
||||||
@@ -178,6 +184,7 @@ public sealed class LazyBrowseNodeTests
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
/// Verifies that BrowseChildrenOptions filter fields are forwarded to the BrowseChildren request.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Browse_WithFilter_ForwardsToRequest()
|
public async Task Browse_WithFilter_ForwardsToRequest()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
public sealed class MxGatewayClientAlarmsTests
|
public sealed class MxGatewayClientAlarmsTests
|
||||||
{
|
{
|
||||||
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
/// <summary>AcknowledgeAlarmAsync records request and returns reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
public async Task AcknowledgeAlarmAsync_RecordsRequestShapeAndReturnsReply()
|
||||||
{
|
{
|
||||||
@@ -48,6 +49,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
/// <summary>AcknowledgeAlarmAsync honors cancellation.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
public async Task AcknowledgeAlarmAsync_HonorsCancellation()
|
||||||
{
|
{
|
||||||
@@ -72,6 +74,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
/// <summary>AcknowledgeAlarmAsync maps unauthenticated RPC exception to typed exception.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
public async Task AcknowledgeAlarmAsync_MapsUnauthenticated_RpcException_ToTypedException()
|
||||||
{
|
{
|
||||||
@@ -97,6 +100,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
/// <summary>QueryActiveAlarmsAsync streams enqueued snapshots.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
public async Task QueryActiveAlarmsAsync_StreamsEnqueuedSnapshots()
|
||||||
{
|
{
|
||||||
@@ -122,6 +126,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
/// <summary>QueryActiveAlarmsAsync passes filter prefix.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
public async Task QueryActiveAlarmsAsync_PassesFilterPrefix()
|
||||||
{
|
{
|
||||||
@@ -142,6 +147,7 @@ public sealed class MxGatewayClientAlarmsTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
/// <summary>QueryActiveAlarmsAsync honors cancellation during enumeration.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
public async Task QueryActiveAlarmsAsync_HonorsCancellationDuringEnumeration()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
/// <summary>Verifies that the version command with --json flag prints JSON protocol versions.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
public async Task RunAsync_VersionJson_PrintsJsonProtocolVersions()
|
||||||
{
|
{
|
||||||
@@ -38,6 +39,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
/// <summary>Verifies that the write command builds a write request and prints JSON reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
public async Task RunAsync_Write_BuildsWriteCommandAndPrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -83,6 +85,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
/// <summary>Verifies that error output redacts sensitive API key values.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
public async Task RunAsync_ErrorOutput_RedactsApiKey()
|
||||||
{
|
{
|
||||||
@@ -107,6 +110,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
/// <summary>Verifies that stream-events with max-events limit stops output in non-JSON format.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
public async Task RunAsync_StreamEvents_WithMaxEventsStopsNonJsonOutput()
|
||||||
{
|
{
|
||||||
@@ -149,6 +153,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
|
|
||||||
|
|
||||||
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
/// <summary>Verifies that stream-alarms with --max-events stops output and distinguishes payload cases.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
public async Task RunAsync_StreamAlarms_WithMaxEventsStopsAndDistinguishesPayloadCases()
|
||||||
{
|
{
|
||||||
@@ -188,6 +193,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
/// <summary>Verifies that acknowledge-alarm builds a request and prints the JSON reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
public async Task RunAsync_AcknowledgeAlarm_BuildsRequestAndPrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -230,6 +236,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
/// <summary>Verifies that smoke command closes opened session when a command fails.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
public async Task RunAsync_Smoke_WhenCommandFails_ClosesOpenedSession()
|
||||||
{
|
{
|
||||||
@@ -261,6 +268,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
/// <summary>Verifies that galaxy-test-connection command prints JSON reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
public async Task RunAsync_GalaxyTestConnection_PrintsJsonReply()
|
||||||
{
|
{
|
||||||
@@ -291,6 +299,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
/// <summary>Verifies that galaxy-discover command prints hierarchy summary.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
public async Task RunAsync_GalaxyDiscover_PrintsHierarchySummary()
|
||||||
{
|
{
|
||||||
@@ -361,6 +370,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
/// <summary>Verifies that galaxy-watch command prints text output for deploy events.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
public async Task RunAsync_GalaxyWatch_PrintsTextOutputForEvents()
|
||||||
{
|
{
|
||||||
@@ -415,6 +425,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
/// <summary>Verifies that galaxy-watch with --json emits one JSON object per event.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
public async Task RunAsync_GalaxyWatch_JsonEmitsOneObjectPerEvent()
|
||||||
{
|
{
|
||||||
@@ -450,6 +461,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
/// <summary>Verifies that batch mode dispatches a single version command and emits the EOR sentinel.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
public async Task RunAsync_Batch_DispatchesVersionAndWritesEndOfRecord()
|
||||||
{
|
{
|
||||||
@@ -476,6 +488,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
/// <summary>Verifies that batch mode routes per-command errors to stdout as JSON between EOR markers.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
public async Task RunAsync_Batch_WritesErrorsToStdoutAsJson()
|
||||||
{
|
{
|
||||||
@@ -520,6 +533,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// against exit code 0.
|
/// against exit code 0.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
/// <param name="command">The alarm subcommand to validate (e.g. "stream-alarms", "acknowledge-alarm").</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("stream-alarms")]
|
[InlineData("stream-alarms")]
|
||||||
[InlineData("acknowledge-alarm")]
|
[InlineData("acknowledge-alarm")]
|
||||||
@@ -574,6 +588,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// against a zero server handle. The fix must fail loudly with a
|
/// against a zero server handle. The fix must fail loudly with a
|
||||||
/// descriptive <see cref="MxGatewayException"/>.
|
/// descriptive <see cref="MxGatewayException"/>.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
public async Task RunAsync_BenchReadBulk_WhenRegisterReplyMissingTypedPayload_FailsLoudly()
|
||||||
{
|
{
|
||||||
@@ -624,6 +639,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
/// kept spinning until <c>--duration-seconds</c> elapsed. After the fix
|
||||||
/// the bench must exit promptly when the supplied token cancels.
|
/// the bench must exit promptly when the supplied token cancels.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
public async Task RunAsync_BenchReadBulk_WhenSteadyStateLoopReceivesCancellation_ExitsPromptly()
|
||||||
{
|
{
|
||||||
@@ -718,6 +734,7 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
/// to ~49.7 days. The fix must reject negatives with a clear error.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
/// <param name="command">The bulk-read subcommand to validate (e.g. "read-bulk", "bench-read-bulk").</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Theory]
|
[Theory]
|
||||||
[InlineData("read-bulk")]
|
[InlineData("read-bulk")]
|
||||||
[InlineData("bench-read-bulk")]
|
[InlineData("bench-read-bulk")]
|
||||||
@@ -880,7 +897,8 @@ public sealed class MxGatewayClientCliTests
|
|||||||
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
/// <summary>Optional per-call handler that overrides queue-based behaviour.</summary>
|
||||||
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
public Func<MxCommandRequest, CancellationToken, Task<MxCommandReply>>? InvokeHandler { get; init; }
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases resources held by the fake CLI client.</summary>
|
||||||
|
/// <returns>A completed value task.</returns>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
return ValueTask.CompletedTask;
|
return ValueTask.CompletedTask;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
public sealed class MxGatewayClientSessionTests
|
public sealed class MxGatewayClientSessionTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
/// <summary>Verifies that open session attaches API key metadata and cancellation token.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
public async Task OpenSessionRawAsync_AttachesApiKeyMetadataAndCancellation()
|
||||||
{
|
{
|
||||||
@@ -22,6 +23,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
/// <summary>Verifies that open session returns a session with the raw open reply.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
public async Task OpenSessionAsync_ReturnsSessionWithRawOpenReply()
|
||||||
{
|
{
|
||||||
@@ -37,6 +39,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
/// <summary>Verifies that register builds a register command and returns server handle.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
public async Task RegisterAsync_BuildsRegisterCommandAndReturnsServerHandle()
|
||||||
{
|
{
|
||||||
@@ -62,6 +65,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
/// <summary>Verifies that add item 2 builds a command with the specified context.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
public async Task AddItem2Async_BuildsAddItem2CommandWithContext()
|
||||||
{
|
{
|
||||||
@@ -87,6 +91,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
/// <summary>Verifies that write raw builds a write command with the raw value.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
public async Task WriteRawAsync_BuildsWriteCommandWithRawValue()
|
||||||
{
|
{
|
||||||
@@ -118,6 +123,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
/// <summary>Verifies that write 2 raw builds a write 2 command with value and timestamp.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
public async Task Write2RawAsync_BuildsWrite2CommandWithValueAndTimestamp()
|
||||||
{
|
{
|
||||||
@@ -146,6 +152,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
/// <summary>Verifies that subscribe bulk builds one command and returns per-item results.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
public async Task SubscribeBulkAsync_BuildsOneBulkCommandAndReturnsPerItemResults()
|
||||||
{
|
{
|
||||||
@@ -185,6 +192,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
/// <summary>Verifies that stream events yields events in the order received from the gateway.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
public async Task StreamEventsAsync_YieldsEventsInGatewayOrder()
|
||||||
{
|
{
|
||||||
@@ -216,6 +224,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
/// <summary>Verifies that close is explicit and idempotent.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task CloseAsync_IsExplicitAndIdempotent()
|
public async Task CloseAsync_IsExplicitAndIdempotent()
|
||||||
{
|
{
|
||||||
@@ -232,6 +241,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
/// <summary>Verifies that invoke retries safe diagnostic commands on transient RPC failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
public async Task InvokeAsync_RetriesSafeDiagnosticCommandOnTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -256,6 +266,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
/// <summary>Verifies that open session does not retry on transient RPC failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
public async Task OpenSessionAsync_DoesNotRetryTransientGrpcFailure()
|
||||||
{
|
{
|
||||||
@@ -269,6 +280,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
/// <summary>Verifies that invoke does not retry write commands on transient RPC failure.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
public async Task InvokeAsync_DoesNotRetryWriteCommand()
|
||||||
{
|
{
|
||||||
@@ -284,6 +296,7 @@ public sealed class MxGatewayClientSessionTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
/// <summary>Verifies that invoke helpers pass cancellation token to the transport.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
public async Task InvokeHelpers_PassCancellationTokenToTransport()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ namespace ZB.MOM.WW.MxGateway.Client.Tests;
|
|||||||
public sealed class MxGatewayGeneratedContractTests
|
public sealed class MxGatewayGeneratedContractTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
/// <summary>Verifies that the generated gRPC client can be instantiated from the client factory.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
public async Task GeneratedGrpcClient_CanBeConstructedFromClientFactory()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -337,6 +337,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>Builds a <see cref="BrowseChildrenRequest"/> from the provided options.</summary>
|
||||||
|
/// <param name="options">Browse children options to convert.</param>
|
||||||
|
/// <returns>The constructed request message.</returns>
|
||||||
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
|
internal static BrowseChildrenRequest BuildBrowseChildrenRequest(BrowseChildrenOptions options)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
ArgumentNullException.ThrowIfNull(options);
|
||||||
@@ -424,6 +427,7 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the gRPC channel and releases resources.
|
/// Closes the gRPC channel and releases resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -493,6 +497,9 @@ public sealed class GalaxyRepositoryClient : IAsyncDisposable
|
|||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
CreateHttpHandlerForTests(options);
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
||||||
|
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
||||||
|
/// <returns>The configured HTTP message handler.</returns>
|
||||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
MxGatewayClientOptions options,
|
MxGatewayClientOptions options,
|
||||||
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
GalaxyRepository.GalaxyRepositoryClient rawClient) : IGalaxyRepositoryClientTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -91,7 +89,11 @@ internal sealed class GrpcGalaxyRepositoryClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Streams deploy events from the Galaxy Repository, using an explicit cancellation token that overrides the call options token when provided.</summary>
|
||||||
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
|
/// <param name="callOptions">Call options for the underlying gRPC call.</param>
|
||||||
|
/// <param name="cancellationToken">Optional cancellation token; takes precedence over the token in <paramref name="callOptions"/> when cancellable.</param>
|
||||||
|
/// <returns>An async enumerable of deploy events.</returns>
|
||||||
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
public async IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
|
|||||||
@@ -10,9 +10,7 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
MxGatewayClientOptions options,
|
MxGatewayClientOptions options,
|
||||||
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
MxAccessGateway.MxAccessGatewayClient rawClient) : IMxGatewayClientTransport
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets the gateway client options.
|
|
||||||
/// </summary>
|
|
||||||
public MxGatewayClientOptions Options { get; } = options;
|
public MxGatewayClientOptions Options { get; } = options;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -74,7 +72,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Streams MXAccess events from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||||
|
/// <param name="request">The stream events request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||||
|
/// <returns>An async enumerable of MXAccess events.</returns>
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
@@ -133,7 +135,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Queries active alarms from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||||
|
/// <param name="request">The query active alarms request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||||
|
/// <returns>An async enumerable of active alarm snapshots.</returns>
|
||||||
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
public async IAsyncEnumerable<ActiveAlarmSnapshot> QueryActiveAlarmsAsync(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
@@ -175,7 +181,11 @@ internal sealed class GrpcMxGatewayClientTransport(
|
|||||||
return QueryActiveAlarmsAsync(request, callOptions);
|
return QueryActiveAlarmsAsync(request, callOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Streams alarm feed messages from the gateway, forwarding an explicit cancellation token to the stream reader.</summary>
|
||||||
|
/// <param name="request">The stream alarms request.</param>
|
||||||
|
/// <param name="callOptions">gRPC call options.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the streaming enumeration.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
public async IAsyncEnumerable<AlarmFeedMessage> StreamAlarmsAsync(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
CallOptions callOptions,
|
CallOptions callOptions,
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
/// <summary>Tests the connection to the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The test connection request.</param>
|
/// <param name="request">The test connection request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the test connection reply.</returns>
|
||||||
Task<TestConnectionReply> TestConnectionAsync(
|
Task<TestConnectionReply> TestConnectionAsync(
|
||||||
TestConnectionRequest request,
|
TestConnectionRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -22,6 +23,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
/// <summary>Gets the last deploy time from the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The get last deploy time request.</param>
|
/// <param name="request">The get last deploy time request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the last deploy time reply.</returns>
|
||||||
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
Task<GetLastDeployTimeReply> GetLastDeployTimeAsync(
|
||||||
GetLastDeployTimeRequest request,
|
GetLastDeployTimeRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -29,6 +31,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
/// <summary>Discovers the object hierarchy in the Galaxy Repository.</summary>
|
||||||
/// <param name="request">The discover hierarchy request.</param>
|
/// <param name="request">The discover hierarchy request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the hierarchy discovery reply.</returns>
|
||||||
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
Task<DiscoverHierarchyReply> DiscoverHierarchyAsync(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -36,6 +39,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
|
/// <summary>Returns direct children of a parent in the Galaxy hierarchy.</summary>
|
||||||
/// <param name="request">The browse children request.</param>
|
/// <param name="request">The browse children request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>A task that resolves to the browse children reply.</returns>
|
||||||
Task<BrowseChildrenReply> BrowseChildrenAsync(
|
Task<BrowseChildrenReply> BrowseChildrenAsync(
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
@@ -43,6 +47,7 @@ internal interface IGalaxyRepositoryClientTransport
|
|||||||
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
/// <summary>Watches for deployment events from the Galaxy Repository server.</summary>
|
||||||
/// <param name="request">The watch deploy events request.</param>
|
/// <param name="request">The watch deploy events request.</param>
|
||||||
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
/// <param name="callOptions">gRPC call options (timeout, cancellation, etc.).</param>
|
||||||
|
/// <returns>An async enumerable of deploy events.</returns>
|
||||||
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
IAsyncEnumerable<DeployEvent> WatchDeployEventsAsync(
|
||||||
WatchDeployEventsRequest request,
|
WatchDeployEventsRequest request,
|
||||||
CallOptions callOptions);
|
CallOptions callOptions);
|
||||||
|
|||||||
@@ -16,6 +16,11 @@ public sealed class LazyBrowseNode
|
|||||||
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
private readonly SemaphoreSlim _expandLock = new(1, 1);
|
||||||
private bool _isExpanded;
|
private bool _isExpanded;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of <see cref="LazyBrowseNode"/>.</summary>
|
||||||
|
/// <param name="client">The repository client used to fetch children.</param>
|
||||||
|
/// <param name="object">The underlying Galaxy object for this node.</param>
|
||||||
|
/// <param name="hasChildrenHint">True when the server reports the node has at least one matching descendant.</param>
|
||||||
|
/// <param name="options">Options controlling child browse behavior.</param>
|
||||||
internal LazyBrowseNode(
|
internal LazyBrowseNode(
|
||||||
GalaxyRepositoryClient client,
|
GalaxyRepositoryClient client,
|
||||||
GalaxyObject @object,
|
GalaxyObject @object,
|
||||||
@@ -49,6 +54,7 @@ public sealed class LazyBrowseNode
|
|||||||
/// (after the first completes) return immediately.
|
/// (after the first completes) return immediately.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
public async Task ExpandAsync(CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
if (_isExpanded)
|
if (_isExpanded)
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public static class MxCommandReplyExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
/// <summary>Validates that the reply has a successful protocol status (Ok or MxAccessFailure), throwing a gateway exception if not.</summary>
|
||||||
/// <param name="reply">The command reply to check.</param>
|
/// <param name="reply">The command reply to check.</param>
|
||||||
|
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
||||||
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
public static MxCommandReply EnsureProtocolSuccess(this MxCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
@@ -24,6 +25,7 @@ public static class MxCommandReplyExtensions
|
|||||||
|
|
||||||
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
/// <summary>Validates that the reply indicates MXAccess success (no HResult or status failures), throwing MxAccessException if not.</summary>
|
||||||
/// <param name="reply">The command reply to check.</param>
|
/// <param name="reply">The command reply to check.</param>
|
||||||
|
/// <returns>The same <paramref name="reply"/> for fluent chaining when validation passes.</returns>
|
||||||
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
public static MxCommandReply EnsureMxAccessSuccess(this MxCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes the client and releases all resources.
|
/// Disposes the client and releases all resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
@@ -318,6 +319,9 @@ public sealed class MxGatewayClient : IAsyncDisposable
|
|||||||
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
private static HttpMessageHandler CreateHttpHandler(MxGatewayClientOptions options) =>
|
||||||
CreateHttpHandlerForTests(options);
|
CreateHttpHandlerForTests(options);
|
||||||
|
|
||||||
|
/// <summary>Creates an <see cref="HttpMessageHandler"/> configured from the provided options for test use.</summary>
|
||||||
|
/// <param name="options">Client options used to configure TLS and timeouts.</param>
|
||||||
|
/// <returns>The configured HTTP message handler.</returns>
|
||||||
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
internal static SocketsHttpHandler CreateHttpHandlerForTests(MxGatewayClientOptions options)
|
||||||
{
|
{
|
||||||
SocketsHttpHandler handler = new()
|
SocketsHttpHandler handler = new()
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ internal static class MxGatewayClientRetryPolicy
|
|||||||
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
/// <summary>Creates a Polly ResiliencePipeline that retries transient gRPC failures with exponential backoff.</summary>
|
||||||
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
/// <param name="options">Retry configuration (max attempts, delay bounds, jitter).</param>
|
||||||
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
/// <param name="logger">Optional logger for retry diagnostics.</param>
|
||||||
|
/// <returns>A configured <see cref="ResiliencePipeline"/> with exponential-backoff retry.</returns>
|
||||||
public static ResiliencePipeline Create(
|
public static ResiliencePipeline Create(
|
||||||
MxGatewayClientRetryOptions options,
|
MxGatewayClientRetryOptions options,
|
||||||
ILogger? logger)
|
ILogger? logger)
|
||||||
@@ -42,6 +43,7 @@ internal static class MxGatewayClientRetryPolicy
|
|||||||
|
|
||||||
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
/// <summary>Returns whether a command kind is eligible for automatic retry on transient failures.</summary>
|
||||||
/// <param name="kind">The command kind to check.</param>
|
/// <param name="kind">The command kind to check.</param>
|
||||||
|
/// <returns><see langword="true"/> if the command kind is safe to retry; otherwise <see langword="false"/>.</returns>
|
||||||
public static bool IsRetryableCommand(MxCommandKind kind)
|
public static bool IsRetryableCommand(MxCommandKind kind)
|
||||||
{
|
{
|
||||||
return kind is MxCommandKind.Ping
|
return kind is MxCommandKind.Ping
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task AdviseAsync(
|
public async Task AdviseAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -252,6 +253,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task UnAdviseAsync(
|
public async Task UnAdviseAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -293,6 +295,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="serverHandle">The ServerHandle from register.</param>
|
/// <param name="serverHandle">The ServerHandle from register.</param>
|
||||||
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
/// <param name="itemHandle">The ItemHandle from add-item.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task RemoveItemAsync(
|
public async Task RemoveItemAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -675,6 +678,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="value">The value to write.</param>
|
/// <param name="value">The value to write.</param>
|
||||||
/// <param name="userId">User ID context for the write.</param>
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task WriteAsync(
|
public async Task WriteAsync(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -729,6 +733,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
/// <param name="timestampValue">The timestamp to write with the value.</param>
|
||||||
/// <param name="userId">User ID context for the write.</param>
|
/// <param name="userId">User ID context for the write.</param>
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
/// <param name="cancellationToken">Cancellation token.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task Write2Async(
|
public async Task Write2Async(
|
||||||
int serverHandle,
|
int serverHandle,
|
||||||
int itemHandle,
|
int itemHandle,
|
||||||
@@ -821,6 +826,7 @@ public sealed class MxGatewaySession : IAsyncDisposable
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Closes the session and releases resources.
|
/// Closes the session and releases resources.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await CloseAsync().ConfigureAwait(false);
|
await CloseAsync().ConfigureAwait(false);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ public static class MxStatusProxyExtensions
|
|||||||
{
|
{
|
||||||
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
/// <summary>Returns whether the status indicates success (success flag set and category is Ok).</summary>
|
||||||
/// <param name="status">The status to check.</param>
|
/// <param name="status">The status to check.</param>
|
||||||
|
/// <returns><c>true</c> if the status is successful; <c>false</c> otherwise.</returns>
|
||||||
public static bool IsSuccess(this MxStatusProxy status)
|
public static bool IsSuccess(this MxStatusProxy status)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
@@ -17,6 +18,7 @@ public static class MxStatusProxyExtensions
|
|||||||
|
|
||||||
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
/// <summary>Returns a formatted summary of the status for diagnostic output.</summary>
|
||||||
/// <param name="status">The status to summarize.</param>
|
/// <param name="status">The status to summarize.</param>
|
||||||
|
/// <returns>A human-readable string combining category, source, detail, and diagnostic text.</returns>
|
||||||
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
public static string ToDiagnosticSummary(this MxStatusProxy status)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
ArgumentNullException.ThrowIfNull(status);
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
/// Converts a boolean value to an MxValue with MxDataType.Boolean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Scalar boolean value to wrap.</param>
|
/// <param name="value">Scalar boolean value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c>.</returns>
|
||||||
public static MxValue ToMxValue(this bool value)
|
public static MxValue ToMxValue(this bool value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -28,6 +29,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
/// Converts a 32-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">32-bit integer value to wrap.</param>
|
/// <param name="value">32-bit integer value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
||||||
public static MxValue ToMxValue(this int value)
|
public static MxValue ToMxValue(this int value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -42,6 +44,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
/// Converts a 64-bit integer value to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">64-bit integer value to wrap.</param>
|
/// <param name="value">64-bit integer value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c>.</returns>
|
||||||
public static MxValue ToMxValue(this long value)
|
public static MxValue ToMxValue(this long value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -56,6 +59,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
/// Converts a single-precision floating-point value to an MxValue with MxDataType.Float.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
/// <param name="value">Single-precision floating-point value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c>.</returns>
|
||||||
public static MxValue ToMxValue(this float value)
|
public static MxValue ToMxValue(this float value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -70,6 +74,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
/// Converts a double-precision floating-point value to an MxValue with MxDataType.Double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
/// <param name="value">Double-precision floating-point value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c>.</returns>
|
||||||
public static MxValue ToMxValue(this double value)
|
public static MxValue ToMxValue(this double value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -84,6 +89,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a string value to an MxValue with MxDataType.String.
|
/// Converts a string value to an MxValue with MxDataType.String.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">String value to wrap.</param>
|
/// <param name="value">String value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c>.</returns>
|
||||||
public static MxValue ToMxValue(this string value)
|
public static MxValue ToMxValue(this string value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -100,6 +106,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
/// Converts a DateTimeOffset value to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">DateTimeOffset value to wrap.</param>
|
/// <param name="value">DateTimeOffset value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
||||||
public static MxValue ToMxValue(this DateTimeOffset value)
|
public static MxValue ToMxValue(this DateTimeOffset value)
|
||||||
{
|
{
|
||||||
return new MxValue
|
return new MxValue
|
||||||
@@ -114,6 +121,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
/// Converts a DateTime value to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">DateTime value to wrap.</param>
|
/// <param name="value">DateTime value to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c>.</returns>
|
||||||
public static MxValue ToMxValue(this DateTime value)
|
public static MxValue ToMxValue(this DateTime value)
|
||||||
{
|
{
|
||||||
return new DateTimeOffset(
|
return new DateTimeOffset(
|
||||||
@@ -127,6 +135,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
/// Converts a boolean array to an MxValue with MxDataType.Boolean.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of boolean values to wrap.</param>
|
/// <param name="values">Array of boolean values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Boolean</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
public static MxValue ToMxValue(this IReadOnlyList<bool> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -145,6 +154,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
/// Converts a 32-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
/// <param name="values">Array of 32-bit integer values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
public static MxValue ToMxValue(this IReadOnlyList<int> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -163,6 +173,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
/// Converts a 64-bit integer array to an MxValue with MxDataType.Integer.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
/// <param name="values">Array of 64-bit integer values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Integer</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
public static MxValue ToMxValue(this IReadOnlyList<long> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -181,6 +192,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
/// Converts a single-precision floating-point array to an MxValue with MxDataType.Float.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
/// <param name="values">Array of single-precision floating-point values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Float</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
public static MxValue ToMxValue(this IReadOnlyList<float> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -199,6 +211,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
/// Converts a double-precision floating-point array to an MxValue with MxDataType.Double.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
/// <param name="values">Array of double-precision floating-point values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Double</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
public static MxValue ToMxValue(this IReadOnlyList<double> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -217,6 +230,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a string array to an MxValue with MxDataType.String.
|
/// Converts a string array to an MxValue with MxDataType.String.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of string values to wrap.</param>
|
/// <param name="values">Array of string values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.String</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
public static MxValue ToMxValue(this IReadOnlyList<string> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -235,6 +249,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
/// Converts a DateTimeOffset array to an MxValue with MxDataType.Time.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
/// <param name="values">Array of DateTimeOffset values to wrap.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Time</c> and an array payload.</returns>
|
||||||
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
public static MxValue ToMxValue(this IReadOnlyList<DateTimeOffset> values)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(values);
|
ArgumentNullException.ThrowIfNull(values);
|
||||||
@@ -253,6 +268,7 @@ public static class MxValueExtensions
|
|||||||
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
/// Gets the projection kind (field name) of the given MxValue's current oneof value.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
/// <param name="value">The MxValue whose oneof projection kind is returned.</param>
|
||||||
|
/// <returns>The JSON field name of the active oneof case, or <c>"nullValue"</c>/<c>"unspecified"</c> for null/unset values.</returns>
|
||||||
public static string GetProjectionKind(this MxValue value)
|
public static string GetProjectionKind(this MxValue value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -276,6 +292,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
/// Converts an MxValue to a CLR object; returns the boxed value or null for null MxValues.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="value">The MxValue to convert.</param>
|
/// <param name="value">The MxValue to convert.</param>
|
||||||
|
/// <returns>The boxed CLR value, or null if the MxValue represents a null.</returns>
|
||||||
public static object? ToClrValue(this MxValue value)
|
public static object? ToClrValue(this MxValue value)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(value);
|
ArgumentNullException.ThrowIfNull(value);
|
||||||
@@ -299,6 +316,7 @@ public static class MxValueExtensions
|
|||||||
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
/// Converts an MxArray to a CLR array; returns null if the array does not have a known element type.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="array">The MxArray to convert.</param>
|
/// <param name="array">The MxArray to convert.</param>
|
||||||
|
/// <returns>A CLR array of the appropriate element type, or null for unknown element types.</returns>
|
||||||
public static object? ToClrArrayValue(this MxArray array)
|
public static object? ToClrArrayValue(this MxArray array)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(array);
|
ArgumentNullException.ThrowIfNull(array);
|
||||||
@@ -328,6 +346,7 @@ public static class MxValueExtensions
|
|||||||
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
/// <param name="variantType">Variant type string (e.g., "VT_BSTR").</param>
|
||||||
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
/// <param name="rawDiagnostic">Diagnostic string describing the raw value.</param>
|
||||||
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
/// <param name="rawDataType">Optional MXAccess data type override.</param>
|
||||||
|
/// <returns>An <see cref="MxValue"/> with <c>MxDataType.Unknown</c> and the raw byte payload.</returns>
|
||||||
public static MxValue ToRawMxValue(
|
public static MxValue ToRawMxValue(
|
||||||
byte[] value,
|
byte[] value,
|
||||||
string variantType,
|
string variantType,
|
||||||
|
|||||||
@@ -790,159 +790,3 @@ Post-ack transition: kind=Clear …
|
|||||||
|
|
||||||
10s cadence held throughout; full proto fields populated correctly;
|
10s cadence held throughout; full proto fields populated correctly;
|
||||||
ack registered server-side without errors.
|
ack registered server-side without errors.
|
||||||
|
|
||||||
## Subtag-monitoring fallback provider
|
|
||||||
|
|
||||||
When the wnwrap alarm-manager source fails, the gateway worker switches to
|
|
||||||
`SubtagAlarmConsumer` — a synthetic alarm source that advises each alarm
|
|
||||||
attribute's subtags via the existing MXAccess `AddItem`/`Advise` pipeline and
|
|
||||||
derives alarm transitions from the resulting value-change stream. This is a
|
|
||||||
non-parity, degraded-mode source; every transition and snapshot it produces
|
|
||||||
carries `degraded = true`.
|
|
||||||
|
|
||||||
### Watch-list discovery
|
|
||||||
|
|
||||||
`GatewayAlarmMonitor` resolves the subtag watch-list at subscribe time by
|
|
||||||
calling `IAlarmWatchListResolver.GetAlarmAttributesAsync`. The resolver merges:
|
|
||||||
|
|
||||||
1. Galaxy Repository SQL (`GetAlarmAttributesAsync`) — objects that have alarm
|
|
||||||
extensions in the configured area.
|
|
||||||
2. Config overrides — `IncludeAttributes` adds explicit entries;
|
|
||||||
`ExcludeAttributes` removes Repository-derived ones. The config list takes
|
|
||||||
effect even when `UseGalaxyRepository` is `false`.
|
|
||||||
|
|
||||||
The resolved list is a set of `AlarmSubtagTarget` messages sent to the worker
|
|
||||||
inside `SubscribeAlarmsCommand.watch_list`. Each target carries the composed
|
|
||||||
MXAccess item addresses for the `InAlarm`, `Acked`, `AckMsg`, and `Priority`
|
|
||||||
subtags (confirmed AVEVA `AlarmExtension` field names, verified against the live
|
|
||||||
ZB Galaxy `attribute_definition` rows). The gateway re-runs discovery on its
|
|
||||||
reconcile cadence and pushes an updated watch-list when the model changes.
|
|
||||||
|
|
||||||
Each target's canonical `AlarmFullReference` is composed as
|
|
||||||
`Galaxy!{area}.{reference}` (literal `Galaxy` provider). The `{area}` is the
|
|
||||||
alarm object's **real Galaxy area** — discovered per object via
|
|
||||||
`gobject.area_gobject_id` (`GetAlarmAttributesAsync` projects it as `area_name`)
|
|
||||||
— so the synthesized reference's group matches exactly the area the native
|
|
||||||
alarmmgr (wnwrap) emits for the same alarm (e.g. `TestMachine_001` in `TestArea`
|
|
||||||
yields `Galaxy!TestArea.TestMachine_001.TestAlarm001`). The configured
|
|
||||||
`Discovery.Area` / `DefaultArea` is **only** the fallback for explicit
|
|
||||||
`IncludeAttributes` entries, which carry no discovered area.
|
|
||||||
|
|
||||||
### Subtag advise and `LmxSubtagAlarmSource`
|
|
||||||
|
|
||||||
`LmxSubtagAlarmSource` (implements `ISubtagAlarmSource`) owns a separate
|
|
||||||
`LMXProxyServerClass` instance on the worker STA — it does not share the
|
|
||||||
session's main MXAccess object. For each watch-list target it calls
|
|
||||||
`AddItem`/`Advise` on the configured subtag addresses. When a subtag value
|
|
||||||
changes, it raises `ValueChanged` on the STA and `SubtagAlarmConsumer`
|
|
||||||
forwards it to `SubtagAlarmStateMachine`.
|
|
||||||
|
|
||||||
`PollOnce()` on the subtag consumer is a no-op — the path is event-driven
|
|
||||||
through `Advise`, not poll-driven.
|
|
||||||
|
|
||||||
### Synthesis rules
|
|
||||||
|
|
||||||
`SubtagAlarmStateMachine` tracks `(active, acked)` per watch-list entry and
|
|
||||||
emits `MxAlarmTransitionEvent` records on change:
|
|
||||||
|
|
||||||
| Subtag change | Emitted transition | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `InAlarm` false → true | Raise (`UNACK_ALM`) | `original_raise_timestamp` = first observed active time for this episode |
|
|
||||||
| `Acked` false → true, while `InAlarm` | Acknowledge (`ACK_ALM`) | `AckedDuringEpisode` latch set |
|
|
||||||
| `InAlarm` true → false | Clear | `AckRtn` if `AckedDuringEpisode` is set, else `UnackRtn` |
|
|
||||||
| `Acked` true → false, while `InAlarm` | (none) | Latch is NOT cleared; the episode retains its acknowledged status at clear |
|
|
||||||
|
|
||||||
The `AckedDuringEpisode` latch addresses out-of-order subtag delivery:
|
|
||||||
MXAccess does not guarantee the `Acked = false` update arrives before the
|
|
||||||
`InAlarm = false` update. The latch ensures a clear always emits `ACK_RTN`
|
|
||||||
when the alarm was acknowledged at any point during the active episode.
|
|
||||||
|
|
||||||
`SnapshotActive()` returns one `MxAlarmSnapshotRecord` per currently-active
|
|
||||||
alarm. State mapping:
|
|
||||||
|
|
||||||
- `InAlarm && !Acked` → `UNACK_ALM`
|
|
||||||
- `InAlarm && Acked` → `ACK_ALM`
|
|
||||||
- `!InAlarm` → not included in the snapshot
|
|
||||||
|
|
||||||
### Synthetic GUID
|
|
||||||
|
|
||||||
The alarmmgr provider supplies a native GUID per alarm record. The subtag
|
|
||||||
provider has no native GUID. `SubtagAlarmConsumer` derives a deterministic
|
|
||||||
GUID by hashing `alarm_full_reference` (via `SyntheticAlarmGuid.ForReference`).
|
|
||||||
The same reference always produces the same GUID within a session, so
|
|
||||||
GUID-based ack routing resolves correctly. The GUID is not stable across
|
|
||||||
different alarm references or gateway restarts in the sense of matching any
|
|
||||||
AVEVA-internal GUID.
|
|
||||||
|
|
||||||
### Acknowledge in subtag mode
|
|
||||||
|
|
||||||
`AlarmDispatcher` routes ack calls by active provider mode:
|
|
||||||
|
|
||||||
- **Alarm-manager mode:** `AlarmAckByName` on `wwAlarmConsumerClass` (unchanged).
|
|
||||||
- **Subtag mode:** `SubtagAlarmConsumer.AcknowledgeByName` resolves the
|
|
||||||
watch-list entry's `ack_comment_subtag` and issues a `Write(comment)` on
|
|
||||||
the STA via `LmxSubtagAlarmSource`. Writing the `AckMsg` subtag performs
|
|
||||||
the acknowledge in AVEVA (`AckMsg` is the confirmed `AlarmExtension` ack-comment
|
|
||||||
write target).
|
|
||||||
|
|
||||||
If the alarm has no writable ack-comment subtag (`AckComment` config key is
|
|
||||||
empty, or the entry's `ack_comment_subtag` field is empty), the ack call
|
|
||||||
returns a failure code that the gateway surfaces as `FailedPrecondition`.
|
|
||||||
`AcknowledgeByGuid` maps the synthetic GUID back to its reference via an
|
|
||||||
internal dictionary, then calls the same write path.
|
|
||||||
|
|
||||||
`SubtagAlarmConsumer.Subscribe` advises the ack-comment subtag alongside the
|
|
||||||
observed ones (active/acked/priority). This is required: MXAccess rejects a
|
|
||||||
write to an item that has been added but not advised with `E_INVALIDARG`
|
|
||||||
("Value does not fall within the expected range"). Advising it at subscribe
|
|
||||||
time makes it an active item so the later ack write succeeds — its value
|
|
||||||
changes carry no transition (the state machine ignores unmapped addresses).
|
|
||||||
|
|
||||||
### Live validation
|
|
||||||
|
|
||||||
The subtag path was validated against live MXAccess on the dev rig
|
|
||||||
(`DESKTOP-6JL3KKO`, Galaxy `DEV`, `TestMachine_001.TestAlarm001`):
|
|
||||||
|
|
||||||
- `….InAlarm` → `True` (Boolean), `….Acked` → `False` (Boolean),
|
|
||||||
`….Priority` → `500` (Int32), `….AckMsg` → string — confirming the field
|
|
||||||
names **and** the runtime reference shape `<Object>.<AlarmAttr>.<field>`
|
|
||||||
with **no** intermediate alarm-condition segment.
|
|
||||||
- `AcknowledgeByName` (AckMsg write) returned `0` once the ack-comment subtag
|
|
||||||
was advised — confirming the ack-by-comment-write mechanism end to end.
|
|
||||||
|
|
||||||
### Fidelity limitations
|
|
||||||
|
|
||||||
The following fields are not available or have lower quality in subtag mode:
|
|
||||||
|
|
||||||
| Field | Subtag-mode behavior |
|
|
||||||
|-------|---------------------|
|
|
||||||
| `alarm_guid` | Synthetic deterministic GUID from `alarm_full_reference`; not an AVEVA-native GUID |
|
|
||||||
| `original_raise_timestamp` | First observed `active = true` time; no AVEVA-native raise time |
|
|
||||||
| `transition_timestamp` | `OnDataChange` source timestamp from MXAccess |
|
|
||||||
| `severity` | From priority subtag if advised; 0 otherwise |
|
|
||||||
| `category` / `description` | Not populated (no subtag for these) |
|
|
||||||
| `current_value` / `limit_value` | Not populated unless corresponding subtags are in the watch-list |
|
|
||||||
| `alarm_type_name` | Not populated |
|
|
||||||
| `operator_user` / `operator_comment` | Not populated on synthesized raise/clear transitions |
|
|
||||||
| `retrigger` transition | Not synthesized (no re-alarm counter subtag is observed) |
|
|
||||||
|
|
||||||
Every transition and snapshot record carries `degraded = true` and
|
|
||||||
`source_provider = ALARM_PROVIDER_MODE_SUBTAG`. Clients that require full
|
|
||||||
fidelity must wait for failback to the alarm manager.
|
|
||||||
|
|
||||||
### Provider mode reflection
|
|
||||||
|
|
||||||
When `FailoverAlarmConsumer` switches between providers, it raises
|
|
||||||
`ProviderModeChanged`. `AlarmDispatcher` enqueues an
|
|
||||||
`OnAlarmProviderModeChangedEvent` (carried as an `MxEvent`), which the
|
|
||||||
gateway receives and reflects into:
|
|
||||||
|
|
||||||
- `AlarmFeedMessage.provider_status` emitted to every `StreamAlarms`
|
|
||||||
subscriber.
|
|
||||||
- The `/hubs/alarms` SignalR hub for the dashboard.
|
|
||||||
- Metrics: `mxgateway.alarms.provider_mode` gauge and
|
|
||||||
`mxgateway.alarms.provider_switches` counter.
|
|
||||||
|
|
||||||
On every switch `GatewayAlarmMonitor` also forces a reconcile
|
|
||||||
(`QueryActiveAlarms`) against the now-active provider so the gateway cache
|
|
||||||
reflects the post-switch state without a spurious raise/clear storm.
|
|
||||||
|
|||||||
@@ -411,58 +411,6 @@ a per-channel skip-verify hook:
|
|||||||
See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
See [Gateway Configuration — Automatic self-signed certificate](./GatewayConfiguration.md#automatic-self-signed-certificate)
|
||||||
and the per-client READMEs for the as-built behavior.
|
and the per-client READMEs for the as-built behavior.
|
||||||
|
|
||||||
## Alarm-Manager to Subtag Fallback
|
|
||||||
|
|
||||||
Decision: add a second alarm provider (subtag monitoring) that the worker
|
|
||||||
activates automatically when the native wnwrap alarm manager fails, and fails
|
|
||||||
back to automatically when the manager recovers.
|
|
||||||
|
|
||||||
### Worker-side synthesis
|
|
||||||
|
|
||||||
Synthesis of alarm transitions from subtag value changes happens entirely in
|
|
||||||
the worker (`SubtagAlarmConsumer` / `SubtagAlarmStateMachine`). The gateway
|
|
||||||
still forwards only events the worker emits and synthesizes nothing itself.
|
|
||||||
This satisfies the parity rule even though the subtag path is inherently
|
|
||||||
non-parity: the parity rule governs where synthesis lives, not whether
|
|
||||||
synthesis is permitted when the native source is unavailable.
|
|
||||||
|
|
||||||
### Degraded is explicit
|
|
||||||
|
|
||||||
Every subtag-mode transition carries `degraded = true` on the
|
|
||||||
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto messages, and the
|
|
||||||
`AlarmFeedMessage` feed carries an `AlarmProviderStatus` payload on stream
|
|
||||||
open and on every switch. No client can mistake a subtag-mode alarm for an
|
|
||||||
authoritative alarmmgr record. Subtag mode has lower fidelity: synthetic
|
|
||||||
deterministic GUID (SHA-derived from the alarm reference), best-effort
|
|
||||||
original-raise timestamp, narrower field set. Clients that need full fidelity
|
|
||||||
must wait for failback.
|
|
||||||
|
|
||||||
### Failover trigger
|
|
||||||
|
|
||||||
The failover trigger is N consecutive wnwrap COM failures — a `COMException`
|
|
||||||
thrown by `Subscribe` or `PollOnce`, or a failure HRESULT from
|
|
||||||
`GetXmlCurrentAlarms2`. A single poll failure does not trigger a switch; the
|
|
||||||
threshold (default 3, floored at 1) guards against transient COM hiccups. The
|
|
||||||
counter resets on any clean poll so a flapping provider does not permanently
|
|
||||||
latch in subtag mode.
|
|
||||||
|
|
||||||
### Acknowledge via ack-comment write
|
|
||||||
|
|
||||||
In subtag mode, `AcknowledgeAlarm` writes the operator comment to the alarm
|
|
||||||
attribute's ack-comment subtag (`Fallback:Subtags:AckComment`). The write
|
|
||||||
performs the native ack in AVEVA. This differs from alarmmgr mode, where
|
|
||||||
`AlarmAckByName` on `wwAlarmConsumerClass` is called directly. The `AckComment`
|
|
||||||
subtag name is empty by default; configuring it is required for ack to work in
|
|
||||||
subtag mode. The exact AVEVA subtag names are not hard-coded — the `Subtags`
|
|
||||||
config block exists precisely so names are not guessed without validation
|
|
||||||
against the live MXAccess attribute set.
|
|
||||||
|
|
||||||
### Related documentation
|
|
||||||
|
|
||||||
- [Gateway Configuration — Alarm Fallback options](./GatewayConfiguration.md#alarm-fallback-options)
|
|
||||||
- [Alarm Client Discovery — Subtag provider](./AlarmClientDiscovery.md)
|
|
||||||
- [gRPC Contract — provider_status and degraded fields](./Grpc.md)
|
|
||||||
|
|
||||||
## Later Revisit Items
|
## Later Revisit Items
|
||||||
|
|
||||||
These are explicit post-v1 revisit items, not open blockers:
|
These are explicit post-v1 revisit items, not open blockers:
|
||||||
|
|||||||
@@ -148,7 +148,6 @@ the affected stream while the MXAccess session remains active.
|
|||||||
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
| `MxGateway:Dashboard:Enabled` | `true` | Enables Blazor Server dashboard route mapping. The dashboard mounts at the host root (`/`); there is no separate path-base prefix. |
|
||||||
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
| `MxGateway:Dashboard:AllowAnonymousLocalhost` | `true` | Allows loopback dashboard requests to bypass the dashboard cookie requirement for local development. Remote requests still require dashboard authentication. |
|
||||||
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
| `MxGateway:Dashboard:RequireHttpsCookie` | `true` | Sets the dashboard auth cookie's secure policy. `true` keeps `CookieSecurePolicy.Always` — the cookie is only sent over HTTPS, which matches a production HTTPS deployment. Set to `false` for plain-HTTP dev deployments to use `CookieSecurePolicy.SameAsRequest`; the cookie is still flagged Secure on HTTPS requests, but it can round-trip over HTTP. Browsers drop Secure cookies set over HTTP from non-localhost hosts, so leaving this `true` while serving the dashboard over plain HTTP will break login from any remote browser. |
|
||||||
| `MxGateway:Dashboard:CookieName` | `MxGatewayDashboard` | Dashboard auth cookie name. Leave unset (null/blank) to use the default. Override it to give a distinct name to a gateway that shares a hostname with another gateway instance: browser cookies are scoped by host+path but **not** by port, so two instances on the same host would otherwise clobber each other's dashboard session under a shared cookie name. Changing it signs out existing dashboard sessions on next deploy. |
|
|
||||||
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
|
||||||
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
|
||||||
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
|
||||||
@@ -230,75 +229,6 @@ behavior.
|
|||||||
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
The alarm monitor is independent of client sessions: `AcknowledgeAlarm` and
|
||||||
`StreamAlarms` are session-less RPCs served by the monitor.
|
`StreamAlarms` are session-less RPCs served by the monitor.
|
||||||
|
|
||||||
### Alarm fallback options
|
|
||||||
|
|
||||||
The `Fallback` sub-section controls how the alarm feed selects between the
|
|
||||||
native wnwrap alarm-manager provider and the subtag-monitoring fallback.
|
|
||||||
|
|
||||||
| Option | Default | Description |
|
|
||||||
|--------|---------|-------------|
|
|
||||||
| `MxGateway:Alarms:Fallback:Mode` | `Auto` | Provider selection mode. `Auto` uses the alarm manager as primary and fails over to subtag monitoring after consecutive COM failures, then fails back automatically. `ForceAlarmManager` disables failover. `ForceSubtag` forces subtag monitoring on from startup. Values are case-insensitive. |
|
|
||||||
| `MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold` | `3` | Number of consecutive wnwrap COM failures (`COMException` or failure HRESULT from `Subscribe` / `GetXmlCurrentAlarms2`) before the monitor switches to subtag mode. Floored at 1. |
|
|
||||||
| `MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds` | `30` | While in subtag mode, how often (in seconds) the monitor probes the wnwrap provider to detect recovery. Floored at 1. |
|
|
||||||
| `MxGateway:Alarms:Fallback:FailbackStableProbes` | `3` | Number of consecutive clean wnwrap probes required before the monitor switches back to the alarm manager. Floored at 1. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Discovery:UseGalaxyRepository` | `true` | When `true`, the monitor queries the Galaxy Repository SQL database to build the subtag watch-list for the configured area. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Discovery:Area` | _(empty)_ | Galaxy area to scope the Repository query to. Falls back to `MxGateway:Alarms:DefaultArea` when empty. Ignored when `UseGalaxyRepository` is `false`. This area is **not** used to compose a Repository-derived alarm's canonical `Galaxy!{area}.{reference}`: each discovered alarm uses its object's real Galaxy area (discovered via `gobject.area_gobject_id`), so the reference's group matches what the native alarmmgr emits. `Discovery:Area` / `DefaultArea` is used as the composition area only for explicit `IncludeAttributes` entries, which carry no discovered area. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Discovery:IncludeAttributes` | _(empty)_ | Explicit MXAccess attribute paths to add to the subtag watch-list, supplementing (or replacing, when `UseGalaxyRepository` is `false`) the Repository-derived list. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Discovery:ExcludeAttributes` | _(empty)_ | Attribute paths to remove from the Repository-derived watch-list. Ignored when `UseGalaxyRepository` is `false`. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Subtags:Active` | `InAlarm` | Subtag name for the in-alarm boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Subtags:Acked` | `Acked` | Subtag name for the acknowledged boolean. Confirmed AVEVA `AlarmExtension` field name. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Subtags:AckComment` | `AckMsg` | Subtag name for the acknowledgement comment write target. Writing this subtag performs the acknowledge in AVEVA. Confirmed AVEVA `AlarmExtension` field name. When empty, the ack-comment write path is disabled. |
|
|
||||||
| `MxGateway:Alarms:Fallback:Subtags:Priority` | `Priority` | Subtag name for the alarm priority / severity value. Confirmed AVEVA `AlarmExtension` field name. |
|
|
||||||
|
|
||||||
Validation rules:
|
|
||||||
|
|
||||||
- `Mode` must be `Auto`, `ForceAlarmManager`, or `ForceSubtag` (case-insensitive).
|
|
||||||
- `Mode = ForceSubtag` with both `UseGalaxyRepository = false` and an empty
|
|
||||||
`IncludeAttributes` list produces a startup validation warning: the subtag
|
|
||||||
provider has no attributes to advise.
|
|
||||||
- `ConsecutiveFailureThreshold`, `FailbackProbeIntervalSeconds`, and
|
|
||||||
`FailbackStableProbes` are floored at 1 by `GatewayOptionsValidator`.
|
|
||||||
|
|
||||||
Full example with non-default fallback settings:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"MxGateway": {
|
|
||||||
"Alarms": {
|
|
||||||
"Enabled": true,
|
|
||||||
"SubscriptionExpression": "\\\\SCADA01\\Galaxy!PlantArea",
|
|
||||||
"DefaultArea": "PlantArea",
|
|
||||||
"ReconcileIntervalSeconds": 30,
|
|
||||||
"Fallback": {
|
|
||||||
"Mode": "Auto",
|
|
||||||
"ConsecutiveFailureThreshold": 3,
|
|
||||||
"FailbackProbeIntervalSeconds": 30,
|
|
||||||
"FailbackStableProbes": 3,
|
|
||||||
"Discovery": {
|
|
||||||
"UseGalaxyRepository": true,
|
|
||||||
"Area": "",
|
|
||||||
"IncludeAttributes": [],
|
|
||||||
"ExcludeAttributes": []
|
|
||||||
},
|
|
||||||
"Subtags": {
|
|
||||||
"Active": "InAlarm",
|
|
||||||
"Acked": "Acked",
|
|
||||||
"AckComment": "AckMsg",
|
|
||||||
"Priority": "Priority"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The defaults (`InAlarm`/`Acked`/`AckMsg`/`Priority`) are the confirmed AVEVA
|
|
||||||
`AlarmExtension` primitive field names, verified by querying the live ZB Galaxy
|
|
||||||
`attribute_definition` rows. The `Subtags` block exists so names can be
|
|
||||||
overridden without a code change if a site's alarm template uses different
|
|
||||||
attribute names. See `docs/AlarmClientDiscovery.md` for the synthesis rules that
|
|
||||||
depend on these names.
|
|
||||||
|
|
||||||
## Host Endpoints and Transport Security (Kestrel)
|
## Host Endpoints and Transport Security (Kestrel)
|
||||||
|
|
||||||
The listening endpoints are **not** part of the `MxGateway` section. The gateway
|
The listening endpoints are **not** part of the `MxGateway` section. The gateway
|
||||||
|
|||||||
@@ -94,73 +94,6 @@ Carrying the enqueue timestamp into the worker layer is what lets queue-wait tim
|
|||||||
|
|
||||||
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
|
`StreamAlarms` is a server-streaming, **session-less** RPC that attaches to the gateway's central alarm feed. The handler delegates to `IGatewayAlarmService.StreamAsync`. The stream opens with one `AlarmFeedMessage` carrying an `active_alarm` per currently-active alarm (the ConditionRefresh snapshot), then a single `snapshot_complete`, then a `transition` for every subsequent raise / acknowledge / clear. It is served by the always-on `GatewayAlarmMonitor`, which owns a single gateway-managed worker session and fans out to every attached client — clients no longer open a session of their own. `alarm_filter_prefix`, when set, scopes the stream to a sub-tree.
|
||||||
|
|
||||||
#### Provider status on the alarm feed
|
|
||||||
|
|
||||||
`AlarmFeedMessage` has a fourth `payload` case, `provider_status`, carrying
|
|
||||||
an `AlarmProviderStatus` message:
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message AlarmProviderStatus {
|
|
||||||
AlarmProviderMode mode = 1;
|
|
||||||
bool degraded = 2; // true whenever mode == SUBTAG
|
|
||||||
string reason = 3; // human-readable switch reason
|
|
||||||
google.protobuf.Timestamp since = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
The gateway emits `provider_status` once when a client first subscribes
|
|
||||||
(immediately after the initial snapshot and before the first live transition)
|
|
||||||
and again on every failover or failback. A late-joining client therefore
|
|
||||||
always learns the current provider mode without waiting for the next switch.
|
|
||||||
|
|
||||||
`AlarmProviderMode` is an enum with three values:
|
|
||||||
|
|
||||||
| Value | Meaning |
|
|
||||||
|-------|---------|
|
|
||||||
| `ALARM_PROVIDER_MODE_UNSPECIFIED` (0) | Default / unset |
|
|
||||||
| `ALARM_PROVIDER_MODE_ALARMMGR` (1) | Native wnwrap alarm-manager source |
|
|
||||||
| `ALARM_PROVIDER_MODE_SUBTAG` (2) | Subtag-monitoring fallback (degraded) |
|
|
||||||
|
|
||||||
#### Degraded and source-provider fields on transitions and snapshots
|
|
||||||
|
|
||||||
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` both carry two new fields:
|
|
||||||
|
|
||||||
- `bool degraded` (field 14) — `true` when the record came from the subtag
|
|
||||||
fallback, not the native alarmmgr.
|
|
||||||
- `AlarmProviderMode source_provider` (field 15) — which provider produced
|
|
||||||
this record (`ALARMMGR` or `SUBTAG`).
|
|
||||||
|
|
||||||
Both fields are proto3 defaults (`false` / `UNSPECIFIED`) in alarmmgr mode,
|
|
||||||
so existing clients that do not read them continue to function without change.
|
|
||||||
Clients that care about provenance — for example, an OPC UA server that
|
|
||||||
applies different quality flags to degraded alarms — should inspect `degraded`
|
|
||||||
before consuming the transition.
|
|
||||||
|
|
||||||
Subtag-mode records are a non-parity source. They carry synthetic GUIDs,
|
|
||||||
best-effort timestamps, and reduced field coverage. See
|
|
||||||
`docs/AlarmClientDiscovery.md` for the full fidelity table.
|
|
||||||
|
|
||||||
#### Provider-mode-changed event
|
|
||||||
|
|
||||||
The worker emits `OnAlarmProviderModeChangedEvent` (family
|
|
||||||
`MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED`) on each switch between
|
|
||||||
providers:
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message OnAlarmProviderModeChangedEvent {
|
|
||||||
AlarmProviderMode mode = 1;
|
|
||||||
string reason = 2;
|
|
||||||
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
|
||||||
google.protobuf.Timestamp at = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
This event arrives on the `StreamEvents` stream of the alarm monitor's
|
|
||||||
internal gateway session (not on client sessions). `GatewayAlarmMonitor`
|
|
||||||
consumes it and reflects the new mode into the `StreamAlarms` feed's
|
|
||||||
`provider_status`, the dashboard hub, and metrics. Client sessions do not
|
|
||||||
receive this event directly.
|
|
||||||
|
|
||||||
## Validation Rules
|
## Validation Rules
|
||||||
|
|
||||||
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
|
`MxAccessGrpcRequestValidator` rejects requests with `StatusCode.InvalidArgument` before any session work happens. The rules are intentionally narrow — anything that requires session state (for example, "session does not exist") is left for `ISessionManager` so the validator can stay synchronous and side-effect free.
|
||||||
|
|||||||
@@ -1,316 +0,0 @@
|
|||||||
# Alarm Subtag-Monitoring Fallback — Design
|
|
||||||
|
|
||||||
**Date:** 2026-06-13
|
|
||||||
**Status:** Superseded by implementation (merged to `main`). This is the original
|
|
||||||
brainstorming design; a few details below were refined during implementation —
|
|
||||||
see the inline **Superseded** notes. The shipped behaviour is documented in
|
|
||||||
`docs/AlarmClientDiscovery.md`, the client READMEs, and the contracts.
|
|
||||||
**Branch:** `feat/alarm-subtag-fallback`
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
The gateway's central alarm feed (`GatewayAlarmMonitor` → worker
|
|
||||||
`WnWrapAlarmConsumer`) depends on the AVEVA wnwrap COM consumer
|
|
||||||
(`WNWRAPCONSUMERLib.wwAlarmConsumerClass`), which polls `GetXmlCurrentAlarms2`
|
|
||||||
on the worker STA. That provider can fail at the COM boundary (the older
|
|
||||||
`aaAlarmManagedClient` crashed on FILETIME marshaling; wnwrap can still return
|
|
||||||
failure HRESULTs or throw `COMException`). When it does, the gateway loses all
|
|
||||||
alarm visibility.
|
|
||||||
|
|
||||||
This design adds a **second alarm source** — direct monitoring of each alarm
|
|
||||||
attribute's subtags (`.active`, `.acked`, …) via the existing MXAccess
|
|
||||||
`AddItem`/`Advise` pipeline — and **fails over to it automatically when the
|
|
||||||
wnwrap provider breaks, then fails back automatically when it recovers**. The
|
|
||||||
subtag source can also be forced on by config.
|
|
||||||
|
|
||||||
## Decisions (locked during brainstorming)
|
|
||||||
|
|
||||||
| Decision | Choice |
|
|
||||||
|---|---|
|
|
||||||
| Failover model | **Auto-failover + auto-failback** (both directions, runtime) |
|
|
||||||
| Watch-list source | **Galaxy Repository SQL discovery + config override** |
|
|
||||||
| Acknowledge in subtag mode | **Write the operator comment to the alarm's ack-comment subtag** (the write performs the ack) |
|
|
||||||
| Failure signal | **N consecutive wnwrap COM failures** (Subscribe / `GetXmlCurrentAlarms2` throws or returns a failure HRESULT) |
|
|
||||||
| Degraded-state visibility | **Both** — explicit field in the gRPC contract **and** dashboard + metrics |
|
|
||||||
| Synthesis location | **Worker-side** (`Approach A`) — keeps the parity rule "the gateway forwards only events the worker emits; it never synthesizes events" |
|
|
||||||
|
|
||||||
## Core principle
|
|
||||||
|
|
||||||
Subtag monitoring is, by definition, a **non-parity, lower-fidelity** alarm
|
|
||||||
source: it synthesizes alarm transitions from raw data changes, has no native
|
|
||||||
alarm GUID, no native original-raise timestamp, and a narrower field set. Per
|
|
||||||
`CLAUDE.md`, synthesizing events is allowed only as an explicit opt-in
|
|
||||||
non-parity mode. This design satisfies that by (a) doing the synthesis **inside
|
|
||||||
the worker** (so the gateway still only forwards worker-emitted events) and
|
|
||||||
(b) marking every degraded event and the whole feed as degraded so no client
|
|
||||||
mistakes it for the authoritative alarmmgr feed.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
GATEWAY (.NET 10, x64)
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ GatewayAlarmMonitor (BackgroundService) │
|
|
||||||
│ • resolves watch-list: Galaxy Repository SQL + config override │
|
|
||||||
│ • arms the worker with the watch-list at subscribe time │
|
|
||||||
│ • consumes AlarmProviderModeChanged → reflects mode into feed, │
|
|
||||||
│ /hubs/alarms dashboard hub, and metrics │
|
|
||||||
│ • forces a cache reconcile (QueryActiveAlarms) on every switch │
|
|
||||||
└───────────────────────────────┬───────────────────────────────────┘
|
|
||||||
│ IPC (WorkerEnvelope frames)
|
|
||||||
│ · SubscribeAlarms{ watch_list, failover cfg }
|
|
||||||
│ · AlarmProviderModeChanged{ mode, reason, hresult }
|
|
||||||
│ · OnAlarmTransitionEvent (degraded flag set in subtag mode)
|
|
||||||
▼
|
|
||||||
WORKER (.NET FW 4.8, x86, STA)
|
|
||||||
┌─────────────────────────────────────────────────────────────────┐
|
|
||||||
│ AlarmDispatcher → FailoverAlarmConsumer : IMxAccessAlarmConsumer │
|
|
||||||
│ ├─ primary : WnWrapAlarmConsumer (wnwrap COM poll, unchanged) │
|
|
||||||
│ └─ standby : SubtagAlarmConsumer (AddItem/Advise on subtags) │
|
|
||||||
│ │
|
|
||||||
│ FailoverAlarmConsumer owns the state machine: │
|
|
||||||
│ PrimaryActive ──(N consecutive wnwrap COM failures)──▶ Degraded │
|
|
||||||
│ Degraded ──(M consecutive clean wnwrap probe polls)──▶ Primary │
|
|
||||||
│ on each switch: snapshot the now-active provider, hand off │
|
|
||||||
└─────────────────────────────────────────────────────────────────┘
|
|
||||||
```
|
|
||||||
|
|
||||||
The failover state machine lives **worker-local** so the switch is instant — no
|
|
||||||
IPC round-trip at the moment alarmmgr dies. The gateway *arms* the standby
|
|
||||||
consumer up front (passes the watch-list at subscribe time) so it is ready
|
|
||||||
before it is ever needed.
|
|
||||||
|
|
||||||
## Components
|
|
||||||
|
|
||||||
### Worker (`src/ZB.MOM.WW.MxGateway.Worker/MxAccess/`)
|
|
||||||
|
|
||||||
**`SubtagAlarmConsumer : IMxAccessAlarmConsumer` (new)** — the standby provider.
|
|
||||||
|
|
||||||
- On `Subscribe`, instead of wnwrap registration it `AddItem`/`Advise`s the
|
|
||||||
configured subtags for each watch-list entry on the existing STA (reuses the
|
|
||||||
worker's item-subscription machinery). Per attribute it advises at minimum
|
|
||||||
`.active` and `.acked`; optionally `.priority`/severity, `.descr`, value/limit
|
|
||||||
if present.
|
|
||||||
- Converts each `OnDataChange` into the same `MxAlarmTransitionEvent` the wnwrap
|
|
||||||
consumer emits, via the synthesis rules below, and raises
|
|
||||||
`AlarmTransitionEmitted`. Marks each as **degraded**.
|
|
||||||
- `SnapshotActiveAlarms()` returns the currently-active set computed from
|
|
||||||
last-known subtag values.
|
|
||||||
- `AcknowledgeByName(...)` resolves the watch-list entry's ack-comment subtag and
|
|
||||||
issues a `Write(comment)` on the STA. `AcknowledgeByGuid(...)` maps the
|
|
||||||
synthetic GUID (see below) back to a reference, then does the same. If the
|
|
||||||
attribute exposes no writable ack-comment subtag, returns a failure code that
|
|
||||||
the gateway surfaces as `FailedPrecondition`.
|
|
||||||
- `PollOnce()` is a no-op (subtag mode is event-driven via Advise).
|
|
||||||
|
|
||||||
**`FailoverAlarmConsumer : IMxAccessAlarmConsumer` (new)** — composite + state
|
|
||||||
machine. Owns the wnwrap consumer (primary) and the subtag consumer (standby),
|
|
||||||
forwards `AlarmTransitionEmitted` from whichever child is active, and raises a
|
|
||||||
new `ProviderModeChanged` event on every switch.
|
|
||||||
|
|
||||||
- **Failure counting:** wraps `Subscribe`/`PollOnce` on the primary; a thrown
|
|
||||||
`COMException` or a failure HRESULT increments a consecutive-failure counter,
|
|
||||||
reset to zero on any clean poll.
|
|
||||||
- **Failover** (`PrimaryActive → Degraded`): at `ConsecutiveFailureThreshold`
|
|
||||||
(default 3), ensures the standby is subscribed (it was armed at startup), sets
|
|
||||||
active = standby, snapshots the standby's active set for hand-off, and emits
|
|
||||||
`ProviderModeChanged(SUBTAG, reason, hresult)`.
|
|
||||||
- **Failback probe** (`Degraded → PrimaryActive`): while degraded, every
|
|
||||||
`FailbackProbeIntervalSeconds` (default 30) it re-attempts wnwrap
|
|
||||||
`Subscribe`+`PollOnce` on the STA. After `FailbackStableProbes` (default 3)
|
|
||||||
consecutive clean polls it switches active = primary, returns the standby to
|
|
||||||
standby, and emits `ProviderModeChanged(ALARMMGR, "recovered")`.
|
|
||||||
- **Hand-off:** on every switch it takes `SnapshotActiveAlarms()` from the
|
|
||||||
now-active provider so the gateway can reconcile and avoid spurious
|
|
||||||
raise/clear storms.
|
|
||||||
|
|
||||||
**`AlarmDispatcher` / `MxAccessAlarmEventSink` / `AlarmCommandHandler`
|
|
||||||
(changed, minimal)** — `AlarmDispatcher` holds a `FailoverAlarmConsumer` instead
|
|
||||||
of a bare `WnWrapAlarmConsumer`; it subscribes to `ProviderModeChanged` and
|
|
||||||
enqueues a mode-changed worker event. The ack path routes by active mode (native
|
|
||||||
wnwrap ack in alarmmgr mode; ack-comment write in subtag mode), but that routing
|
|
||||||
is entirely inside the consumer — the dispatcher just calls
|
|
||||||
`AcknowledgeByName`/`AcknowledgeByGuid`.
|
|
||||||
|
|
||||||
### Gateway (`src/ZB.MOM.WW.MxGateway.Server/`)
|
|
||||||
|
|
||||||
**Galaxy Repository discovery (new query)** — alongside the existing GR SQL
|
|
||||||
browse RPCs, a query "attributes that have alarms configured, with their
|
|
||||||
ack-comment subtag and area", scoped to the configured area. Merged with the
|
|
||||||
config override (explicit includes/excludes). Produces the watch-list of
|
|
||||||
`AlarmSubtagTarget`s.
|
|
||||||
|
|
||||||
**`GatewayAlarmMonitor` (changed)** — resolves the watch-list at subscribe time
|
|
||||||
and passes it to the worker; consumes `AlarmProviderModeChanged` and reflects
|
|
||||||
the current provider mode into (a) the `AlarmFeedMessage` provider-status,
|
|
||||||
(b) the `/hubs/alarms` dashboard hub, and (c) metrics; forces a reconcile
|
|
||||||
(`QueryActiveAlarms`) on every switch. Re-runs discovery on its existing
|
|
||||||
reconcile cadence and pushes an updated watch-list when the model changes.
|
|
||||||
|
|
||||||
**`AlarmsOptions` (extended)** — new `Fallback` sub-section (below).
|
|
||||||
|
|
||||||
### Contract (`src/ZB.MOM.WW.MxGateway.Contracts/Protos/`)
|
|
||||||
|
|
||||||
**`mxaccess_gateway.proto`:**
|
|
||||||
|
|
||||||
- `enum AlarmProviderMode { ALARM_PROVIDER_MODE_UNSPECIFIED = 0; ALARMMGR = 1; SUBTAG = 2; }`
|
|
||||||
- New `AlarmFeedMessage` oneof case `AlarmProviderStatus provider_status`,
|
|
||||||
carrying `{ AlarmProviderMode mode; bool degraded; string reason;
|
|
||||||
google.protobuf.Timestamp since; }`. Emitted on stream open and on every
|
|
||||||
change so a late-joining client immediately learns the mode.
|
|
||||||
- Add `bool degraded` + `AlarmProviderMode source_provider` to
|
|
||||||
`OnAlarmTransitionEvent` **and** `ActiveAlarmSnapshot`, so per-item provenance
|
|
||||||
is visible even mid-stream. All additions are new field numbers — backward
|
|
||||||
compatible; existing clients ignore them and keep seeing alarms.
|
|
||||||
|
|
||||||
**`mxaccess_worker.proto`:**
|
|
||||||
|
|
||||||
> **Superseded:** these additions shipped in `mxaccess_gateway.proto`, not
|
|
||||||
> `mxaccess_worker.proto` — the worker imports the gateway proto and the alarm
|
|
||||||
> commands/events live there (`AlarmSubtagTarget`,
|
|
||||||
> `OnAlarmProviderModeChangedEvent`, the extended subscribe command).
|
|
||||||
|
|
||||||
- Extend the alarm-subscribe command with: `AlarmProviderMode forced_mode`
|
|
||||||
(`UNSPECIFIED` = auto), `int32 consecutive_failure_threshold`,
|
|
||||||
`int32 failback_probe_interval_seconds`, `int32 failback_stable_probes`, and
|
|
||||||
`repeated AlarmSubtagTarget watch_list`, where `AlarmSubtagTarget =
|
|
||||||
{ string alarm_full_reference; string source_object_reference;
|
|
||||||
string active_subtag; string acked_subtag; string ack_comment_subtag;
|
|
||||||
string priority_subtag; }`.
|
|
||||||
- New worker→gateway event `AlarmProviderModeChanged { AlarmProviderMode mode;
|
|
||||||
string reason; int32 hresult; google.protobuf.Timestamp at; }`.
|
|
||||||
|
|
||||||
> Generated code under `Generated/` and `clients/*/generated*/` is rebuilt from
|
|
||||||
> these `.proto` files — never hand-edited. Every generated client touched by
|
|
||||||
> the contract is rebuilt per the source-update workflow.
|
|
||||||
|
|
||||||
## Data flow
|
|
||||||
|
|
||||||
### Subtag synthesis rules
|
|
||||||
|
|
||||||
`SubtagAlarmConsumer` keeps last-known `(active, acked)` per watch-list entry and
|
|
||||||
emits transitions on change:
|
|
||||||
|
|
||||||
| Subtag change | Emitted transition | Notes |
|
|
||||||
|---|---|---|
|
|
||||||
| `active` false → true | `RAISE` (state `UNACK_ALM`) | `original_raise_timestamp` = first-observed active time |
|
|
||||||
| `acked` false → true while `active` | `ACKNOWLEDGE` | `operator_user`/`operator_comment` from ack-comment subtag if advised |
|
|
||||||
| `active` true → false | `CLEAR` | maps to `AckRtn` if acked at clear, else `UnackRtn` |
|
|
||||||
| `active` stays true, re-alarm | `RETRIGGER` | **only** if a re-alarm counter subtag exists; otherwise not synthesized (documented limitation) |
|
|
||||||
|
|
||||||
Snapshot state mapping for `ActiveAlarmSnapshot.current_state`:
|
|
||||||
`active && !acked → ACTIVE`, `active && acked → ACTIVE_ACKED`,
|
|
||||||
`!active → INACTIVE`.
|
|
||||||
|
|
||||||
Field degradation in subtag mode:
|
|
||||||
- `alarm_full_reference` — from the watch-list entry (stable, drives ack-by-ref).
|
|
||||||
- Synthetic, deterministic GUID derived by hashing `alarm_full_reference` so
|
|
||||||
GUID-based ack still resolves; flagged `degraded = true`.
|
|
||||||
- `severity` — from the priority subtag if advised, else 0.
|
|
||||||
- `original_raise_timestamp` — first-observed active time (best effort).
|
|
||||||
- `transition_timestamp` — the `OnDataChange` timestamp.
|
|
||||||
- `category`/`description`/`current_value`/`limit_value` — populated only if the
|
|
||||||
corresponding subtag is advised; otherwise empty.
|
|
||||||
|
|
||||||
### Acknowledge
|
|
||||||
|
|
||||||
`AcknowledgeAlarm`/`AcknowledgeAlarmByName` are unchanged at the RPC surface.
|
|
||||||
`AlarmDispatcher` routes by active provider mode:
|
|
||||||
- **alarmmgr mode:** native wnwrap `AlarmAckByName`/`AlarmAckByGUID` (unchanged).
|
|
||||||
- **subtag mode:** resolve the target's `ack_comment_subtag`, `Write` the
|
|
||||||
operator comment via the existing worker write path on the STA. No writable
|
|
||||||
ack-comment subtag → `FailedPrecondition`.
|
|
||||||
|
|
||||||
### Provider-mode reflection
|
|
||||||
|
|
||||||
Worker `AlarmProviderModeChanged` → `GatewayAlarmMonitor` → (a) emit/refresh
|
|
||||||
`AlarmFeedMessage.provider_status` to every `StreamAlarms` subscriber, (b) push
|
|
||||||
to `/hubs/alarms`, (c) update metrics, (d) force a reconcile.
|
|
||||||
|
|
||||||
## Error handling
|
|
||||||
|
|
||||||
- **Both providers down** (subtag advise also failing): the monitor stays
|
|
||||||
faulted and keeps retrying both; acknowledge returns `Unavailable`. No silent
|
|
||||||
data loss — the feed reports degraded with reason.
|
|
||||||
- **Empty watch-list in subtag mode** (GR SQL unavailable, no config override):
|
|
||||||
log + metric `alarm_fallback_watchlist_empty`; the feed reports degraded +
|
|
||||||
empty; the gateway keeps re-running discovery on its reconcile cadence and
|
|
||||||
pushes an updated watch-list when one becomes available.
|
|
||||||
- **Switch hand-off:** every switch snapshots the now-active provider and
|
|
||||||
reconciles against the gateway cache to avoid a raise/clear storm.
|
|
||||||
- **STA affinity:** all subtag advise/write and wnwrap probe calls run on the
|
|
||||||
worker STA (reuse the existing affinity guard) to satisfy
|
|
||||||
`ThreadingModel=Apartment`.
|
|
||||||
|
|
||||||
### Metrics
|
|
||||||
|
|
||||||
- `mxgateway_alarm_provider_mode` (gauge: 1 = alarmmgr, 2 = subtag)
|
|
||||||
- `mxgateway_alarm_provider_switch_total{from,to,reason}` (counter)
|
|
||||||
- `mxgateway_alarm_fallback_watchlist_size` (gauge)
|
|
||||||
|
|
||||||
> **Superseded:** the shipped meter names are `mxgateway.alarms.provider_mode`
|
|
||||||
> (gauge) and `mxgateway.alarms.provider_switches{from,to,reason}` (counter,
|
|
||||||
> `reason` bounded to `failover`/`failback`/`unknown`). The watch-list-size /
|
|
||||||
> watch-list-empty gauges were not implemented; an empty watch-list is surfaced
|
|
||||||
> via a warning log and the feed's degraded `ProviderStatus` instead.
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
```jsonc
|
|
||||||
"MxGateway": {
|
|
||||||
"Alarms": {
|
|
||||||
"Enabled": true,
|
|
||||||
"SubscriptionExpression": "\\\\DESKTOP-6JL3KKO\\Galaxy!DEV",
|
|
||||||
"DefaultArea": "DEV",
|
|
||||||
"ReconcileIntervalSeconds": 30,
|
|
||||||
"Fallback": {
|
|
||||||
"Mode": "Auto", // Auto | ForceAlarmManager | ForceSubtag
|
|
||||||
"ConsecutiveFailureThreshold": 3,
|
|
||||||
"FailbackProbeIntervalSeconds": 30,
|
|
||||||
"FailbackStableProbes": 3,
|
|
||||||
"Discovery": {
|
|
||||||
"UseGalaxyRepository": true,
|
|
||||||
"Area": "", // defaults to Alarms.DefaultArea
|
|
||||||
"IncludeAttributes": [], // explicit additions
|
|
||||||
"ExcludeAttributes": []
|
|
||||||
},
|
|
||||||
"Subtags": {
|
|
||||||
"Active": "active",
|
|
||||||
"Acked": "acked",
|
|
||||||
"AckComment": "", // verified against MXAccess analysis
|
|
||||||
"Priority": "priority"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`GatewayOptionsValidator` additions: `Mode = ForceSubtag` with empty discovery
|
|
||||||
result and no explicit `IncludeAttributes` → startup validation warning;
|
|
||||||
threshold/interval/probe values floored at sane minimums.
|
|
||||||
|
|
||||||
## Open item to confirm during implementation
|
|
||||||
|
|
||||||
The exact AVEVA subtag names (`.active`, `.acked`, the ack-comment attribute,
|
|
||||||
priority) must be confirmed against the MXAccess analysis project
|
|
||||||
(`C:\Users\dohertj2\Desktop\mxaccess`, `docs/MXAccess-Public-API.md`) and the
|
|
||||||
live Galaxy before wiring `SubtagAlarmConsumer`. The config `Subtags` block
|
|
||||||
exists precisely so the resolved names are not hard-coded.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
| Layer | Tests |
|
|
||||||
|---|---|
|
|
||||||
| Worker unit (`MxGateway.Worker.Tests`, x86) | `SubtagAlarmConsumer` synthesis — feed `OnDataChange` sequences, assert raise/ack/clear transitions, snapshot states, degraded flag, synthetic-GUID stability, ack-comment write routing |
|
|
||||||
| Worker unit | `FailoverAlarmConsumer` state machine — fake wnwrap throwing after K polls: assert switch at threshold, failback after stable probes, `ProviderModeChanged` emitted, no duplicate transitions across switch (hand-off reconcile) |
|
|
||||||
| Gateway unit (`MxGateway.Tests`, fake worker) | discovery + config-override merge; `GatewayAlarmMonitor` reflects mode into feed + hub; metrics increment on switch |
|
|
||||||
| Contract | proto round-trip for new fields; existing alarm tests unchanged (alarmmgr-mode regression — parity preserved) |
|
|
||||||
| Live (opt-in, `MXGATEWAY_RUN_LIVE_MXACCESS_TESTS=1`) | real subtag advise + ack-comment write against a live alarm; GR SQL discovery query against the `ZB` DB (gated like existing GR tests) |
|
|
||||||
|
|
||||||
## Docs to update in the same change
|
|
||||||
|
|
||||||
`gateway.md` (alarm provider section), `docs/DesignDecisions.md` (record the
|
|
||||||
fallback decision), `docs/GatewayConfiguration.md` (the `Fallback` block),
|
|
||||||
`docs/AlarmClientDiscovery.md` (subtag provider + synthesis rules),
|
|
||||||
`docs/Grpc.md` (the new `provider_status` / `degraded` fields), and any client
|
|
||||||
READMEs whose generated alarm types gain fields.
|
|
||||||
@@ -1,860 +0,0 @@
|
|||||||
# Alarm Subtag-Monitoring Fallback — Implementation Plan
|
|
||||||
|
|
||||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers-extended-cc:executing-plans (or subagent-driven-development) to implement this plan task-by-task.
|
|
||||||
|
|
||||||
**Goal:** Add a second alarm source — direct MXAccess subtag monitoring — that the gateway auto-fails-over to when the wnwrap alarmmgr provider breaks, auto-fails-back to when it recovers, and can be forced on by config.
|
|
||||||
|
|
||||||
**Architecture:** Worker-side synthesis (parity rule preserved). A new `SubtagAlarmConsumer` (own `LMXProxyServerClass`, `AddItem`/`Advise` on alarm subtags) and a `FailoverAlarmConsumer` composite (state machine over the wnwrap primary + subtag standby) both implement the existing `IMxAccessAlarmConsumer` seam. The gateway resolves the subtag watch-list (Galaxy Repository SQL + config override), arms the worker at subscribe time, and reflects the live provider mode into the gRPC alarm feed, the dashboard hub, and metrics.
|
|
||||||
|
|
||||||
**Tech Stack:** .NET 10 (gateway, x64) + .NET Framework 4.8 (worker, x86, STA), protobuf/gRPC, `Microsoft.Data.SqlClient` (Galaxy Repository), SignalR (dashboard), `System.Diagnostics.Metrics`, xUnit (plain `Assert`, no FluentAssertions).
|
|
||||||
|
|
||||||
**Design source:** `docs/plans/2026-06-13-alarm-subtag-fallback-design.md`
|
|
||||||
|
|
||||||
**Branch:** `feat/alarm-subtag-fallback` (already created)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Conventions for every task
|
|
||||||
|
|
||||||
- **TDD:** write the failing test, run it red, implement, run it green, commit.
|
|
||||||
- **xUnit, plain `Assert.*`**, naming `Subject_Condition_Expected`. Worker fakes are sealed private nested classes that raise events.
|
|
||||||
- **Build/test commands:**
|
|
||||||
- Contracts regen: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
|
||||||
- Gateway: `dotnet build src/ZB.MOM.WW.MxGateway.Server` ; `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj`
|
|
||||||
- Worker (x86): `dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86` ; `dotnet test src/ZB.MOM.WW.MxGateway.Worker.Tests/ZB.MOM.WW.MxGateway.Worker.Tests.csproj -p:Platform=x86`
|
|
||||||
- Single test: append `--filter FullyQualifiedName~<ClassOrMethod>`
|
|
||||||
- **Build is strict:** `TreatWarningsAsErrors=true`, nullable enabled. Add XML doc comments on public members (the repo runs a doc checker).
|
|
||||||
- **Generated code** under `Generated/` is never hand-edited — rebuild the contracts project to regenerate.
|
|
||||||
- **Namespaces:** worker MxAccess types live in `ZB.MOM.WW.MxGateway.Worker.MxAccess`; proto C# types in `ZB.MOM.WW.MxGateway.Contracts.Proto`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 0 — Contracts
|
|
||||||
|
|
||||||
### Task 1: Worker proto — subtag watch-list, failover config, provider-mode enum
|
|
||||||
|
|
||||||
**Classification:** high-risk
|
|
||||||
**Estimated implement time:** ~4 min
|
|
||||||
**Parallelizable with:** none (Task 2 imports these types)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (real `SubscribeAlarmsCommand` at ~line 324; `MxCommand` references it at 123-125)
|
|
||||||
|
|
||||||
> **CORRECTION (execution):** The alarm command messages and `MxCommand` live in **`mxaccess_gateway.proto`**, not the worker proto. `mxaccess_worker.proto` *imports* the gateway proto (`WorkerCommand.command` is `mxaccess_gateway.v1.MxCommand`), so the gateway proto is the base and the worker proto needs **no** change. `AlarmProviderMode` and the new types are added to the gateway proto and are visible to worker code as `mxaccess_gateway.v1` types. Tasks 1 and 2 are executed as a single combined edit on this one file.
|
|
||||||
|
|
||||||
**Step 1: Add the enum and messages.** In `mxaccess_gateway.proto`, extend the existing `SubscribeAlarmsCommand` message (line 324) and add the new types after it:
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
// Provider selection / current provider for the alarm feed. Defined here in
|
|
||||||
// the worker contract because the worker SubscribeAlarmsCommand references it;
|
|
||||||
// mxaccess_gateway.proto imports this file and reuses the same enum.
|
|
||||||
enum AlarmProviderMode {
|
|
||||||
ALARM_PROVIDER_MODE_UNSPECIFIED = 0; // auto: alarmmgr primary, subtag fallback
|
|
||||||
ALARM_PROVIDER_MODE_ALARMMGR = 1;
|
|
||||||
ALARM_PROVIDER_MODE_SUBTAG = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
message SubscribeAlarmsCommand {
|
|
||||||
string subscription_expression = 1; // existing field — keep
|
|
||||||
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
|
||||||
AlarmProviderMode forced_mode = 2;
|
|
||||||
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
|
|
||||||
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
|
||||||
repeated AlarmSubtagTarget watch_list = 3;
|
|
||||||
AlarmFailoverConfig failover = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// One alarm attribute the subtag consumer advises. Addresses are full MXAccess
|
|
||||||
// item references the worker passes straight to AddItem.
|
|
||||||
message AlarmSubtagTarget {
|
|
||||||
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
|
|
||||||
string source_object_reference = 2; // e.g. "Tank01"
|
|
||||||
string active_subtag = 3; // item address of the in-alarm boolean
|
|
||||||
string acked_subtag = 4; // item address of the acknowledged boolean
|
|
||||||
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
|
|
||||||
string priority_subtag = 6; // optional severity source; empty if absent
|
|
||||||
}
|
|
||||||
|
|
||||||
message AlarmFailoverConfig {
|
|
||||||
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
|
|
||||||
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
|
|
||||||
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
`UnsubscribeAlarmsCommand` and `AcknowledgeAlarmCommand` are unchanged.
|
|
||||||
|
|
||||||
**Step 2: Regenerate & verify it compiles.**
|
|
||||||
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
|
||||||
Expected: build succeeds; generated `AlarmProviderMode`, `AlarmSubtagTarget`, `AlarmFailoverConfig` types appear.
|
|
||||||
|
|
||||||
**Step 3: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_worker.proto
|
|
||||||
git commit -m "contracts(worker): subtag watch-list + failover config + AlarmProviderMode"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 2: Gateway proto — provider status on the feed, degraded provenance, mode-changed event
|
|
||||||
|
|
||||||
**Classification:** high-risk
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none (depends on Task 1; Task 3 tests both)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto` (`OnAlarmTransitionEvent` ~719-771, `ActiveAlarmSnapshot` ~783-803, `AlarmFeedMessage` ~860-870, `MxEvent` family enum + body oneof, `MxEventFamily` enum)
|
|
||||||
|
|
||||||
**Step 1: Add degraded provenance to the two alarm payloads.** Append to `OnAlarmTransitionEvent` (next free field 14):
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
// True when this transition came from the subtag-monitoring fallback rather
|
|
||||||
// than the native alarmmgr provider — i.e. it was synthesized from data
|
|
||||||
// changes and carries reduced fidelity (synthetic GUID, no native raise time).
|
|
||||||
bool degraded = 14;
|
|
||||||
// Which provider produced this transition.
|
|
||||||
AlarmProviderMode source_provider = 15;
|
|
||||||
```
|
|
||||||
|
|
||||||
Append the identical two fields to `ActiveAlarmSnapshot` (next free field 14):
|
|
||||||
```protobuf
|
|
||||||
bool degraded = 14;
|
|
||||||
AlarmProviderMode source_provider = 15;
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Add provider status to the feed oneof.** Add a new oneof case to `AlarmFeedMessage` (next free field 4) and a new message:
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
message AlarmFeedMessage {
|
|
||||||
oneof payload {
|
|
||||||
ActiveAlarmSnapshot active_alarm = 1;
|
|
||||||
bool snapshot_complete = 2;
|
|
||||||
OnAlarmTransitionEvent transition = 3;
|
|
||||||
// Provider-mode status. Emitted once on stream open and again on every
|
|
||||||
// failover/failback so late joiners learn the current mode immediately.
|
|
||||||
AlarmProviderStatus provider_status = 4;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
message AlarmProviderStatus {
|
|
||||||
AlarmProviderMode mode = 1;
|
|
||||||
bool degraded = 2; // true whenever mode == SUBTAG
|
|
||||||
string reason = 3; // human-readable switch reason
|
|
||||||
google.protobuf.Timestamp since = 4;
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 3: Add the worker→gateway mode-changed event to `MxEvent`.** Find the `MxEventFamily` enum and the `MxEvent` body oneof. Add a family member and a body message + oneof case (use the next free family value and the next free `MxEvent` body field number — check the file):
|
|
||||||
|
|
||||||
```protobuf
|
|
||||||
// in MxEventFamily enum:
|
|
||||||
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = <next>;
|
|
||||||
|
|
||||||
// new message near OnAlarmTransitionEvent:
|
|
||||||
message OnAlarmProviderModeChangedEvent {
|
|
||||||
AlarmProviderMode mode = 1;
|
|
||||||
string reason = 2;
|
|
||||||
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
|
||||||
google.protobuf.Timestamp at = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// in MxEvent body oneof:
|
|
||||||
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = <next>;
|
|
||||||
```
|
|
||||||
|
|
||||||
`AlarmProviderMode` is defined in `mxaccess_worker.proto`; confirm `mxaccess_gateway.proto` already has `import "mxaccess_worker.proto";` (it references `SubscribeAlarmsCommand`, so it does) and reference the enum unqualified or via its package as the existing references do.
|
|
||||||
|
|
||||||
**Step 4: Regenerate & verify.**
|
|
||||||
Run: `dotnet build src/ZB.MOM.WW.MxGateway.Contracts/ZB.MOM.WW.MxGateway.Contracts.csproj`
|
|
||||||
Expected: build succeeds.
|
|
||||||
|
|
||||||
**Step 5: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Contracts/Protos/mxaccess_gateway.proto
|
|
||||||
git commit -m "contracts(gateway): AlarmProviderStatus feed case, degraded provenance, mode-changed event"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 3: Proto round-trip tests for the new alarm fields
|
|
||||||
|
|
||||||
**Classification:** small
|
|
||||||
**Estimated implement time:** ~3 min
|
|
||||||
**Parallelizable with:** none (depends on Tasks 1-2)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs`
|
|
||||||
|
|
||||||
**Step 1: Add tests** mirroring the existing `Event_RoundTripsOnAlarmTransitionWithFullPayload` style:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact]
|
|
||||||
public void Feed_RoundTripsProviderStatus()
|
|
||||||
{
|
|
||||||
var since = Timestamp.FromDateTime(new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
|
||||||
var original = new AlarmFeedMessage
|
|
||||||
{
|
|
||||||
ProviderStatus = new AlarmProviderStatus
|
|
||||||
{
|
|
||||||
Mode = AlarmProviderMode.Subtag,
|
|
||||||
Degraded = true,
|
|
||||||
Reason = "wnwrap poll failed 3x (HRESULT 0x80004005)",
|
|
||||||
Since = since,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
var parsed = AlarmFeedMessage.Parser.ParseFrom(original.ToByteArray());
|
|
||||||
|
|
||||||
Assert.Equal(original, parsed);
|
|
||||||
Assert.Equal(AlarmFeedMessage.PayloadOneofCase.ProviderStatus, parsed.PayloadCase);
|
|
||||||
Assert.True(parsed.ProviderStatus.Degraded);
|
|
||||||
Assert.Equal(AlarmProviderMode.Subtag, parsed.ProviderStatus.Mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Transition_RoundTripsDegradedProvenance()
|
|
||||||
{
|
|
||||||
var t = new OnAlarmTransitionEvent
|
|
||||||
{
|
|
||||||
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
|
||||||
TransitionKind = AlarmTransitionKind.Raise,
|
|
||||||
Degraded = true,
|
|
||||||
SourceProvider = AlarmProviderMode.Subtag,
|
|
||||||
};
|
|
||||||
|
|
||||||
var parsed = OnAlarmTransitionEvent.Parser.ParseFrom(t.ToByteArray());
|
|
||||||
|
|
||||||
Assert.True(parsed.Degraded);
|
|
||||||
Assert.Equal(AlarmProviderMode.Subtag, parsed.SourceProvider);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Run red→green.**
|
|
||||||
Run: `dotnet test src/ZB.MOM.WW.MxGateway.Tests/ZB.MOM.WW.MxGateway.Tests.csproj --filter FullyQualifiedName~ProtobufContractRoundTripTests`
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
**Step 3: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Tests/Contracts/ProtobufContractRoundTripTests.cs
|
|
||||||
git commit -m "test(contracts): round-trip provider status + degraded provenance"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1 — Worker: subtag consumer + failover
|
|
||||||
|
|
||||||
### Task 4: Subtag value-source abstraction + synthesis state holder
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none (Task 5 builds on it)
|
|
||||||
|
|
||||||
A testable seam so synthesis logic is unit-tested without COM. The COM wiring lands in Task 6.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs`
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs`
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs`
|
|
||||||
|
|
||||||
**Step 1: Define the source abstraction.** `ISubtagAlarmSource` advises subtag addresses and raises a normalized value-change callback on the STA:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Worker.MxAccess;
|
|
||||||
|
|
||||||
/// <summary>A change in one advised subtag value, normalized off the COM boundary.</summary>
|
|
||||||
public sealed class SubtagValueChange
|
|
||||||
{
|
|
||||||
/// <summary>The full item address that changed (matches an AlarmSubtagTarget subtag).</summary>
|
|
||||||
public string ItemAddress { get; init; } = string.Empty;
|
|
||||||
/// <summary>The new value (boolean for .active/.acked, numeric for priority).</summary>
|
|
||||||
public object? Value { get; init; }
|
|
||||||
/// <summary>The change timestamp in UTC.</summary>
|
|
||||||
public DateTime TimestampUtc { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Advises a set of MXAccess subtag addresses and surfaces value changes.
|
|
||||||
/// The production implementation (Task 6) owns its own LMXProxyServerClass;
|
|
||||||
/// tests substitute a fake that pushes <see cref="SubtagValueChange"/>s.
|
|
||||||
/// </summary>
|
|
||||||
public interface ISubtagAlarmSource : IDisposable
|
|
||||||
{
|
|
||||||
/// <summary>Raised on the STA when an advised subtag's value changes.</summary>
|
|
||||||
event EventHandler<SubtagValueChange>? ValueChanged;
|
|
||||||
|
|
||||||
/// <summary>Advises every subtag in the supplied addresses; idempotent per address.</summary>
|
|
||||||
void Advise(IReadOnlyCollection<string> itemAddresses);
|
|
||||||
|
|
||||||
/// <summary>Writes a value to an item address (used for the ack-comment write).</summary>
|
|
||||||
void Write(string itemAddress, object? value);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Write the state-machine tests first.** `SubtagAlarmStateMachine` maps `(active, acked)` changes per target to `MxAlarmTransitionEvent`s. Test the four core transitions:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Worker.Tests.MxAccess;
|
|
||||||
|
|
||||||
public sealed class SubtagAlarmStateMachineTests
|
|
||||||
{
|
|
||||||
private static AlarmSubtagTarget Target() => new()
|
|
||||||
{
|
|
||||||
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
|
||||||
SourceObjectReference = "Tank01",
|
|
||||||
ActiveSubtag = "Tank01.Level.HiHi.active",
|
|
||||||
AckedSubtag = "Tank01.Level.HiHi.acked",
|
|
||||||
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ActiveFalseToTrue_EmitsRaise_FlaggedDegraded()
|
|
||||||
{
|
|
||||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
|
||||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
|
||||||
|
|
||||||
var events = sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
|
||||||
|
|
||||||
var e = Assert.Single(events);
|
|
||||||
Assert.Equal(MxAlarmStateKind.UnackAlm, e.Record.State);
|
|
||||||
Assert.Equal(MxAlarmStateKind.Unspecified, e.PreviousState);
|
|
||||||
Assert.Equal("Tank01.Level.HiHi", e.Record.TagName); // reference minus provider/area
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AckedTrueWhileActive_EmitsAckTransition()
|
|
||||||
{
|
|
||||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
|
||||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
|
||||||
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
|
||||||
|
|
||||||
var events = sm.Apply("Tank01.Level.HiHi.acked", true, ts.AddSeconds(5));
|
|
||||||
|
|
||||||
var e = Assert.Single(events);
|
|
||||||
Assert.Equal(MxAlarmStateKind.AckAlm, e.Record.State);
|
|
||||||
Assert.Equal(MxAlarmStateKind.UnackAlm, e.PreviousState);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ActiveTrueToFalse_WhileUnacked_EmitsUnackRtn()
|
|
||||||
{
|
|
||||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
|
||||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
|
||||||
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
|
||||||
|
|
||||||
var events = sm.Apply("Tank01.Level.HiHi.active", false, ts.AddSeconds(10));
|
|
||||||
|
|
||||||
var e = Assert.Single(events);
|
|
||||||
Assert.Equal(MxAlarmStateKind.UnackRtn, e.Record.State);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Snapshot_ReflectsActiveAndAckedState()
|
|
||||||
{
|
|
||||||
var sm = new SubtagAlarmStateMachine(new[] { Target() });
|
|
||||||
var ts = new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc);
|
|
||||||
sm.Apply("Tank01.Level.HiHi.active", true, ts);
|
|
||||||
sm.Apply("Tank01.Level.HiHi.acked", true, ts);
|
|
||||||
|
|
||||||
var snap = Assert.Single(sm.SnapshotActive());
|
|
||||||
Assert.Equal(MxAlarmStateKind.AckAlm, snap.State);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Run: `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmStateMachineTests` → FAIL (type missing).
|
|
||||||
|
|
||||||
**Step 3: Implement `SubtagAlarmStateMachine`.** Build an address→target index (active/acked/priority/comment addresses), hold per-reference `(bool active, bool acked, DateTime firstRaiseUtc, int priority)`, and emit on change:
|
|
||||||
- active `false→true` ⇒ `UnackAlm`, set `firstRaiseUtc`, `PreviousState` from prior state.
|
|
||||||
- acked `false→true` while active ⇒ `AckAlm`.
|
|
||||||
- active `true→false` ⇒ `AckRtn` if currently acked else `UnackRtn`; then reset acked.
|
|
||||||
- priority change ⇒ update stored priority, no transition.
|
|
||||||
- `TagName` = `alarm_full_reference` with any `Provider!Area.` prefix stripped (match `WnWrapAlarmConsumer`'s reference shape so `GatewayAlarmMonitor` keys align). Set `ProviderName`, `Group`, `Priority`, `AlarmComment` from the target/last values. Mark a `Degraded`/source flag (carried via a new field — see Task 5 wiring).
|
|
||||||
- `SnapshotActive()` returns `MxAlarmSnapshotRecord` for references whose active is true.
|
|
||||||
|
|
||||||
**Step 4: Run green.** Expected: PASS.
|
|
||||||
|
|
||||||
**Step 5: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ISubtagAlarmSource.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmStateMachine.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmStateMachineTests.cs
|
|
||||||
git commit -m "worker(alarms): subtag value-source seam + synthesis state machine"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 5: `SubtagAlarmConsumer` over the source seam (no COM yet)
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none (depends on Task 4)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs`
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs`
|
|
||||||
|
|
||||||
**Step 1: Test with a fake `ISubtagAlarmSource`.** Drive value changes through the source, assert `AlarmTransitionEmitted` fires with synthesized records and that ack writes the comment to the ack-comment subtag:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed class SubtagAlarmConsumerTests
|
|
||||||
{
|
|
||||||
private sealed class FakeSource : ISubtagAlarmSource
|
|
||||||
{
|
|
||||||
public event EventHandler<SubtagValueChange>? ValueChanged;
|
|
||||||
public List<string> Advised { get; } = new();
|
|
||||||
public (string Address, object? Value)? LastWrite { get; private set; }
|
|
||||||
public void Advise(IReadOnlyCollection<string> a) => Advised.AddRange(a);
|
|
||||||
public void Write(string a, object? v) => LastWrite = (a, v);
|
|
||||||
public void Raise(string addr, object? val, DateTime ts) =>
|
|
||||||
ValueChanged?.Invoke(this, new SubtagValueChange { ItemAddress = addr, Value = val, TimestampUtc = ts });
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AlarmSubtagTarget Target() => new()
|
|
||||||
{
|
|
||||||
AlarmFullReference = "Galaxy!Area.Tank01.Level.HiHi",
|
|
||||||
ActiveSubtag = "Tank01.Level.HiHi.active",
|
|
||||||
AckedSubtag = "Tank01.Level.HiHi.acked",
|
|
||||||
AckCommentSubtag = "Tank01.Level.HiHi.ackmsg",
|
|
||||||
};
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Subscribe_AdvisesAllSubtags()
|
|
||||||
{
|
|
||||||
var src = new FakeSource();
|
|
||||||
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
|
||||||
c.Subscribe("ignored-in-subtag-mode");
|
|
||||||
Assert.Contains("Tank01.Level.HiHi.active", src.Advised);
|
|
||||||
Assert.Contains("Tank01.Level.HiHi.acked", src.Advised);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ValueChange_RaisesSynthesizedTransition()
|
|
||||||
{
|
|
||||||
var src = new FakeSource();
|
|
||||||
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
|
||||||
c.Subscribe("x");
|
|
||||||
MxAlarmTransitionEvent? seen = null;
|
|
||||||
c.AlarmTransitionEmitted += (_, e) => seen = e;
|
|
||||||
|
|
||||||
src.Raise("Tank01.Level.HiHi.active", true, new DateTime(2026, 6, 13, 9, 0, 0, DateTimeKind.Utc));
|
|
||||||
|
|
||||||
Assert.NotNull(seen);
|
|
||||||
Assert.Equal(MxAlarmStateKind.UnackAlm, seen!.Record.State);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AcknowledgeByName_WritesCommentToAckCommentSubtag()
|
|
||||||
{
|
|
||||||
var src = new FakeSource();
|
|
||||||
using var c = new SubtagAlarmConsumer(src, new[] { Target() });
|
|
||||||
c.Subscribe("x");
|
|
||||||
|
|
||||||
int rc = c.AcknowledgeByName("Tank01.Level.HiHi", "Galaxy", "Area",
|
|
||||||
"ack from HMI", "op1", "node", "dom", "Op One");
|
|
||||||
|
|
||||||
Assert.Equal(0, rc);
|
|
||||||
Assert.Equal(("Tank01.Level.HiHi.ackmsg", (object?)"ack from HMI"), src.LastWrite);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Implement `SubtagAlarmConsumer : IMxAccessAlarmConsumer`.**
|
|
||||||
- Constructor `(ISubtagAlarmSource source, IReadOnlyList<AlarmSubtagTarget> watchList)`; build a `SubtagAlarmStateMachine`; index `alarm_full_reference`→target for ack routing.
|
|
||||||
- `Subscribe(_)`: call `source.Advise(<all subtag addresses>)`; subscribe to `source.ValueChanged`, feed each into the state machine, and re-raise each produced `MxAlarmTransitionEvent` via `AlarmTransitionEmitted` (mark degraded).
|
|
||||||
- `AcknowledgeByName(alarmName, …, comment, …)`: resolve the target by reference; if no `AckCommentSubtag`, return a non-zero failure code; else `source.Write(target.AckCommentSubtag, comment)` and return 0.
|
|
||||||
- `AcknowledgeByGuid(guid, …)`: map the synthetic GUID (deterministic hash of reference — see Task 8 helper, or a local copy) back to a reference, then delegate to the name path; unknown GUID ⇒ non-zero.
|
|
||||||
- `SnapshotActiveAlarms()`: from the state machine.
|
|
||||||
- `PollOnce()`: no-op.
|
|
||||||
- `Dispose()`: unsubscribe + dispose source.
|
|
||||||
|
|
||||||
**Step 3: Run green.** `dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~SubtagAlarmConsumerTests`.
|
|
||||||
|
|
||||||
**Step 4: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SubtagAlarmConsumer.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/SubtagAlarmConsumerTests.cs
|
|
||||||
git commit -m "worker(alarms): SubtagAlarmConsumer synthesizing transitions over the source seam"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 6: COM-backed `LmxSubtagAlarmSource` (own LMXProxyServerClass)
|
|
||||||
|
|
||||||
**Classification:** high-risk
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none
|
|
||||||
|
|
||||||
The only piece that touches live COM. Like `WnWrapAlarmConsumer`, it owns its own MXAccess server object so the subtag source is self-contained and isolated from the session's item pipeline. Logic stays thin (advise/write/marshal); real verification is the live smoke test in Task 17.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs`
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs` (constructor/guard tests only; COM path is live-gated)
|
|
||||||
|
|
||||||
**Step 1: Implement `LmxSubtagAlarmSource : ISubtagAlarmSource`.**
|
|
||||||
- Own an `LMXProxyServerClass` (reuse the worker's `IMxAccessServer`/`MxAccessComServer` wrapper + `IMxAccessComObjectFactory` so it is fakeable; constructor takes the factory).
|
|
||||||
- `Advise(addresses)`: `RegisterServer` (topic) once; per address `AddItem`→`itemHandle`, `Advise`, and record `itemHandle→address`. Subscribe to the proxy's `OnDataChange`; in the handler, look up the address by `phItemHandle`, normalize `pvItemValue` (VARIANT→bool/double) and `pftItemTimeStamp`→UTC, and raise `ValueChanged`. All calls run on the STA (the worker STA pumps messages, so `OnDataChange` delivers).
|
|
||||||
- `Write(address, value)`: resolve/create the item handle, `server.Write(serverHandle, itemHandle, value, userId: 0)`.
|
|
||||||
- `Dispose()`: `UnAdvise`/`RemoveItem`/`Unregister`/release COM.
|
|
||||||
|
|
||||||
**Step 2: Tests** — only the non-COM guards (null factory throws; `Write` before `Advise` resolves a handle or throws a clear error). Mark the COM round-trip `[LiveMxAccessFact]` and `Skip` per the `AlarmsLiveSmokeTests` precedent.
|
|
||||||
|
|
||||||
**Step 3: Build x86 + run unit tests.**
|
|
||||||
`dotnet build src/ZB.MOM.WW.MxGateway.Worker/ZB.MOM.WW.MxGateway.Worker.csproj -p:Platform=x86`
|
|
||||||
`dotnet test ...Worker.Tests... -p:Platform=x86 --filter FullyQualifiedName~LmxSubtagAlarmSourceTests`
|
|
||||||
|
|
||||||
**Step 4: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/LmxSubtagAlarmSource.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/LmxSubtagAlarmSourceTests.cs
|
|
||||||
git commit -m "worker(alarms): COM-backed LmxSubtagAlarmSource advising alarm subtags"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 7: `FailoverAlarmConsumer` state machine
|
|
||||||
|
|
||||||
**Classification:** high-risk
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none (depends on Task 5)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs`
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs` (small EventArgs)
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs`
|
|
||||||
|
|
||||||
**Step 1: Test the switch/failback with a fake primary that throws.**
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
public sealed class FailoverAlarmConsumerTests
|
|
||||||
{
|
|
||||||
private sealed class FlakyPrimary : IMxAccessAlarmConsumer
|
|
||||||
{
|
|
||||||
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
|
|
||||||
public int PollsUntilHeal = int.MaxValue; // becomes healthy after N polls while degraded
|
|
||||||
public bool ThrowOnPoll = true;
|
|
||||||
private int _polls;
|
|
||||||
public void Subscribe(string s) { if (ThrowOnPoll) throw new COMException("boom", unchecked((int)0x80004005)); }
|
|
||||||
public void PollOnce()
|
|
||||||
{
|
|
||||||
_polls++;
|
|
||||||
if (ThrowOnPoll && _polls < PollsUntilHeal) throw new COMException("boom", unchecked((int)0x80004005));
|
|
||||||
}
|
|
||||||
public int AcknowledgeByGuid(Guid g, string c, string a, string b, string d, string e) => 0;
|
|
||||||
public int AcknowledgeByName(string n, string p, string gr, string c, string a, string b, string d, string e) => 0;
|
|
||||||
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => Array.Empty<MxAlarmSnapshotRecord>();
|
|
||||||
public void Dispose() { }
|
|
||||||
}
|
|
||||||
|
|
||||||
private sealed class StubStandby : IMxAccessAlarmConsumer { /* records Subscribe, no-op rest */ }
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void Primary_FailsThresholdTimes_SwitchesToSubtagAndEmitsModeChange()
|
|
||||||
{
|
|
||||||
var primary = new FlakyPrimary();
|
|
||||||
var standby = new StubStandby();
|
|
||||||
using var c = new FailoverAlarmConsumer(primary, standby,
|
|
||||||
new FailoverSettings(threshold: 3, probeIntervalSeconds: 30, stableProbes: 3));
|
|
||||||
AlarmProviderModeChange? change = null;
|
|
||||||
c.ProviderModeChanged += (_, e) => change = e;
|
|
||||||
|
|
||||||
c.Subscribe("\\\\host\\Galaxy!Area"); // primary.Subscribe throws -> counts as failure 1
|
|
||||||
c.PollOnce(); // failure 2
|
|
||||||
c.PollOnce(); // failure 3 -> switch
|
|
||||||
|
|
||||||
Assert.NotNull(change);
|
|
||||||
Assert.Equal(AlarmProviderMode.Subtag, change!.Mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void WhileDegraded_PrimaryHeals_FailsBackAfterStableProbes()
|
|
||||||
{
|
|
||||||
var primary = new FlakyPrimary { PollsUntilHeal = 0 }; // will heal once we stop throwing
|
|
||||||
var standby = new StubStandby();
|
|
||||||
using var c = new FailoverAlarmConsumer(primary, standby,
|
|
||||||
new FailoverSettings(threshold: 1, probeIntervalSeconds: 0, stableProbes: 2));
|
|
||||||
var modes = new List<AlarmProviderMode>();
|
|
||||||
c.ProviderModeChanged += (_, e) => modes.Add(e.Mode);
|
|
||||||
|
|
||||||
c.Subscribe("x"); // failure -> switch to subtag
|
|
||||||
primary.ThrowOnPoll = false;
|
|
||||||
c.ProbeOnce(); // clean probe 1
|
|
||||||
c.ProbeOnce(); // clean probe 2 -> failback
|
|
||||||
|
|
||||||
Assert.Equal(AlarmProviderMode.Subtag, modes[0]);
|
|
||||||
Assert.Equal(AlarmProviderMode.Alarmmgr, modes[^1]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Implement.**
|
|
||||||
- `record FailoverSettings(int threshold, int probeIntervalSeconds, int stableProbes)`; `AlarmProviderModeChange : EventArgs { AlarmProviderMode Mode; string Reason; int HResult; DateTime AtUtc; }`.
|
|
||||||
- Constructor `(IMxAccessAlarmConsumer primary, IMxAccessAlarmConsumer standby, FailoverSettings settings)`; forced-mode variants handled in Task 9 wiring (forced ⇒ skip the other consumer).
|
|
||||||
- Forward `AlarmTransitionEmitted` from the **active** child only (swap the subscription on switch).
|
|
||||||
- Wrap `Subscribe`/`PollOnce` on the primary: on `COMException` (or a failure HRESULT) while `PrimaryActive`, increment a counter; at `threshold`, ensure standby `Subscribe`d, set active=standby, snapshot standby for hand-off, raise `ProviderModeChanged(Subtag, reason, hresult, now)`. Reset counter on any clean primary poll.
|
|
||||||
- `ProbeOnce()` (driven by the poll loop while degraded, gated by `probeIntervalSeconds`): try primary `Subscribe`+`PollOnce`; count consecutive clean probes; at `stableProbes`, set active=primary, return standby to standby, raise `ProviderModeChanged(Alarmmgr, "recovered", 0, now)`.
|
|
||||||
- `Acknowledge*` / `SnapshotActiveAlarms` delegate to the **active** child.
|
|
||||||
- `PollOnce()` drives the active child's poll, and—while degraded—also drives the failback probe cadence.
|
|
||||||
|
|
||||||
**Step 3: Run green** (x86 filter `FailoverAlarmConsumerTests`).
|
|
||||||
|
|
||||||
**Step 4: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/FailoverAlarmConsumer.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmProviderModeChange.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/FailoverAlarmConsumerTests.cs
|
|
||||||
git commit -m "worker(alarms): FailoverAlarmConsumer auto-failover/failback state machine"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 8: Synthetic-GUID helper + degraded flag on the event sink path
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~4 min
|
|
||||||
**Parallelizable with:** Task 9
|
|
||||||
|
|
||||||
Carry `degraded` + `source_provider` from the worker synthesis into the emitted `OnAlarmTransitionEvent`.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs` (add `bool Degraded`)
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs` (`EnqueueTransition` carries degraded)
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs` (`CreateOnAlarmTransition` sets `Degraded`/`SourceProvider`)
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs`
|
|
||||||
- Test: add cases to `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmDispatcherTests.cs` and a new `SyntheticAlarmGuidTests.cs`
|
|
||||||
|
|
||||||
**Step 1: `SyntheticAlarmGuid.ForReference(string reference)`** — deterministic GUID from a stable hash (e.g. MD5 of the UTF-8 reference → `new Guid(bytes)`), so subtag-mode acks resolve by GUID. Test determinism + difference:
|
|
||||||
|
|
||||||
```csharp
|
|
||||||
[Fact] public void SameReference_SameGuid() =>
|
|
||||||
Assert.Equal(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.C"));
|
|
||||||
[Fact] public void DifferentReference_DifferentGuid() =>
|
|
||||||
Assert.NotEqual(SyntheticAlarmGuid.ForReference("A.B.C"), SyntheticAlarmGuid.ForReference("A.B.D"));
|
|
||||||
```
|
|
||||||
|
|
||||||
**Step 2: Thread `degraded`** through `MxAlarmSnapshotRecord.Degraded`, `EnqueueTransition(... bool degraded)`, and `CreateOnAlarmTransition(... bool degraded, AlarmProviderMode sourceProvider)`. Default `degraded=false`, `sourceProvider=Alarmmgr` so the wnwrap path is unchanged (regression: existing `AlarmDispatcherTests` still pass with `Degraded=false`).
|
|
||||||
|
|
||||||
**Step 3: Tests** — extend `AlarmDispatcherTests` with a subtag-style transition asserting `body.Degraded == true` and `SourceProvider == Subtag`.
|
|
||||||
|
|
||||||
**Step 4: Build x86 + run** worker tests for `AlarmDispatcherTests`, `SyntheticAlarmGuidTests`.
|
|
||||||
|
|
||||||
**Step 5: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAlarmSnapshot.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessAlarmEventSink.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessEventMapper.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker/MxAccess/SyntheticAlarmGuid.cs \
|
|
||||||
src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
|
|
||||||
git commit -m "worker(alarms): synthetic GUID + degraded provenance on emitted transitions"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 9: Wire watch-list + failover config through `AlarmCommandHandler`; emit mode-changed event
|
|
||||||
|
|
||||||
**Classification:** high-risk
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none (depends on Tasks 5, 7, 8)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs`
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/IAlarmCommandHandler.cs`
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessCommandExecutor.cs` (`ExecuteSubscribeAlarms`, ~lines 588-616)
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/MxAccessStaSession.cs` (consumer factory wiring; mode-change → event queue)
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/AlarmCommandHandlerTests.cs` (extend or create)
|
|
||||||
|
|
||||||
**Step 1: Carry the subscribe payload.** Change the alarm subscribe entry point from `Subscribe(string subscription)` to `Subscribe(SubscribeAlarmsCommand command)` (the command now has `ForcedMode`, `WatchList`, `Failover`). In `AlarmCommandHandler.Subscribe`:
|
|
||||||
- Build the active provider per `ForcedMode`:
|
|
||||||
- `ALARMMGR` ⇒ `WnWrapAlarmConsumer` only.
|
|
||||||
- `SUBTAG` ⇒ `SubtagAlarmConsumer(new LmxSubtagAlarmSource(factory), watchList)` only.
|
|
||||||
- `UNSPECIFIED` ⇒ `FailoverAlarmConsumer(primary: wnwrap, standby: subtag, settings-from-Failover)`.
|
|
||||||
- Use the existing `consumerFactory` seam but widen it to `Func<SubscribeAlarmsCommand, IMxAccessAlarmConsumer>` so tests inject fakes and production builds the failover composite. Subscribe to `FailoverAlarmConsumer.ProviderModeChanged` and enqueue an `OnAlarmProviderModeChangedEvent` MxEvent via the event queue (new mapper method `CreateOnAlarmProviderModeChanged`).
|
|
||||||
|
|
||||||
**Step 2: Executor + STA wiring.** `ExecuteSubscribeAlarms` passes the full `SubscribeAlarmsCommand` (not just the expression). In `MxAccessStaSession`, the `alarmCommandHandlerFactory` must give the handler access to the `IMxAccessComObjectFactory` so the subtag source can create its own proxy server on the STA; keep the `EnsureOnAlarmConsumerThread` affinity guard on every path.
|
|
||||||
|
|
||||||
**Step 3: Test** — fake consumer factory; assert that a `SUBTAG` forced command builds the subtag consumer and advises; that an auto command building a fake failover composite, when it raises `ProviderModeChanged`, enqueues an `OnAlarmProviderModeChangedEvent` on the queue.
|
|
||||||
|
|
||||||
**Step 4: Build x86 + worker tests.**
|
|
||||||
|
|
||||||
**Step 5: Commit.**
|
|
||||||
```bash
|
|
||||||
git add src/ZB.MOM.WW.MxGateway.Worker/MxAccess/ src/ZB.MOM.WW.MxGateway.Worker.Tests/MxAccess/
|
|
||||||
git commit -m "worker(alarms): route watch-list/failover config; emit provider-mode-changed event"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2 — Gateway: discovery, options, monitor, metrics, dashboard
|
|
||||||
|
|
||||||
### Task 10: `AlarmsOptions.Fallback` + validation
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~4 min
|
|
||||||
**Parallelizable with:** Task 11, Task 13
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmsOptions.cs`
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Server/Configuration/AlarmFallbackOptions.cs`
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Configuration/GatewayOptionsValidator.cs` (`ValidateAlarms`, ~lines 234-258)
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Configuration/GatewayOptionsValidatorTests.cs` (extend)
|
|
||||||
|
|
||||||
**Step 1:** Add `AlarmFallbackOptions Fallback { get; init; } = new();` to `AlarmsOptions`. `AlarmFallbackOptions`: `string Mode = "Auto"` (`Auto|ForceAlarmManager|ForceSubtag`), `int ConsecutiveFailureThreshold = 3`, `int FailbackProbeIntervalSeconds = 30`, `int FailbackStableProbes = 3`, a `Discovery` sub-object (`bool UseGalaxyRepository = true`, `string Area = ""`, `string[] IncludeAttributes = []`, `string[] ExcludeAttributes = []`), and a `Subtags` sub-object (`Active="active"`, `Acked="acked"`, `AckComment=""`, `Priority="priority"`).
|
|
||||||
|
|
||||||
**Step 2:** In `ValidateAlarms`, when `Enabled` and `Mode == "ForceSubtag"` and `Discovery.UseGalaxyRepository == false` and `IncludeAttributes` empty ⇒ add a validation error ("ForceSubtag requires Galaxy Repository discovery or an explicit IncludeAttributes list"). Floor the three numeric values at 1. Validate `Mode` is one of the three literals.
|
|
||||||
|
|
||||||
**Step 3-5:** Test the new validation cases (red→green), build the server, commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 11: Galaxy Repository "alarm attributes" discovery query
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** Task 10, Task 13
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyRepository.cs` (add `GetAlarmAttributesAsync` + SQL constant, following `GetAttributesAsync` ~lines 86-115 and `AttributesSql` ~line 176)
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/IGalaxyRepository.cs`
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Server/Galaxy/GalaxyAlarmAttributeRow.cs`
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Galaxy/` (projection unit test; live SQL gated)
|
|
||||||
|
|
||||||
**Step 1:** `GalaxyAlarmAttributeRow { string FullTagReference; string SourceObjectReference; string AckCommentSubtag; }` (and any priority subtag). `GetAlarmAttributesAsync` reuses the existing `is_alarm` detection (the `AlarmExtension` primitive join already in `AttributesSql`) filtered to `is_alarm = 1`, projecting the alarm reference + its ack-comment attribute. Follow the exact `SqlConnection`/`SqlCommand`/`SqlDataReader` pattern from `GetAttributesAsync`.
|
|
||||||
|
|
||||||
**Step 2:** Unit-test the row→`AlarmSubtagTarget` mapping (a pure mapper function); gate any live-DB test like the existing Galaxy live tests (or `Skip` with a note, matching `AlarmsLiveSmokeTests`).
|
|
||||||
|
|
||||||
**Step 3-5:** red→green, build server, commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 12: Watch-list resolver (GR SQL + config override → `AlarmSubtagTarget[]`)
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~4 min
|
|
||||||
**Parallelizable with:** none (depends on Tasks 10, 11)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/AlarmWatchListResolver.cs`
|
|
||||||
- Create: `src/ZB.MOM.WW.MxGateway.Server/Alarms/IAlarmWatchListResolver.cs`
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmWatchListResolverTests.cs`
|
|
||||||
|
|
||||||
**Step 1: Test the merge** with a fake `IGalaxyRepository`:
|
|
||||||
- discovery rows + `IncludeAttributes` are unioned; `ExcludeAttributes` removed; each becomes an `AlarmSubtagTarget` with `.active`/`.acked`/`.ackmsg` addresses composed from the configured `Subtags` names (`<reference>.<Active>`, etc.); empty config subtag names fall back to defaults; GR unavailable + no includes ⇒ empty list + a logged warning flag.
|
|
||||||
|
|
||||||
**Step 2: Implement** `ResolveAsync(AlarmsOptions, CancellationToken) → IReadOnlyList<AlarmSubtagTarget>`.
|
|
||||||
|
|
||||||
**Step 3-5:** red→green, build, commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 13: Gateway metrics — provider-mode gauge + switch counter
|
|
||||||
|
|
||||||
**Classification:** small
|
|
||||||
**Estimated implement time:** ~3 min
|
|
||||||
**Parallelizable with:** Task 10, Task 11
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Metrics/GatewayMetrics.cs` (ctor ~lines 55-79; add counter + observable gauge following the existing pattern)
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Metrics/GatewayMetricsTests.cs` (if present; else assert via a `MeterListener`)
|
|
||||||
|
|
||||||
**Step 1:** Add `mxgateway.alarms.provider_switches` counter (tagged `from`,`to`,`reason`) and `mxgateway.alarms.provider_mode` observable gauge (1=alarmmgr, 2=subtag), plus `AlarmProviderSwitched(int from, int to, string reason)` and a private `GetAlarmProviderMode()` (lock on `_syncRoot` like the others).
|
|
||||||
|
|
||||||
**Step 2-4:** test, build, commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 14: `GatewayAlarmMonitor` — arm watch-list, reflect provider mode, reconcile on switch
|
|
||||||
|
|
||||||
**Classification:** high-risk
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none (depends on Tasks 9, 12, 13)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (ctor ~41-49; `SubscribeAlarmsAsync` ~210-233; event-drain loop; `StreamAsync` ~386-434)
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs` (new, using `FakeWorkerHarness`)
|
|
||||||
|
|
||||||
**Step 1:** Inject `IAlarmWatchListResolver` and `GatewayMetrics`. In `SubscribeAlarmsAsync`, resolve the watch-list and build the `SubscribeAlarmsCommand` with `ForcedMode` (from `Fallback.Mode`), `WatchList`, and `Failover` populated from options — instead of the bare `{ SubscriptionExpression }`.
|
|
||||||
|
|
||||||
**Step 2:** In the worker-event drain path, handle `OnAlarmProviderModeChangedEvent`: update a `_providerStatus` field (mode/degraded/reason/since), `Broadcast(new AlarmFeedMessage { ProviderStatus = … })` to every subscriber, call `metrics.AlarmProviderSwitched(...)`, and force a `ReconcileAsync` so the cache re-seeds from the now-active provider (avoids raise/clear storms).
|
|
||||||
|
|
||||||
**Step 3:** In `StreamAsync`, emit the current `provider_status` as the **first** message (before the snapshot) so a late joiner immediately knows the mode.
|
|
||||||
|
|
||||||
**Step 4: Test** — stand up the monitor with `FakeWorkerHarness`; emit an `OnAlarmProviderModeChangedEvent(Subtag)`; assert a `StreamAsync` subscriber receives a `ProviderStatus{ Mode=Subtag, Degraded=true }` and that the switch counter incremented. Also assert a transition emitted in subtag mode flows through with `Degraded=true`.
|
|
||||||
|
|
||||||
**Step 5:** build server, run the new test, commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 15: Dashboard — push provider status to `/hubs/alarms` + UI indicator
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** none (depends on Task 14)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/AlarmsHubPublisher.cs` (forward `ProviderStatus` messages — they already flow through `StreamAsync`, so confirm the existing `SendAsync(AlarmMessage, message)` carries them; add a dedicated `"ProviderModeChanged"` client method if the dashboard needs a distinct channel)
|
|
||||||
- Modify: the alarms dashboard page/component (Bootstrap-only badge: green "alarmmgr" / amber "degraded — subtag") — find under `src/ZB.MOM.WW.MxGateway.Server/Dashboard/`
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/` dashboard model test (e.g. a `DashboardAlarmProviderStatus.FromFeed` mapper, mirroring `DashboardActiveAlarm.FromSnapshot`)
|
|
||||||
|
|
||||||
**Constraint:** Bootstrap CSS/JS only — no MudBlazor/Radzen/FluentUI.
|
|
||||||
|
|
||||||
**Steps:** TDD the model mapper, wire the publisher + badge, build, commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3 — Integration, docs, live smoke
|
|
||||||
|
|
||||||
### Task 16: End-to-end fake-worker failover test
|
|
||||||
|
|
||||||
**Classification:** standard
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** Task 18
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/AlarmFailoverEndToEndTests.cs`
|
|
||||||
|
|
||||||
Drive the full gateway path with `FakeWorkerHarness`: subscribe (assert the `SubscribeAlarmsCommand` carries a watch-list), emit a wnwrap-style transition (assert `Degraded=false`), emit `OnAlarmProviderModeChangedEvent(Subtag)`, emit a synthesized transition (assert `Degraded=true`, `SourceProvider=Subtag`), then `OnAlarmProviderModeChangedEvent(Alarmmgr)` and assert the feed reports recovery. Build, run, commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 17: Live subtag smoke test (opt-in)
|
|
||||||
|
|
||||||
**Classification:** small
|
|
||||||
**Estimated implement time:** ~4 min
|
|
||||||
**Parallelizable with:** Task 18
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.IntegrationTests/...AlarmSubtagLiveSmokeTests.cs` (or the worker live suite)
|
|
||||||
|
|
||||||
A `[LiveMxAccessFact]`, `Skip`-by-default test (per `AlarmsLiveSmokeTests` precedent) that, against a live Galaxy + alarm flip script: advises the real `.active`/`.acked` subtags via `LmxSubtagAlarmSource`, asserts a synthesized raise/clear, and performs an ack via the ack-comment write. Document the exact subtag names discovered (resolves the design's open item). Commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Task 18: Documentation
|
|
||||||
|
|
||||||
**Classification:** trivial
|
|
||||||
**Estimated implement time:** ~5 min
|
|
||||||
**Parallelizable with:** Task 16, Task 17
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `gateway.md` (alarm provider section: dual provider + auto-failover/failback)
|
|
||||||
- Modify: `docs/DesignDecisions.md` (record the fallback decision + parity rationale)
|
|
||||||
- Modify: `docs/GatewayConfiguration.md` (the `MxGateway:Alarms:Fallback` block)
|
|
||||||
- Modify: `docs/AlarmClientDiscovery.md` (subtag provider, synthesis rules, ack-comment write)
|
|
||||||
- Modify: `docs/Grpc.md` (new `provider_status` feed case + `degraded`/`source_provider` fields)
|
|
||||||
|
|
||||||
Follow `StyleGuide.md` (PascalCase filenames, present tense, explain *why*). No code; commit.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Execution order & parallelism summary
|
|
||||||
|
|
||||||
- **Serial spine:** 1 → 2 → 3 → 4 → 5 → 6 → 7 → 8/9 → 10/11 → 12 → 13 → 14 → 15 → 16 → 17/18.
|
|
||||||
- **Parallelizable clusters:** {8, 9 partially}, {10, 11, 13}, {16, 17, 18}.
|
|
||||||
- **High-risk tasks** (full review chain): 1, 2, 6, 7, 9, 14. **Standard:** 4, 5, 8, 10, 11, 12, 15, 16. **Small/trivial:** 3, 13, 17, 18.
|
|
||||||
|
|
||||||
## Risk notes for the executor
|
|
||||||
|
|
||||||
- **Field-number collisions:** Task 2 must read the live `MxEvent`/`MxEventFamily` numbers before adding — the agent map gave alarm-payload maxima but not `MxEvent`'s. Verify before editing.
|
|
||||||
- **STA discipline:** every COM call in `LmxSubtagAlarmSource` and every consumer swap runs on the worker STA; keep the `EnsureOnAlarmConsumerThread` guard. The worker STA already pumps Windows messages, which is required for the subtag `OnDataChange` to deliver.
|
|
||||||
- **Parity regression:** alarmmgr-mode output must be byte-for-byte unchanged. Existing `AlarmDispatcherTests` and `ProtobufContractRoundTripTests` are the guardrail — they must stay green with `Degraded=false` defaults.
|
|
||||||
- **Subtag names unverified:** the design leaves exact AVEVA subtag names (`.active`, `.acked`, ack-comment) to confirm against `C:\Users\dohertj2\Desktop\mxaccess` + a live Galaxy (Task 17). The config `Subtags` block exists so names are not hard-coded.
|
|
||||||
@@ -1,147 +0,0 @@
|
|||||||
{
|
|
||||||
"planPath": "docs/plans/2026-06-13-alarm-subtag-fallback.md",
|
|
||||||
"tasks": [
|
|
||||||
{
|
|
||||||
"id": 54,
|
|
||||||
"subject": "Task 1: Worker proto \u2014 watch-list, failover config, AlarmProviderMode",
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 55,
|
|
||||||
"subject": "Task 2: Gateway proto \u2014 provider status, degraded provenance, mode-changed event",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
54
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 56,
|
|
||||||
"subject": "Task 3: Proto round-trip tests for new alarm fields",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
54,
|
|
||||||
55
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 57,
|
|
||||||
"subject": "Task 4: Subtag value-source abstraction + synthesis state machine",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
54
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 58,
|
|
||||||
"subject": "Task 5: SubtagAlarmConsumer over the source seam",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
57
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 59,
|
|
||||||
"subject": "Task 6: COM-backed LmxSubtagAlarmSource",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
57
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 60,
|
|
||||||
"subject": "Task 7: FailoverAlarmConsumer state machine",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
58
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 61,
|
|
||||||
"subject": "Task 8: Synthetic GUID + degraded flag on event sink path",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
55
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 62,
|
|
||||||
"subject": "Task 9: Wire watch-list/failover through AlarmCommandHandler; emit mode-changed",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
58,
|
|
||||||
60,
|
|
||||||
61
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 63,
|
|
||||||
"subject": "Task 10: AlarmsOptions.Fallback + validation",
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 64,
|
|
||||||
"subject": "Task 11: Galaxy Repository alarm-attributes discovery query",
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 65,
|
|
||||||
"subject": "Task 12: Watch-list resolver (GR SQL + config override)",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
54,
|
|
||||||
63,
|
|
||||||
64
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 66,
|
|
||||||
"subject": "Task 13: Metrics \u2014 provider-mode gauge + switch counter",
|
|
||||||
"status": "completed"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 67,
|
|
||||||
"subject": "Task 14: GatewayAlarmMonitor \u2014 arm watch-list, reflect mode, reconcile on switch",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
55,
|
|
||||||
62,
|
|
||||||
65,
|
|
||||||
66
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 68,
|
|
||||||
"subject": "Task 15: Dashboard \u2014 push provider status + UI badge",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
67
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 69,
|
|
||||||
"subject": "Task 16: End-to-end fake-worker failover test",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
67
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 70,
|
|
||||||
"subject": "Task 17: Live subtag smoke test (opt-in)",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
59,
|
|
||||||
62
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"id": 71,
|
|
||||||
"subject": "Task 18: Documentation",
|
|
||||||
"status": "completed",
|
|
||||||
"blockedBy": [
|
|
||||||
67
|
|
||||||
]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"lastUpdated": "2026-06-13T13:30:00Z"
|
|
||||||
}
|
|
||||||
@@ -1,270 +0,0 @@
|
|||||||
# Deferred Follow-ups Implementation Plan
|
|
||||||
|
|
||||||
**Date:** 2026-06-14
|
|
||||||
**Status:** Plan only — NOT yet executed. Saved for review.
|
|
||||||
**Context:** After the alarm-subtag-fallback cleanup (merged `5976770`) and its redeploy to
|
|
||||||
windev (10.100.0.48), five items remain deferred. This plan handles all five. They are
|
|
||||||
independent — execute in any order, or cherry-pick. Items D1–D2 are code (branch off `main`);
|
|
||||||
D3 is a dev-rig validation; D4–D5 are ops on the deployed hosts (no code).
|
|
||||||
|
|
||||||
Source of the deferred list: the post-deploy review on 2026-06-14. See also memory
|
|
||||||
`project_deploy_mechanics`, `project_wonder_deployment`, `project_alarm_subtag_fallback`,
|
|
||||||
`project_rig_alarms_object_driven`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D1 — Surface `AlarmProviderSwitchCount` on the dashboard metric list
|
|
||||||
|
|
||||||
**Classification:** small · **Est:** ~10 min · **Where:** Server (net10), build+test on macOS
|
|
||||||
**Why deferred:** the dashboard reads provider *state* from the feed's `ProviderStatus` badge,
|
|
||||||
not the metrics snapshot, so the snapshot field (added in B1) and the OTEL counter were enough.
|
|
||||||
This makes the cumulative switch count visible in the dashboard's metric table too.
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardSnapshotService.cs` (`CreateMetricSummaries`, ~line 178–198)
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Dashboard/DashboardSnapshotServiceTests.cs` (or the existing snapshot test file)
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. In `CreateMetricSummaries`, add to the `metrics` list (near the other counters):
|
|
||||||
`new("mxgateway.alarms.provider_switches", snapshot.AlarmProviderSwitchCount),`
|
|
||||||
Also consider adding the current mode as a gauge row if useful:
|
|
||||||
the snapshot does not carry the mode int (only the feed does), so DO NOT invent one —
|
|
||||||
only add the switch count, which the snapshot already exposes.
|
|
||||||
2. Add/extend a unit test asserting the summary list contains a
|
|
||||||
`mxgateway.alarms.provider_switches` row equal to the snapshot's `AlarmProviderSwitchCount`
|
|
||||||
after calling `metrics.AlarmProviderSwitched(...)`.
|
|
||||||
3. Verify: `dotnet build src/ZB.MOM.WW.MxGateway.Server` (macOS) and
|
|
||||||
`dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter FullyQualifiedName~DashboardSnapshot`.
|
|
||||||
4. Commit.
|
|
||||||
|
|
||||||
**Rollback:** revert the one-line list addition.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D2 — Reproduce and fix the `AmbiguousMatchException` on `GET /`
|
|
||||||
|
|
||||||
**Classification:** standard · **Est:** ~30–45 min (repro + fix) · **Where:** Server (net10)
|
|
||||||
**Why deferred:** observed only in *old* (06/05, Development) logs:
|
|
||||||
`AmbiguousMatchException: The request matched multiple endpoints. Matches: / (/) / (/)`.
|
|
||||||
Unauthenticated `GET /` safely 302s to `/login`; the ambiguity would only throw for an
|
|
||||||
*authenticated* request that reaches routing. Pre-existing and unrelated to the alarm work,
|
|
||||||
but it would surface as a dashboard-home 500. Confirm it still reproduces before fixing.
|
|
||||||
|
|
||||||
**Root-cause candidates (the two endpoints both matching `/`):**
|
|
||||||
- `DashboardHome.razor` (`@page "/"`) mapped by `MapRazorComponents<App>()`
|
|
||||||
(`Dashboard/DashboardEndpointRouteBuilderExtensions.cs:92`).
|
|
||||||
- `MapStaticAssets(...)` (`GatewayApplication.cs:190`) — in .NET 8–10 the static-assets
|
|
||||||
endpoint can register a fingerprinted root that collides with the home page.
|
|
||||||
|
|
||||||
**Step 0 — Reproduce (do this first; if it does NOT repro, downgrade to a documentation note):**
|
|
||||||
1. Log into the dashboard as `multi-role`/`password` (Administrator) at
|
|
||||||
`http://10.100.0.48:5130/login`.
|
|
||||||
2. Navigate to `http://10.100.0.48:5130/` (the "Dashboard" home).
|
|
||||||
3. If it renders the Overview page → not a live bug on this build; record that and stop
|
|
||||||
(the dev error-page throw was a Development-only artifact). If it 500s with
|
|
||||||
`AmbiguousMatchException` → proceed to fix.
|
|
||||||
|
|
||||||
**Fix (only if it reproduces):**
|
|
||||||
- Files: `src/ZB.MOM.WW.MxGateway.Server/GatewayApplication.cs` (endpoint mapping order/region),
|
|
||||||
`src/ZB.MOM.WW.MxGateway.Server/Dashboard/DashboardEndpointRouteBuilderExtensions.cs`,
|
|
||||||
`src/ZB.MOM.WW.MxGateway.Server/Dashboard/Components/Pages/DashboardHome.razor`.
|
|
||||||
- Candidate fixes, in order of preference:
|
|
||||||
1. Inspect the actual two matched endpoint display names at runtime (the exception lists
|
|
||||||
them) — temporarily enable detailed errors or read the stderr `Matches:` block — to learn
|
|
||||||
exactly which two collide.
|
|
||||||
2. If `MapStaticAssets` owns the second `/`: it must run, but confirm it isn't double-mapping
|
|
||||||
the home route; if it is, move `MapStaticAssets` ordering or scope it so it doesn't claim
|
|
||||||
the literal `/` content path. (Do NOT remove `MapStaticAssets` — Blazor needs it.)
|
|
||||||
3. If two component pages both declare `@page "/"`: give the non-home one a distinct route.
|
|
||||||
4. Last resort: give `DashboardHome` an explicit route (e.g. `@page "/overview"`) and add a
|
|
||||||
trivial `MapGet("/", () => Results.Redirect("/overview"))` with `.RequireAuthorization(...)`.
|
|
||||||
- Add a test that exercises the root route once resolved (a WebApplicationFactory-based
|
|
||||||
integration test asserting authenticated `GET /` returns 200, if the dashboard test harness
|
|
||||||
supports auth; otherwise a routing-uniqueness assertion).
|
|
||||||
- Verify: `dotnet build src/ZB.MOM.WW.MxGateway.Server`; re-run the authenticated `GET /` repro.
|
|
||||||
|
|
||||||
**Rollback:** revert the routing change; the home page returns to its prior (ambiguous) state.
|
|
||||||
|
|
||||||
**Note:** this fix ships only via a Server redeploy (D-deploy applies, see D5/windev redeploy
|
|
||||||
procedure in `project_deploy_mechanics`). Decide whether to bundle it with the next redeploy.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D3 — Validate the failover path end-to-end on the dev rig (ForceSubtag)
|
|
||||||
|
|
||||||
**Classification:** standard (validation, no production code) · **Est:** ~30–45 min · **Where:** dev rig / windev
|
|
||||||
**Why deferred:** `provider_mode 1` (healthy alarmmgr) is verified in production, but the actual
|
|
||||||
alarmmgr→subtag **failover**, the **degraded badge**, the synthesized subtag alarms, and the
|
|
||||||
`provider_switches{from,to,reason}` counter (with the new bounded `failover`/`failback`/`unknown`
|
|
||||||
tag) have never fired live — a healthy system never switches, and the rig's alarms can't be made
|
|
||||||
to fail COM on demand. Use the explicit `ForceSubtag` config mode to exercise the degraded path
|
|
||||||
deterministically without an alarmmgr fault.
|
|
||||||
|
|
||||||
**Approach:** run a *temporary* gateway instance (NOT the production service) in `ForceSubtag`
|
|
||||||
mode against the dev Galaxy, drive it with the operator/IDE alarm toggle, and confirm the
|
|
||||||
degraded surface. Do this on windev in a throwaway run so production stays on `Auto`.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. On windev, from a build worktree at `main`, run the Server locally (or a second instance on
|
|
||||||
alt ports) with `MxGateway:Alarms:Fallback:Mode=ForceSubtag` (env
|
|
||||||
`MxGateway__Alarms__Fallback__Mode=ForceSubtag`), pointed at the dev Galaxy
|
|
||||||
(`\\DESKTOP-6JL3KKO\Galaxy!DEV`). Use distinct Kestrel ports to avoid clashing with the
|
|
||||||
production service on 5120/5130.
|
|
||||||
2. Subscribe an alarm client (or open the dashboard alarms page for that instance) and confirm:
|
|
||||||
- The provider badge shows **"Subtag monitoring (degraded)"** (amber `bg-warning`).
|
|
||||||
- `curl .../metrics` shows `mxgateway_alarms_provider_mode 2`.
|
|
||||||
- Active alarms appear with `degraded=true` and a synthetic (MD5-derived) GUID, with the
|
|
||||||
reference shape `Galaxy!<realArea>.<object>.<attr>` (e.g. `Galaxy!TestArea.TestMachine_001.TestAlarm001`).
|
|
||||||
3. Drive a transition: have the operator/IDE toggle a `TestMachine_NNN.TestAlarmNNN` true→false
|
|
||||||
(external MXAccess writes are ignored — see `project_rig_alarms_object_driven`); confirm a
|
|
||||||
synthesized Raise then Clear, and an `AckMsg` write via AcknowledgeByName returns 0.
|
|
||||||
4. (Optional, to exercise the switch counter) run in `Auto` and induce a primary fault if a safe
|
|
||||||
way exists; otherwise document that the counter is unit-tested only and `ForceSubtag` covers
|
|
||||||
the degraded surface. Note: `ForceSubtag` may not increment `provider_switches` (no runtime
|
|
||||||
switch) — that counter's live exercise remains the one gap; record it explicitly rather than
|
|
||||||
claiming coverage.
|
|
||||||
5. Tear down the temporary instance. Production service is untouched (stays `Auto`).
|
|
||||||
6. Record results in `project_alarm_subtag_fallback` memory (degraded badge + synthesized
|
|
||||||
subtag alarms now live-validated; switch-counter still unit-test-only if not exercised).
|
|
||||||
|
|
||||||
**Rollback:** none — temporary instance only; nothing in production changes.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D4 — Prune stale deploy backups on windev
|
|
||||||
|
|
||||||
**Classification:** trivial (ops) · **Est:** ~10 min · **Where:** windev (10.100.0.48), no code
|
|
||||||
**Why deferred:** backups accumulate on every deploy; harmless but cluttering
|
|
||||||
`C:\publish\mxaccessgw\`.
|
|
||||||
|
|
||||||
**Current backups observed (2026-06-14):**
|
|
||||||
- `Server.bak-20260526T143341`, `Server.bak-20260529T090320`, `Server.bak529.20260604T122616`,
|
|
||||||
`Server.bak-theme030-20260605-053056`, `Server.bak-theme031-20260605-083410`,
|
|
||||||
`Server.bak-20260614-prefallback` (today's), `Worker.bak-20260614-prefallback` (today's).
|
|
||||||
- Inside `Server\`: `ZB.MOM.WW.MxGateway.Server.dll.bak-20260604-loginfix`,
|
|
||||||
`appsettings.json.bak-20260604-{glauth35,ldapkeys}`.
|
|
||||||
|
|
||||||
**Steps:**
|
|
||||||
1. **Keep** `Server.bak-20260614-prefallback` and `Worker.bak-20260614-prefallback` — the
|
|
||||||
immediate rollback for the current deploy. Keep until the new build has soaked (e.g. a few
|
|
||||||
days / one business cycle).
|
|
||||||
2. Delete the clearly-superseded older backups: the `Server.bak-2026052*`, `Server.bak529.*`,
|
|
||||||
and `Server.bak-theme03*` dirs. Confirm each is a Server dir (not something live) before
|
|
||||||
`rmdir /s /q`.
|
|
||||||
3. Optionally remove the in-`Server` `.bak-*` sidecar files (`*.dll.bak-*`,
|
|
||||||
`appsettings.json.bak-*`) — but FIRST confirm the live `appsettings.json` is correct (it is,
|
|
||||||
post-deploy) so the `.bak` ldap/glauth copies aren't the only record of a needed value.
|
|
||||||
4. Verify the service is unaffected: `Get-Service MxAccessGw` Running, `/health/ready` 200.
|
|
||||||
|
|
||||||
**Rollback:** none needed (deletions only; the current-deploy backup is retained). If unsure
|
|
||||||
about any single dir, skip it.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D5 — Redeploy the wonder host to bring it to parity
|
|
||||||
|
|
||||||
**Classification:** high-risk (separate production-ish box, fiddly access, divergent build) · **Est:** ~1–2 h · **Where:** wonder-app-vd03.zmr.zimmer.com (10.220.157.247)
|
|
||||||
**Why deferred:** wonder still runs the pre-feature build — it lacks the entire alarm-fallback
|
|
||||||
feature and the cleanup. Bring it to `5976770` if parity is wanted. This box is materially
|
|
||||||
different from windev (read `project_wonder_deployment` fully before starting).
|
|
||||||
|
|
||||||
**Key differences from windev (do NOT reuse the windev recipe blindly):**
|
|
||||||
- Access: no usable `ssh host "cmd"` exec. Port 22 OpenSSH (PowerShell) was observed CLOSED
|
|
||||||
2026-05-26; the reliable path is **servecli on port 2222** (cmd.exe PTY + SFTP only) using the
|
|
||||||
base64-`-EncodedCommand` pattern in `project_wonder_deployment` / `~/Desktop/servecli/instructions.md`.
|
|
||||||
Re-test port 22 first; if up, prefer it.
|
|
||||||
- Build shape: wonder runs a **self-contained single-file win-x64** Server (~118 MB), renamed to
|
|
||||||
`MxGateway.Server.exe` for the NSSM path `E:\ApiInstall\MxGateway\Server\MxGateway.Server.exe`.
|
|
||||||
So publish must be `-r win-x64 --self-contained -p:PublishSingleFile=true` (NOT the
|
|
||||||
framework-dependent windev recipe), then rename the exe to `MxGateway.Server.exe`.
|
|
||||||
- Static assets quirk: ship BOTH `ZB.MOM.WW.MxGateway.Server.staticwebassets.endpoints.json` and
|
|
||||||
`MxGateway.Server.staticwebassets.endpoints.json` next to the exe (the ZB-named one is used).
|
|
||||||
- Config: wonder's `appsettings.json` carries HTTP-switched Kestrel (`http://0.0.0.0:5130`),
|
|
||||||
`Dashboard.GroupToRole` = `{ SCADA-Admins/Designers/Deploy-All → Admin }`,
|
|
||||||
`Dashboard.RequireHttpsCookie:false`, LDAP base `dc=scadalink,dc=local`. **Preserve wonder's
|
|
||||||
appsettings.json** exactly like windev (do not ship the repo default). Note: wonder's
|
|
||||||
GroupToRole values are `"Admin"` — VERIFY the current build's validator accepts `"Admin"` (the
|
|
||||||
windev crash showed it requires `'Administrator'`/`'Viewer'`). **If the validator rejects
|
|
||||||
`"Admin"`, wonder's appsettings GroupToRole values must be updated to `Administrator` BEFORE/with
|
|
||||||
the deploy or the new build will crash-loop.** This is the single biggest risk — resolve it first.
|
|
||||||
- Worker: wonder's x86 worker is the original; the alarm-fallback adds new subscribe fields. To
|
|
||||||
use subtag fallback on wonder, the **x86 worker must also be redeployed** (publish x86, rename
|
|
||||||
if needed). If only the Server is updated, confirm worker IPC protobuf compat (contracts changed
|
|
||||||
for alarms — a stale worker may not understand the extended SubscribeAlarms). Safer to deploy
|
|
||||||
both Server and Worker together.
|
|
||||||
|
|
||||||
**Steps (high level — expand against the runbook before executing):**
|
|
||||||
1. Confirm parity is actually wanted on wonder, and whether the dashboard (disabled there) and
|
|
||||||
the alarm monitor are even in scope for that box. If alarms/dashboard are off on wonder, this
|
|
||||||
may reduce to "Server binary parity only."
|
|
||||||
2. Resolve the **GroupToRole value compatibility** question (Admin vs Administrator) — inspect
|
|
||||||
the current build's `GatewayOptionsValidator` and decide whether to patch wonder's appsettings.
|
|
||||||
3. Build self-contained Server (`-r win-x64 --self-contained -p:PublishSingleFile=true`) and x86
|
|
||||||
Worker from `main` (`5976770`).
|
|
||||||
4. Transfer via SFTP (servecli) to a staging dir on `E:\ApiInstall\MxGateway\`.
|
|
||||||
5. Stop `MxAccessGw`; back up `Server` → `Server.bak.<ts>` and `Worker` → `Worker.bak.<ts>`;
|
|
||||||
swap in the new build; **restore wonder's `appsettings.json`** (+ patched GroupToRole if
|
|
||||||
needed); ensure both staticwebassets manifests are present; rename Server exe to
|
|
||||||
`MxGateway.Server.exe`.
|
|
||||||
6. Start `MxAccessGw` (no dependents). Verify stderr line count does not grow (no crash loop),
|
|
||||||
`curl -k https://...:5130/health/live` (or HTTP per current config) Healthy, gRPC 5120 up.
|
|
||||||
7. Verify the new metric is present if the metrics endpoint is exposed there.
|
|
||||||
|
|
||||||
**Rollback (documented in memory):** stop service,
|
|
||||||
`Move-Item Server Server.failed; Move-Item Server.bak.<ts> Server`, restore appsettings from the
|
|
||||||
backup, start.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggested order
|
|
||||||
|
|
||||||
1. **D2 repro** (5 min — just log in and hit `/`): decides whether D2 is a real fix or a no-op.
|
|
||||||
2. **D1** (small code) + **D2 fix if needed** — bundle into one Server branch; they ship together
|
|
||||||
on the next Server redeploy.
|
|
||||||
3. **D4** (prune windev backups) — quick ops, independent.
|
|
||||||
4. **D3** (ForceSubtag validation) — exercises the degraded surface; do before/after the D1+D2
|
|
||||||
redeploy so you also confirm the new Server build is healthy.
|
|
||||||
5. **D5** (wonder) — largest and riskiest; do last, only if parity is wanted, and resolve the
|
|
||||||
GroupToRole-value compatibility question first.
|
|
||||||
|
|
||||||
## Execution note
|
|
||||||
|
|
||||||
This plan is intentionally NOT executed. When ready, execute on a branch off `main`
|
|
||||||
(`feat/deferred-followups` or per-item branches) — do not commit to `main` directly. D1/D2 need a
|
|
||||||
Server redeploy to take effect; D4/D5 are host operations; D3 touches nothing permanent.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D2 — Resolution (2026-06-14)
|
|
||||||
|
|
||||||
Static source determination on build `5976770` (runtime repro out of scope). What was checked:
|
|
||||||
|
|
||||||
- **Razor `@page "/"` count:** exactly ONE — `Dashboard/Components/Pages/DashboardHome.razor`.
|
|
||||||
All other pages declare distinct routes (`/login`, `/sessions`, `/galaxy`, `/browse`,
|
|
||||||
`/apikeys`, `/workers`, `/events`, `/alarms`, `/settings`, `/sessions/{SessionId}`).
|
|
||||||
- **No root index.html:** `src/ZB.MOM.WW.MxGateway.Server/wwwroot/` contains only `css/` and
|
|
||||||
`lib/` subdirectories; no `index.html` anywhere under `wwwroot/`.
|
|
||||||
- **No `UseDefaultFiles` / no `MapFallback`:** neither appears anywhere in the Server project.
|
|
||||||
- **`MapStaticAssets` mapped once** (`GatewayApplication.cs:190`) and `UseStaticFiles()` once
|
|
||||||
(`:41`); the static-assets manifest serves fingerprinted CSS/JS assets, not a literal `/`.
|
|
||||||
- **No `MapGet("/")`:** the dashboard endpoint builder
|
|
||||||
(`Dashboard/DashboardEndpointRouteBuilderExtensions.cs`) maps only `/auth/login`, `/logout`,
|
|
||||||
`/denied`, `/hubs/{snapshot,alarms,events}`, `/hubs/token`, then `MapRazorComponents<App>()`.
|
|
||||||
None use pattern `/`.
|
|
||||||
- **`MapZbHealth` / `MapZbMetrics`** come from the external `ZB.MOM.WW.Health` shared library
|
|
||||||
(not in this repo) and map health/metrics paths, not `/`.
|
|
||||||
|
|
||||||
**Root cause of the 2026-06-05 log:** `code-reviews/Server/findings.md` (re-review at `42b0037`,
|
|
||||||
2026-05-24) records that commit `de7639a` **removed the legacy `MapGet("/", ...)` redirect that
|
|
||||||
was colliding with the Blazor `@page "/"` (a real 500)**. That legacy registration was the source
|
|
||||||
of the `AmbiguousMatchException`. It is gone on the current build, so the second `/` endpoint no
|
|
||||||
longer exists.
|
|
||||||
|
|
||||||
**Conclusion:** No duplicate `/` endpoint on build `5976770`. The AmbiguousMatchException is not
|
|
||||||
reproducible from source — it was a stale Development-only artifact from before `de7639a` reached
|
|
||||||
the deployed instance. **No source change made** (no-op).
|
|
||||||
|
|
||||||
**Residual:** A 100% confirmation still requires an authenticated runtime `GET /` against a
|
|
||||||
deployed instance (the only path that exercises routing past the unauthenticated 302-to-`/login`).
|
|
||||||
Recommend a spot-check of authenticated `GET /` after the next Server redeploy; if it returns 200
|
|
||||||
(not 500), this item can be fully closed.
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
# ForceSubtag Mode Fix Implementation Plan
|
|
||||||
|
|
||||||
> Fixes the two defects surfaced by the D3 live validation (2026-06-15): forced-subtag
|
|
||||||
> doesn't actually run subtag (#1), and the gateway never reflects a forced provider mode
|
|
||||||
> into the gauge/feed (#2).
|
|
||||||
|
|
||||||
**Goal:** Make `MxGateway:Alarms:Fallback:Mode=ForceSubtag` actually serve degraded subtag
|
|
||||||
alarms AND have the gateway advertise `provider_mode=2` / degraded badge.
|
|
||||||
|
|
||||||
**Evidence:** Live ForceSubtag run returned alarmmgr-sourced active alarms (May raise
|
|
||||||
timestamps, `degraded=false`) and `provider_mode` stuck at 1, despite ForceSubtag binding
|
|
||||||
(proven by invalid-value crash) and the deployed worker containing the ForcedMode routing
|
|
||||||
(`3f5e5fc` ∈ `5976770`, worker dated 2026-06-14).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Defect #2 (CONFIRMED code defect) — gateway never reflects forced mode
|
|
||||||
|
|
||||||
**Root cause:** `GatewayAlarmMonitor.RunMonitorAsync` hard-baselines `_providerMode=Alarmmgr`
|
|
||||||
and sets the gauge to 1, ignoring `_options.Fallback.Mode`. `_providerMode` only advances on a
|
|
||||||
worker `OnAlarmProviderModeChanged` event, which is raised ONLY by `FailoverAlarmConsumer`
|
|
||||||
(Auto mode). Forced-subtag builds `SubtagAlarmConsumer` directly → no event → gauge/feed stay
|
|
||||||
Alarmmgr forever.
|
|
||||||
|
|
||||||
### Task 2: Seed provider mode from configured forced mode (gateway, net10)
|
|
||||||
**Classification:** small · **Parallelizable with:** none (precedes the diagnostic build)
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Server/Alarms/GatewayAlarmMonitor.cs` (`RunMonitorAsync`, ~lines 160-172; add a permanent observability log in `SubscribeAlarmsAsync` ~line 257)
|
|
||||||
- Test: `src/ZB.MOM.WW.MxGateway.Tests/Alarms/GatewayAlarmMonitorProviderModeTests.cs`
|
|
||||||
|
|
||||||
**Change:** in `RunMonitorAsync`, compute `initialMode = MapForcedMode(_options.Fallback.Mode)`
|
|
||||||
mapped as `Subtag→Subtag`, `Alarmmgr→Alarmmgr`, `Unspecified→Alarmmgr` (Auto starts on the
|
|
||||||
alarmmgr primary). Set `_providerMode/_providerDegraded(=Subtag)/_providerReason/_providerSince`
|
|
||||||
and `_metrics.SetAlarmProviderMode(ModeToInt(initialMode))` — using the existing no-switch gauge
|
|
||||||
seam so `provider_switches` does NOT increment. Add a log in `SubscribeAlarmsAsync`:
|
|
||||||
`"Alarm subscribe forcedMode={ForcedMode} (configMode={ConfigMode}) watchList={Count}"`.
|
|
||||||
|
|
||||||
**Tests (fake-worker, no MXAccess):** with `Fallback:Mode=ForceSubtag` assert (a) first
|
|
||||||
`StreamAlarms` message is `ProviderStatus{Mode=Subtag,Degraded=true}`; (b) gauge==2; (c)
|
|
||||||
`provider_switches`==0. Add `ForceAlarmManager`→gauge 1 and `Auto`→gauge 1 baseline cases.
|
|
||||||
|
|
||||||
**Verify:** `dotnet build src/ZB.MOM.WW.MxGateway.Server`; `dotnet test src/ZB.MOM.WW.MxGateway.Tests --filter FullyQualifiedName~GatewayAlarmMonitorProviderMode`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Defect #1 (runtime bug — needs diagnosis) — ForceSubtag runs alarmmgr
|
|
||||||
|
|
||||||
The HEAD source path (gateway `MapForcedMode` → `SubscribeAlarmsCommand.ForcedMode=Subtag` →
|
|
||||||
IPC → worker `AlarmCommandHandler.BuildConsumer` → `SubtagAlarmConsumer`) is statically correct,
|
|
||||||
yet the runtime ran alarmmgr. A runtime diagnostic must locate where `forcedMode` becomes
|
|
||||||
`Unspecified`.
|
|
||||||
|
|
||||||
### Task 1: Add worker BuildConsumer observability log (worker, net48 x86)
|
|
||||||
**Classification:** small (net48 — no init-only props) · **Parallelizable with:** Task 2
|
|
||||||
**Files:**
|
|
||||||
- Modify: `src/ZB.MOM.WW.MxGateway.Worker/MxAccess/AlarmCommandHandler.cs` (`BuildConsumer`, ~line 206)
|
|
||||||
|
|
||||||
**Change:** log at entry of `BuildConsumer`:
|
|
||||||
`"BuildConsumer forcedMode={ForcedMode} watchList={Count}"`. This is permanent observability.
|
|
||||||
|
|
||||||
**Verify (windev only):** `dotnet build src/ZB.MOM.WW.MxGateway.Worker -p:Platform=x86`.
|
|
||||||
|
|
||||||
### Task 3: Diagnostic run — capture the real forcedMode
|
|
||||||
**Classification:** standard (ops/diagnostic on windev) · **Parallelizable with:** none
|
|
||||||
**Steps:** rebuild worker (x86) + server from the branch, stand up the D3-style temp ForceSubtag
|
|
||||||
instance (alt ports 5122/5132, isolated `.D3` worker name, WMI-detached, Http2, Development env
|
|
||||||
for file logs), trigger the always-on monitor, and read the two new log lines:
|
|
||||||
- Gateway `Alarm subscribe forcedMode=...` — what the gateway SENDS.
|
|
||||||
- Worker `BuildConsumer forcedMode=...` — what the worker RECEIVES.
|
|
||||||
|
|
||||||
Decision matrix:
|
|
||||||
- Gateway logs `Subtag`, worker logs `Unspecified` → IPC/serialization drops the enum → fix the
|
|
||||||
send/translation path (likely a worker-proto vs gateway-proto `SubscribeAlarmsCommand` mismatch
|
|
||||||
in the named-pipe envelope).
|
|
||||||
- Worker logs `Subtag` but alarmmgr data appears → bug in `BuildStandby`/`SubtagAlarmConsumer`/
|
|
||||||
`AlarmDispatcher` snapshot path.
|
|
||||||
- Gateway logs `Unspecified` despite config ForceSubtag → gateway config/options read.
|
|
||||||
|
|
||||||
### Task 4: Fix #1 per Task 3 diagnosis
|
|
||||||
**Classification:** standard/high-risk (depends on where the defect is) · the exact change is
|
|
||||||
determined by Task 3. Add a regression test at the identified layer (worker unit test for
|
|
||||||
BuildConsumer→SubtagAlarmConsumer, or a contract/IPC round-trip test if the enum is dropped).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Final: build, test, redeploy, re-validate
|
|
||||||
- Build gateway (macOS) + worker (windev x86); run gateway + worker test suites.
|
|
||||||
- Redeploy windev Server (and Worker if Task 4 changed it) per `project_deploy_mechanics`,
|
|
||||||
preserving appsettings.
|
|
||||||
- Re-validate live with the temp ForceSubtag instance: active alarms `degraded=true` /
|
|
||||||
`source_provider=SUBTAG` with recent timestamps, `provider_mode 2`. Tear down temp instance;
|
|
||||||
production untouched.
|
|
||||||
|
|
||||||
## Execution note
|
|
||||||
Branch off `main`. #2 is the clean confirmed fix; #1 is diagnose-then-fix. Net48 worker
|
|
||||||
constraints apply (no init-only props/positional records). Do NOT increment `provider_switches`
|
|
||||||
on an initial forced-mode set.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Resolution (2026-06-15)
|
|
||||||
|
|
||||||
**#1 was NOT a bug — it was a grpcurl proto-mismatch artifact.** End-to-end instrumentation
|
|
||||||
proved: the gateway sends `forcedMode=Subtag`, the worker's `BuildConsumer` builds the
|
|
||||||
`SubtagAlarmConsumer`, and the worker `QueryActive` + gateway `ApplyReconcile`/`StreamAlarms`
|
|
||||||
all carry `degraded=true` / `source_provider=SUBTAG`. The original "degraded=false" observation
|
|
||||||
came from running grpcurl against the host checkout's proto (branch `feat/lazy-browse-children`),
|
|
||||||
which predates the feature and lacks the `degraded`/`source_provider` fields — grpcurl silently
|
|
||||||
dropped them. With the matching proto, every active alarm shows `degraded=true`. No code change.
|
|
||||||
Tasks 1/3/4 (worker diagnostics + #1 fix) were dropped; the temporary diagnostics were reverted.
|
|
||||||
|
|
||||||
**#2 was a real defect — fixed.** Gateway now seeds `_providerMode`/gauge/feed from the configured
|
|
||||||
forced mode (`fix: gateway reflects configured forced provider mode`). Verified live:
|
|
||||||
`provider_mode=2`, first `ProviderStatus` = `Mode=Subtag, degraded=true,
|
|
||||||
reason="Forced subtag mode (configuration)"`. Auto mode unchanged → windev production (Auto)
|
|
||||||
unaffected; no redeploy required. Gateway tests: 163 passed.
|
|
||||||
-61
@@ -143,67 +143,6 @@ session if the worker faults. Gated by `MxGateway:Alarms:Enabled` — see
|
|||||||
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
`docs/DesignDecisions.md` for why this reverses the v1 single-subscriber rule
|
||||||
for the alarm subsystem.
|
for the alarm subsystem.
|
||||||
|
|
||||||
### Alarm providers and failover
|
|
||||||
|
|
||||||
The alarm feed has two providers, both implemented worker-side:
|
|
||||||
|
|
||||||
- **Alarm manager (primary):** `WnWrapAlarmConsumer` polls
|
|
||||||
`wwAlarmConsumerClass.GetXmlCurrentAlarms2` on the worker STA. This is the
|
|
||||||
authoritative native source.
|
|
||||||
- **Subtag monitoring (standby):** `SubtagAlarmConsumer` advises each alarm
|
|
||||||
attribute's subtags (`.active`, `.acked`, optionally `.priority`) via the
|
|
||||||
existing `AddItem`/`Advise` pipeline through `LmxSubtagAlarmSource` and
|
|
||||||
synthesizes alarm transitions with `SubtagAlarmStateMachine`. This is a
|
|
||||||
non-parity, lower-fidelity source — synthetic GUIDs, no native raise
|
|
||||||
timestamps, narrower fields.
|
|
||||||
|
|
||||||
`FailoverAlarmConsumer` wraps both and owns the state machine:
|
|
||||||
|
|
||||||
- **Auto-failover:** after `ConsecutiveFailureThreshold` (default 3)
|
|
||||||
consecutive wnwrap COM failures — `Subscribe` or `PollOnce` throws or
|
|
||||||
returns a failure HRESULT — it activates the standby. The standby is armed
|
|
||||||
(subscribed and adviseing) from the start so its state is warm at the moment
|
|
||||||
of switch.
|
|
||||||
- **Auto-failback:** while degraded, every `FailbackProbeIntervalSeconds`
|
|
||||||
(default 30) it re-probes the still-subscribed primary. After
|
|
||||||
`FailbackStableProbes` (default 3) consecutive clean polls it switches back
|
|
||||||
to the alarm manager.
|
|
||||||
- **On every switch:** the consumer snapshots the now-active provider and
|
|
||||||
emits `OnAlarmProviderModeChangedEvent` so the gateway can reconcile its
|
|
||||||
cache without a raise/clear storm.
|
|
||||||
|
|
||||||
Synthesis is worker-side. This preserves the parity rule — the gateway
|
|
||||||
forwards only events the worker emits and never synthesizes transitions
|
|
||||||
itself. The synthesis rules are documented in
|
|
||||||
`docs/AlarmClientDiscovery.md`.
|
|
||||||
|
|
||||||
**Acknowledge in subtag mode:** the ack-by-name path writes the operator
|
|
||||||
comment to the alarm attribute's ack-comment subtag. The write performs the
|
|
||||||
ack. If the attribute has no writable ack-comment subtag configured, the RPC
|
|
||||||
returns `FailedPrecondition`. In alarm-manager mode, `AlarmAckByName` is
|
|
||||||
used as before.
|
|
||||||
|
|
||||||
**Degraded state visibility:** every subtag-mode transition carries
|
|
||||||
`degraded = true` and `source_provider = ALARM_PROVIDER_MODE_SUBTAG` on the
|
|
||||||
`OnAlarmTransitionEvent` and `ActiveAlarmSnapshot` proto fields. The
|
|
||||||
`AlarmFeedMessage` feed emits an `AlarmProviderStatus` message (the
|
|
||||||
`provider_status` oneof case) on stream open and on every switch. The
|
|
||||||
dashboard shows a Bootstrap badge: green ("Alarm Manager") when healthy, amber
|
|
||||||
("Subtag monitoring (degraded)") on an unexpected failover, and cyan ("Subtag
|
|
||||||
monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSubtag`
|
|
||||||
— the latter distinguished by the well-known `AlarmProviderStatus.reason`
|
|
||||||
(`AlarmProviderReasons.ForcedSubtag`) so an intentional configuration is not shown
|
|
||||||
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
|
|
||||||
2 = subtag) and `mxgateway.alarms.provider_switches` counter.
|
|
||||||
|
|
||||||
Forced modes are available via `MxGateway:Alarms:Fallback:Mode`:
|
|
||||||
`ForceAlarmManager` disables failover; `ForceSubtag` forces the standby
|
|
||||||
on from startup; `Auto` (default) enables failover and failback. Watch-list
|
|
||||||
discovery for the subtag provider uses Galaxy Repository SQL with config
|
|
||||||
overrides. See `docs/GatewayConfiguration.md` for the full `Fallback` option
|
|
||||||
block and `docs/AlarmClientDiscovery.md` for synthesis rules and fidelity
|
|
||||||
limitations.
|
|
||||||
|
|
||||||
Dashboard authentication is LDAP-backed (distinct from the API-key model on
|
Dashboard authentication is LDAP-backed (distinct from the API-key model on
|
||||||
the gRPC API). `/login` accepts username and password in a form body, binds
|
the gRPC API). `/login` accepts username and password in a form body, binds
|
||||||
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
|
against `MxGateway:Ldap`, maps the user's LDAP groups to `Admin` or `Viewer`
|
||||||
|
|||||||
@@ -1,36 +1,27 @@
|
|||||||
# GLAuth — LDAP authn reference for mxaccessgw
|
# GLAuth — LDAP authn reference for mxaccessgw
|
||||||
|
|
||||||
> **UPDATED 2026-06-04 — mxaccessgw no longer uses a per-box GLAuth at `C:\publish\glauth`.
|
GLAuth is a lightweight LDAP server installed on this dev box at
|
||||||
> Dev/test LDAP is now the SHARED GLAuth on `10.100.0.35:3893` (`dc=zb,dc=local`);
|
`C:\publish\glauth\` and run as a Windows service via NSSM. It already
|
||||||
> the single source of truth is `scadaproj/infra/glauth/` (`config.toml` + `README`).
|
backs the LmxOpcUa OPC UA server's UserName-token authn and the LmxOpcUa
|
||||||
> The localhost/NSSM/`glauth.cfg` procedures below are RETIRED, kept for reference/rollback.**
|
Admin UI's cookie login; this doc captures everything mxaccessgw needs
|
||||||
|
to consume the same directory so a single set of dev credentials covers
|
||||||
|
both stacks.
|
||||||
|
|
||||||
GLAuth is a lightweight LDAP server. It already backs all three sister apps (MxAccessGateway,
|
The authoritative copy of LmxOpcUa's reference lives at
|
||||||
OtOpcUa, ScadaBridge) through a **shared container** (`zb-shared-glauth`) running on the Linux
|
`C:\publish\glauth\auth.md`. This doc is a redistilled view tailored to
|
||||||
docker host at **`10.100.0.35:3893`**. This doc captures everything mxaccessgw needs to consume
|
mxaccessgw — what users + groups are already provisioned, how to bind
|
||||||
that directory so a single set of dev credentials covers all stacks.
|
against them, and what's needed to add a gw-specific role.
|
||||||
|
|
||||||
~~GLAuth is installed on this dev box at `C:\publish\glauth\` and run as a Windows service via
|
|
||||||
NSSM.~~ *(RETIRED — the per-box Windows service has been stopped and set to Manual startup;
|
|
||||||
kept only as a rollback option. Do not edit or restart it for new work.)*
|
|
||||||
|
|
||||||
The single source of truth for the shared GLAuth is
|
|
||||||
**`~/Desktop/scadaproj/infra/glauth/config.toml`** (deploy/verify runbook:
|
|
||||||
`scadaproj/infra/glauth/README.md`). This doc is a redistilled view tailored to mxaccessgw —
|
|
||||||
what users + groups are provisioned, how to bind against them, and what's needed to add a
|
|
||||||
gw-specific role.
|
|
||||||
|
|
||||||
## Connection details
|
## Connection details
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Protocol | LDAP (unencrypted) |
|
| Protocol | LDAP (unencrypted) |
|
||||||
| Host | **`10.100.0.35`** (shared docker host — ~~`localhost`~~ retired) |
|
| Host | `localhost` |
|
||||||
| Port | `3893` |
|
| Port | `3893` |
|
||||||
| LDAPS | disabled in dev (`Transport=None`, `AllowInsecure=true`) |
|
| LDAPS | disabled in dev (set `[ldaps]` block to enable) |
|
||||||
| Base DN | `dc=zb,dc=local` |
|
| Base DN | `dc=zb,dc=local` |
|
||||||
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
| Bind DN format | `cn={username},dc=zb,dc=local` |
|
||||||
| Service account DN | `cn=serviceaccount,dc=zb,dc=local` / `serviceaccount123` |
|
|
||||||
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
| Group OU | `ou=<groupname>,ou=groups,dc=zb,dc=local` |
|
||||||
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
| Failed-bind throttle | 3 fails → 10-minute IP lockout (per `[behaviors]`) |
|
||||||
|
|
||||||
@@ -68,13 +59,13 @@ For mxaccessgw dev, `admin` covers every gw-side capability test;
|
|||||||
`readonly` is the right "negative" case for proving Browse-OK /
|
`readonly` is the right "negative" case for proving Browse-OK /
|
||||||
Write-denied.
|
Write-denied.
|
||||||
|
|
||||||
The gateway dashboard uses two gateway-specific groups beyond the LmxOpcUa taxonomy:
|
The gateway dashboard adds one role beyond this LmxOpcUa taxonomy:
|
||||||
`GwAdmin` (gid 5610 → role `Administrator`) and `GwReader` (gid 5611 → role `Viewer`).
|
`GwAdmin`. `LdapOptions.RequiredGroup` defaults to `GwAdmin`, so the
|
||||||
These are already provisioned in the shared `scadaproj/infra/glauth/config.toml`.
|
dashboard login and `DashboardLdapLiveTests` require `admin` to be a
|
||||||
The dashboard test users are **`multi-role`/`password`** (Administrator) and
|
member of a `GwAdmin` group. `GwAdmin` is **not** in the baseline
|
||||||
**`gw-viewer`/`password`** (Viewer). `LdapOptions.RequiredGroup` defaults to `GwAdmin`.
|
GLAuth config — it must be provisioned before dashboard authn or the
|
||||||
See [Provisioning the GwAdmin group](#provisioning-the-gwadmin-group) below for the
|
LDAP live tests work. See [Provisioning the GwAdmin
|
||||||
(now-retired) per-box procedure and for the shared-config equivalent.
|
group](#provisioning-the-gwadmin-group) below.
|
||||||
|
|
||||||
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
> **Dashboard role value (Task 1.7):** the LDAP `GwAdmin` group now maps to
|
||||||
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
> the canonical dashboard role **`Administrator`** (was `Admin`); `GwReader`
|
||||||
@@ -127,7 +118,7 @@ record:
|
|||||||
```yaml
|
```yaml
|
||||||
ldap:
|
ldap:
|
||||||
enabled: true
|
enabled: true
|
||||||
server: 10.100.0.35 # shared GLAuth on docker host (was localhost)
|
server: localhost
|
||||||
port: 3893
|
port: 3893
|
||||||
useTls: false
|
useTls: false
|
||||||
allowInsecureLdap: true # dev only
|
allowInsecureLdap: true # dev only
|
||||||
@@ -152,29 +143,13 @@ look that up in `groupToRole`.
|
|||||||
|
|
||||||
## Provisioning the GwAdmin group
|
## Provisioning the GwAdmin group
|
||||||
|
|
||||||
> **UPDATED 2026-06-04 — RETIRED per-box procedure.** `GwAdmin` (gid 5610) and `GwReader`
|
|
||||||
> (gid 5611) are already present in the shared GLAuth. To add or modify users/groups,
|
|
||||||
> edit **`~/Desktop/scadaproj/infra/glauth/config.toml`** on host `10.100.0.35` and run:
|
|
||||||
>
|
|
||||||
> ```bash
|
|
||||||
> cd ~/Desktop/scadaproj/infra/glauth
|
|
||||||
> docker compose up -d --force-recreate
|
|
||||||
> ```
|
|
||||||
>
|
|
||||||
> The per-box `C:\publish\glauth\glauth.cfg` + NSSM procedure below is kept for
|
|
||||||
> rollback reference only — do not use it for new provisioning.
|
|
||||||
|
|
||||||
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
`GwAdmin` is the gateway-specific dashboard-admin role. It is the
|
||||||
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
default `LdapOptions.RequiredGroup`, so the dashboard cookie login and
|
||||||
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
`DashboardLdapLiveTests` (`MXGATEWAY_RUN_LIVE_LDAP_TESTS=1`) reject
|
||||||
logins unless the user is a member of `GwAdmin`.
|
`admin` until a `GwAdmin` group exists and `admin` is a member.
|
||||||
The `GwAdmin` (gid 5610) and `GwReader` (gid 5611) groups already exist in the shared
|
GLAuth's baseline config ships only the five LmxOpcUa role groups, so
|
||||||
config at `scadaproj/infra/glauth/config.toml`. Dashboard test users are
|
`GwAdmin` must be added to GLAuth rather than run from a separate LDAP
|
||||||
`multi-role`/`password` (Administrator) and `gw-viewer`/`password` (Viewer).
|
server:
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**RETIRED — per-box provisioning (reference/rollback only):**
|
|
||||||
|
|
||||||
1. Edit `C:\publish\glauth\glauth.cfg`
|
1. Edit `C:\publish\glauth\glauth.cfg`
|
||||||
2. Append the group:
|
2. Append the group:
|
||||||
@@ -224,16 +199,15 @@ echo -n "yourpassword" | openssl dgst -sha256
|
|||||||
|
|
||||||
## Quick verification
|
## Quick verification
|
||||||
|
|
||||||
From mxaccessgw's dev box, prove the shared directory is reachable:
|
From mxaccessgw's dev box, prove the directory is reachable:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
# Plain bind via PowerShell + System.DirectoryServices.Protocols
|
||||||
# (shared GLAuth on 10.100.0.35 — was localhost, now the docker host)
|
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("localhost:3893")
|
||||||
$ldap = New-Object System.DirectoryServices.Protocols.LdapConnection("10.100.0.35:3893")
|
|
||||||
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
$ldap.AuthType = [System.DirectoryServices.Protocols.AuthType]::Basic
|
||||||
$ldap.SessionOptions.ProtocolVersion = 3
|
$ldap.SessionOptions.ProtocolVersion = 3
|
||||||
$ldap.SessionOptions.SecureSocketLayer = $false
|
$ldap.SessionOptions.SecureSocketLayer = $false
|
||||||
$cred = New-Object System.Net.NetworkCredential("cn=multi-role,dc=zb,dc=local","password")
|
$cred = New-Object System.Net.NetworkCredential("cn=admin,dc=zb,dc=local","admin123")
|
||||||
$ldap.Bind($cred)
|
$ldap.Bind($cred)
|
||||||
"Bind OK"
|
"Bind OK"
|
||||||
```
|
```
|
||||||
@@ -241,32 +215,17 @@ $ldap.Bind($cred)
|
|||||||
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
Or via `ldapsearch` if you have OpenLDAP CLI tools:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
ldapsearch -x -H ldap://10.100.0.35:3893 \
|
ldapsearch -x -H ldap://localhost:3893 \
|
||||||
-D "cn=serviceaccount,dc=zb,dc=local" -w serviceaccount123 \
|
-D "cn=admin,dc=zb,dc=local" -w admin123 \
|
||||||
-b "dc=zb,dc=local" "(uid=multi-role)"
|
-b "dc=zb,dc=local" "(uid=admin)"
|
||||||
```
|
```
|
||||||
|
|
||||||
The response should list `multi-role`'s entry with `memberOf` including
|
The response should list `admin`'s entry with `memberOf` populated for
|
||||||
`ou=GwAdmin,ou=groups,dc=zb,dc=local`.
|
all five role groups — plus `GwAdmin` once the gateway-specific group
|
||||||
|
is provisioned.
|
||||||
|
|
||||||
## Service management
|
## Service management
|
||||||
|
|
||||||
> **RETIRED — per-box NSSM service (reference/rollback only).** The shared GLAuth is
|
|
||||||
> managed via `docker compose` on `10.100.0.35` (`scadaproj/infra/glauth/`). The
|
|
||||||
> Windows NSSM `GLAuth` service on the dev box has been stopped and set to
|
|
||||||
> `StartupType=Manual`; only restart it if you need to roll back to a local directory.
|
|
||||||
>
|
|
||||||
> **Active (shared) management:**
|
|
||||||
> ```bash
|
|
||||||
> ssh 10.100.0.35
|
|
||||||
> cd ~/Desktop/scadaproj/infra/glauth
|
|
||||||
> docker compose ps # check container status
|
|
||||||
> docker compose up -d --force-recreate # apply config.toml changes
|
|
||||||
> docker compose logs -f # tail logs
|
|
||||||
> ```
|
|
||||||
|
|
||||||
**RETIRED — per-box NSSM commands (rollback reference):**
|
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
# Status / start / stop / restart
|
# Status / start / stop / restart
|
||||||
nssm status GLAuth
|
nssm status GLAuth
|
||||||
@@ -300,7 +259,7 @@ applies to mxaccessgw verbatim. Keys that change:
|
|||||||
|
|
||||||
| Field | GLAuth dev value | AD production value |
|
| Field | GLAuth dev value | AD production value |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `Server` | `10.100.0.35` (shared docker host) | a domain controller FQDN, or the domain itself |
|
| `Server` | `localhost` | a domain controller FQDN, or the domain itself |
|
||||||
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
| `Port` | `3893` | `636` (LDAPS) — AD increasingly rejects plain bind under LDAP-signing enforcement |
|
||||||
| `UseTls` | `false` | `true` |
|
| `UseTls` | `false` | `true` |
|
||||||
| `AllowInsecureLdap` | `true` | `false` |
|
| `AllowInsecureLdap` | `true` | `false` |
|
||||||
@@ -316,12 +275,12 @@ add a `tokenGroups` query as an enhancement.
|
|||||||
|
|
||||||
## Security notes for production
|
## Security notes for production
|
||||||
|
|
||||||
- **Plaintext passwords in `config.toml` are dev-only.** The shared config is in
|
- **Plaintext passwords in `glauth.cfg` are dev-only.** The config is
|
||||||
`scadaproj/infra/glauth/config.toml` (unencrypted); restrict filesystem access on
|
unencrypted on disk; anyone with read access to `C:\publish\glauth\`
|
||||||
`10.100.0.35` accordingly. Treat the dev creds as throwaway. Production LDAP is Active
|
can SHA256-rainbow-table the entries. Treat the dev creds as
|
||||||
Directory. *(The retired per-box `C:\publish\glauth\glauth.cfg` has the same caveat.)*
|
throwaway. Production LDAP is Active Directory.
|
||||||
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
- The 3-fail / 10-minute lockout is per source IP, not per user — a
|
||||||
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
shared NAT can lock out a whole office. Tunable in `[behaviors]`.
|
||||||
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
- LDAPS isn't enabled in dev; binding sends passwords cleartext on the
|
||||||
wire. The shared GLAuth listens only on the LAN (`10.100.0.35`); never
|
wire. Fine for `localhost`, never expose port 3893 off-box without
|
||||||
expose port 3893 externally without enabling TLS first.
|
enabling TLS first.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -315,14 +315,6 @@ message SubscribeBulkCommand {
|
|||||||
repeated string tag_addresses = 2;
|
repeated string tag_addresses = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Provider selection / current provider for the alarm feed. UNSPECIFIED on a
|
|
||||||
// SubscribeAlarmsCommand means auto: alarmmgr primary with subtag fallback.
|
|
||||||
enum AlarmProviderMode {
|
|
||||||
ALARM_PROVIDER_MODE_UNSPECIFIED = 0;
|
|
||||||
ALARM_PROVIDER_MODE_ALARMMGR = 1;
|
|
||||||
ALARM_PROVIDER_MODE_SUBTAG = 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Subscribe the worker's alarm consumer to an AVEVA alarm provider.
|
// Subscribe the worker's alarm consumer to an AVEVA alarm provider.
|
||||||
// Subscription expression follows the canonical
|
// Subscription expression follows the canonical
|
||||||
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
// `\\<machine>\Galaxy!<area>` format (literal "Galaxy" provider). The
|
||||||
@@ -331,12 +323,6 @@ enum AlarmProviderMode {
|
|||||||
// SubscribeAlarms to reconfigure).
|
// SubscribeAlarms to reconfigure).
|
||||||
message SubscribeAlarmsCommand {
|
message SubscribeAlarmsCommand {
|
||||||
string subscription_expression = 1;
|
string subscription_expression = 1;
|
||||||
// UNSPECIFIED = auto-failover/failback. ALARMMGR/SUBTAG force one provider.
|
|
||||||
AlarmProviderMode forced_mode = 2;
|
|
||||||
// Subtag watch-list resolved by the gateway (GR SQL + config). Empty in pure
|
|
||||||
// alarmmgr mode; in subtag mode it bounds what the consumer can observe.
|
|
||||||
repeated AlarmSubtagTarget watch_list = 3;
|
|
||||||
AlarmFailoverConfig failover = 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tear down the worker's alarm consumer. No-op if no subscription is
|
// Tear down the worker's alarm consumer. No-op if no subscription is
|
||||||
@@ -344,23 +330,6 @@ message SubscribeAlarmsCommand {
|
|||||||
message UnsubscribeAlarmsCommand {
|
message UnsubscribeAlarmsCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
// One alarm attribute the subtag fallback consumer advises. Addresses are full
|
|
||||||
// MXAccess item references the worker passes straight to AddItem.
|
|
||||||
message AlarmSubtagTarget {
|
|
||||||
string alarm_full_reference = 1; // e.g. "Galaxy!Area.Tank01.Level.HiHi"
|
|
||||||
string source_object_reference = 2; // e.g. "Tank01"
|
|
||||||
string active_subtag = 3; // item address of the in-alarm boolean
|
|
||||||
string acked_subtag = 4; // item address of the acknowledged boolean
|
|
||||||
string ack_comment_subtag = 5; // writable ack-comment attribute (ack write target)
|
|
||||||
string priority_subtag = 6; // optional severity source; empty if absent
|
|
||||||
}
|
|
||||||
|
|
||||||
message AlarmFailoverConfig {
|
|
||||||
int32 consecutive_failure_threshold = 1; // wnwrap COM failures before switching (>=1)
|
|
||||||
int32 failback_probe_interval_seconds = 2; // probe cadence while degraded (>=1)
|
|
||||||
int32 failback_stable_probes = 3; // clean probes before switching back (>=1)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
// Acknowledge a single alarm by its GUID. Operator identity fields are
|
||||||
// recorded atomically with the ack transition in the alarm-history log.
|
// recorded atomically with the ack transition in the alarm-history log.
|
||||||
// The reply's hresult / native_status surfaces AVEVA's
|
// The reply's hresult / native_status surfaces AVEVA's
|
||||||
@@ -715,7 +684,6 @@ message MxEvent {
|
|||||||
OperationCompleteEvent operation_complete = 22;
|
OperationCompleteEvent operation_complete = 22;
|
||||||
OnBufferedDataChangeEvent on_buffered_data_change = 23;
|
OnBufferedDataChangeEvent on_buffered_data_change = 23;
|
||||||
OnAlarmTransitionEvent on_alarm_transition = 24;
|
OnAlarmTransitionEvent on_alarm_transition = 24;
|
||||||
OnAlarmProviderModeChangedEvent on_alarm_provider_mode_changed = 25;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -726,7 +694,6 @@ enum MxEventFamily {
|
|||||||
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
|
MX_EVENT_FAMILY_OPERATION_COMPLETE = 3;
|
||||||
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
|
MX_EVENT_FAMILY_ON_BUFFERED_DATA_CHANGE = 4;
|
||||||
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
|
MX_EVENT_FAMILY_ON_ALARM_TRANSITION = 5;
|
||||||
MX_EVENT_FAMILY_ON_ALARM_PROVIDER_MODE_CHANGED = 6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message OnDataChangeEvent {
|
message OnDataChangeEvent {
|
||||||
@@ -801,20 +768,6 @@ message OnAlarmTransitionEvent {
|
|||||||
// Limit/threshold value that triggered the transition for limit alarms.
|
// Limit/threshold value that triggered the transition for limit alarms.
|
||||||
// Optional; populated for AnalogLimitAlarm-family transitions.
|
// Optional; populated for AnalogLimitAlarm-family transitions.
|
||||||
MxValue limit_value = 13;
|
MxValue limit_value = 13;
|
||||||
|
|
||||||
// True when this transition came from the subtag-monitoring fallback rather
|
|
||||||
// than the native alarmmgr provider — synthesized from data changes, reduced
|
|
||||||
// fidelity (synthetic GUID, no native raise time).
|
|
||||||
bool degraded = 14;
|
|
||||||
// Which provider produced this transition.
|
|
||||||
AlarmProviderMode source_provider = 15;
|
|
||||||
}
|
|
||||||
|
|
||||||
message OnAlarmProviderModeChangedEvent {
|
|
||||||
AlarmProviderMode mode = 1;
|
|
||||||
string reason = 2;
|
|
||||||
int32 hresult = 3; // COM HRESULT that triggered failover; 0 on failback
|
|
||||||
google.protobuf.Timestamp at = 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AlarmTransitionKind {
|
enum AlarmTransitionKind {
|
||||||
@@ -847,8 +800,6 @@ message ActiveAlarmSnapshot {
|
|||||||
string operator_comment = 11;
|
string operator_comment = 11;
|
||||||
MxValue current_value = 12;
|
MxValue current_value = 12;
|
||||||
MxValue limit_value = 13;
|
MxValue limit_value = 13;
|
||||||
bool degraded = 14;
|
|
||||||
AlarmProviderMode source_provider = 15;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
enum AlarmConditionState {
|
enum AlarmConditionState {
|
||||||
@@ -915,19 +866,9 @@ message AlarmFeedMessage {
|
|||||||
bool snapshot_complete = 2;
|
bool snapshot_complete = 2;
|
||||||
// A live alarm state change (raise / acknowledge / clear).
|
// A live alarm state change (raise / acknowledge / clear).
|
||||||
OnAlarmTransitionEvent transition = 3;
|
OnAlarmTransitionEvent transition = 3;
|
||||||
// Provider-mode status. Emitted once on stream open and again on every
|
|
||||||
// failover/failback so late joiners learn the current mode immediately.
|
|
||||||
AlarmProviderStatus provider_status = 4;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
message AlarmProviderStatus {
|
|
||||||
AlarmProviderMode mode = 1;
|
|
||||||
bool degraded = 2; // true whenever mode == SUBTAG
|
|
||||||
string reason = 3; // human-readable switch reason
|
|
||||||
google.protobuf.Timestamp since = 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
message MxStatusProxy {
|
message MxStatusProxy {
|
||||||
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
// Mirrors the `success` member of the MXAccess MXSTATUS_PROXY struct
|
||||||
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
// (a 16-bit signed value in the COM struct, widened to int32 on the
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests;
|
|||||||
public sealed class DashboardLdapLiveTests
|
public sealed class DashboardLdapLiveTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
|
/// <summary>Verifies that an admin user in the GwAdmin group authenticates successfully.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
public async Task AuthenticateAsync_AdminInGwAdminGroup_Succeeds()
|
||||||
{
|
{
|
||||||
@@ -42,6 +43,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
|
/// <summary>Verifies that a readonly user without GwAdmin group fails to authenticate.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
|
public async Task AuthenticateAsync_ReadOnlyUserMissingGwAdminGroup_Fails()
|
||||||
{
|
{
|
||||||
@@ -58,6 +60,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
/// <summary>Verifies that authentication with wrong password fails without leaking the password.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
public async Task AuthenticateAsync_AdminWithWrongPassword_FailsWithoutLeakingPassword()
|
||||||
{
|
{
|
||||||
@@ -77,6 +80,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authentication with unknown username fails.</summary>
|
/// <summary>Verifies that authentication with unknown username fails.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
public async Task AuthenticateAsync_UnknownUsername_Fails()
|
||||||
{
|
{
|
||||||
@@ -94,6 +98,7 @@ public sealed class DashboardLdapLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
|
/// <summary>Verifies that authentication fails gracefully when the server is unreachable.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveLdapFact]
|
[LiveLdapFact]
|
||||||
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
public async Task AuthenticateAsync_ServerUnreachable_FailsWithoutThrowing()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ namespace ZB.MOM.WW.MxGateway.IntegrationTests.Galaxy;
|
|||||||
public sealed class GalaxyRepositoryLiveTests
|
public sealed class GalaxyRepositoryLiveTests
|
||||||
{
|
{
|
||||||
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
|
/// <summary>Verifies that the Galaxy Repository can establish a live connection to the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task TestConnection_AgainstZb_Succeeds()
|
public async Task TestConnection_AgainstZb_Succeeds()
|
||||||
{
|
{
|
||||||
@@ -18,6 +19,7 @@ public sealed class GalaxyRepositoryLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
|
/// <summary>Verifies that the last deploy time can be retrieved from the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
|
public async Task GetLastDeployTime_AgainstZb_ReturnsTimestamp()
|
||||||
{
|
{
|
||||||
@@ -29,6 +31,7 @@ public sealed class GalaxyRepositoryLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
|
/// <summary>Verifies that the hierarchy can be retrieved from the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
|
public async Task GetHierarchy_AgainstZb_ReturnsObjects()
|
||||||
{
|
{
|
||||||
@@ -46,6 +49,7 @@ public sealed class GalaxyRepositoryLiveTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
|
/// <summary>Verifies that object attributes can be retrieved from the ZB database.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveGalaxyRepositoryFact]
|
[LiveGalaxyRepositoryFact]
|
||||||
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
|
public async Task GetAttributes_AgainstZb_ReturnsAtLeastOneAttribute()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
|
/// Verifies that a gateway session can register, add item, advise, and stream events from live MXAccess.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
|
public async Task GatewaySession_WithLiveWorker_RegistersAdvisesStreamsDataAndCloses()
|
||||||
{
|
{
|
||||||
@@ -119,6 +120,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event
|
/// and that the worker emits a matching <see cref="MxEventFamily.OnWriteComplete"/> event
|
||||||
/// — the proof of round-trip the cross-language client e2e runner relies on.
|
/// — the proof of round-trip the cross-language client e2e runner relies on.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem()
|
public async Task GatewaySession_WithLiveWorker_WritesValueToAdvisedItem()
|
||||||
{
|
{
|
||||||
@@ -235,6 +237,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure
|
/// Verifies that an AddItem against an invalid server handle surfaces the MXAccess failure
|
||||||
/// without faulting the gateway transport, exercising the invalid-handle parity path.
|
/// without faulting the gateway transport, exercising the invalid-handle parity path.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault()
|
public async Task GatewaySession_WithLiveWorker_InvalidHandleCommand_SurfacesFailureWithoutTransportFault()
|
||||||
{
|
{
|
||||||
@@ -293,6 +296,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
|
/// OnDataChange events for the un-advised item. Exercises the lifecycle-ordering
|
||||||
/// parity CLAUDE.md singles out as a "do not synthesize" rule.
|
/// parity CLAUDE.md singles out as a "do not synthesize" rule.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity()
|
public async Task GatewaySession_WithLiveWorker_UnadviseRemoveItemUnregister_TeardownOrderingParity()
|
||||||
{
|
{
|
||||||
@@ -437,6 +441,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// parity surface the gateway must not "fix" — the test asserts the reply kind and
|
/// parity surface the gateway must not "fix" — the test asserts the reply kind and
|
||||||
/// protocol status, not a fabricated outcome.
|
/// protocol status, not a fabricated outcome.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
|
public async Task GatewaySession_WithLiveWorker_WriteSecured_AuthenticatedRoundTripParity()
|
||||||
{
|
{
|
||||||
@@ -568,6 +573,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// must observe the abnormal exit, transition the session, and surface a non-empty
|
/// must observe the abnormal exit, transition the session, and surface a non-empty
|
||||||
/// fault description rather than hanging or crashing.
|
/// fault description rather than hanging or crashing.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
[LiveMxAccessFact]
|
[LiveMxAccessFact]
|
||||||
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted()
|
public async Task GatewaySession_WithLiveWorker_AbnormalWorkerExit_MarksSessionFaulted()
|
||||||
{
|
{
|
||||||
@@ -1114,6 +1120,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
/// <param name="sessionId">The session identifier.</param>
|
||||||
/// <param name="session">The session if found; otherwise null.</param>
|
/// <param name="session">The session if found; otherwise null.</param>
|
||||||
|
/// <returns>True if the session was found; otherwise false.</returns>
|
||||||
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
|
||||||
{
|
{
|
||||||
return _registry.TryGet(sessionId, out session);
|
return _registry.TryGet(sessionId, out session);
|
||||||
@@ -1122,6 +1129,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Disposes the fixture resources and closes all sessions.
|
/// Disposes the fixture resources and closes all sessions.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
foreach (GatewaySession session in _registry.Snapshot())
|
foreach (GatewaySession session in _registry.Snapshot())
|
||||||
@@ -1192,6 +1200,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// Records the message and signals any pending waiter.
|
/// Records the message and signals any pending waiter.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">The message to write.</param>
|
/// <param name="message">The message to write.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task WriteAsync(T message)
|
public Task WriteAsync(T message)
|
||||||
{
|
{
|
||||||
lock (syncRoot)
|
lock (syncRoot)
|
||||||
@@ -1374,7 +1383,9 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
return workerProcess;
|
return workerProcess;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Waits for all recorded worker processes to exit within the specified timeout.</summary>
|
||||||
|
/// <param name="timeout">Maximum time to wait for each process to exit.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task WaitForProcessesAsync(TimeSpan timeout)
|
public async Task WaitForProcessesAsync(TimeSpan timeout)
|
||||||
{
|
{
|
||||||
foreach (TestWorkerProcess process in processes)
|
foreach (TestWorkerProcess process in processes)
|
||||||
@@ -1454,7 +1465,7 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
process.Kill(entireProcessTree);
|
process.Kill(entireProcessTree);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases the wrapped process resources.</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
process.Dispose();
|
process.Dispose();
|
||||||
@@ -1466,13 +1477,15 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
|
private sealed class TestOutputLoggerProvider(ITestOutputHelper output) : ILoggerProvider
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>Creates a logger that writes to the test output helper for the given category.</summary>
|
||||||
|
/// <param name="categoryName">The logger category name.</param>
|
||||||
|
/// <returns>A logger that forwards to the test output helper.</returns>
|
||||||
public ILogger CreateLogger(string categoryName)
|
public ILogger CreateLogger(string categoryName)
|
||||||
{
|
{
|
||||||
return new TestOutputLogger(output, categoryName);
|
return new TestOutputLogger(output, categoryName);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases resources held by the provider (no-op for this test double).</summary>
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
@@ -1485,20 +1498,31 @@ public sealed class WorkerLiveMxAccessSmokeTests(ITestOutputHelper output)
|
|||||||
ITestOutputHelper output,
|
ITestOutputHelper output,
|
||||||
string categoryName) : ILogger
|
string categoryName) : ILogger
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>Begins a log scope; returns null as this test logger does not support scopes.</summary>
|
||||||
|
/// <param name="state">The state object for the scope.</param>
|
||||||
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||||
|
/// <returns>Always null.</returns>
|
||||||
public IDisposable? BeginScope<TState>(TState state)
|
public IDisposable? BeginScope<TState>(TState state)
|
||||||
where TState : notnull
|
where TState : notnull
|
||||||
{
|
{
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Returns true for log levels at or above <see cref="LogLevel.Information"/>.</summary>
|
||||||
|
/// <param name="logLevel">The log level to check.</param>
|
||||||
|
/// <returns>True if the log level is enabled.</returns>
|
||||||
public bool IsEnabled(LogLevel logLevel)
|
public bool IsEnabled(LogLevel logLevel)
|
||||||
{
|
{
|
||||||
return logLevel >= LogLevel.Information;
|
return logLevel >= LogLevel.Information;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Writes a log entry to the test output helper.</summary>
|
||||||
|
/// <param name="logLevel">The log level.</param>
|
||||||
|
/// <param name="eventId">The event identifier.</param>
|
||||||
|
/// <param name="state">The state object to log.</param>
|
||||||
|
/// <param name="exception">Optional exception associated with the log entry.</param>
|
||||||
|
/// <param name="formatter">Function to format the state and exception into a string.</param>
|
||||||
|
/// <typeparam name="TState">The type of the state object.</typeparam>
|
||||||
public void Log<TState>(
|
public void Log<TState>(
|
||||||
LogLevel logLevel,
|
LogLevel logLevel,
|
||||||
EventId eventId,
|
EventId eventId,
|
||||||
|
|||||||
@@ -1,18 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Well-known <c>reason</c> strings carried on the alarm feed's
|
|
||||||
/// <c>AlarmProviderStatus</c> message. Shared between the producer
|
|
||||||
/// (<see cref="GatewayAlarmMonitor" />) and consumers (e.g. the dashboard
|
|
||||||
/// provider badge) so the two cannot drift on a magic string.
|
|
||||||
/// </summary>
|
|
||||||
public static class AlarmProviderReasons
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Reason set when the monitor starts in subtag mode because
|
|
||||||
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c> — a
|
|
||||||
/// deliberate configuration, not a runtime failover. Lets the dashboard
|
|
||||||
/// distinguish a forced subtag mode from an unexpected degraded failover.
|
|
||||||
/// </summary>
|
|
||||||
public const string ForcedSubtag = "Forced subtag mode (configuration)";
|
|
||||||
}
|
|
||||||
@@ -1,178 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Default <see cref="IAlarmWatchListResolver"/>. Merges Galaxy Repository
|
|
||||||
/// alarm-attribute discovery with the configured include/exclude overrides
|
|
||||||
/// and composes the per-attribute subtag item addresses from the configured
|
|
||||||
/// subtag names.
|
|
||||||
/// </summary>
|
|
||||||
// NOTE: The exact subtag names and the canonical AlarmFullReference shape
|
|
||||||
// ("Galaxy!{area}.{reference}") are validated against a live Galaxy in the
|
|
||||||
// Task 17 live smoke test. The config Subtags block exists precisely so these
|
|
||||||
// names are not hard-coded here. The {area} is the alarm object's REAL Galaxy
|
|
||||||
// area discovered via gobject.area_gobject_id (the alarm group the native
|
|
||||||
// alarmmgr emits), giving exact reference parity with wnwrap. The configured
|
|
||||||
// Discovery.Area/DefaultArea is only the fallback for explicit IncludeAttributes
|
|
||||||
// entries, which carry no discovered area.
|
|
||||||
public sealed class AlarmWatchListResolver : IAlarmWatchListResolver
|
|
||||||
{
|
|
||||||
private const string ProviderLiteral = "Galaxy";
|
|
||||||
private const string DefaultActiveSubtag = "InAlarm";
|
|
||||||
private const string DefaultAckedSubtag = "Acked";
|
|
||||||
|
|
||||||
private readonly IGalaxyRepository _repository;
|
|
||||||
private readonly ILogger<AlarmWatchListResolver> _logger;
|
|
||||||
|
|
||||||
/// <summary>Initializes the watch-list resolver.</summary>
|
|
||||||
/// <param name="repository">Galaxy Repository used for alarm-attribute discovery.</param>
|
|
||||||
/// <param name="logger">Diagnostic logger.</param>
|
|
||||||
public AlarmWatchListResolver(
|
|
||||||
IGalaxyRepository repository,
|
|
||||||
ILogger<AlarmWatchListResolver> logger)
|
|
||||||
{
|
|
||||||
_repository = repository ?? throw new ArgumentNullException(nameof(repository));
|
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public async Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
|
|
||||||
AlarmsOptions options,
|
|
||||||
CancellationToken cancellationToken = default)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(options);
|
|
||||||
|
|
||||||
AlarmDiscoveryOptions discovery = options.Fallback.Discovery;
|
|
||||||
|
|
||||||
// Config fallback area used only for explicit IncludeAttributes entries (which
|
|
||||||
// carry no discovered area): discovery area, else the default area (may be empty).
|
|
||||||
string configFallbackArea = string.IsNullOrEmpty(discovery.Area) ? options.DefaultArea : discovery.Area;
|
|
||||||
|
|
||||||
// 1. Build the ordered, de-duplicated attribute reference set.
|
|
||||||
// Each entry carries the reference, the source-object reference, and the
|
|
||||||
// per-entry area used to compose the canonical reference. GR rows contribute
|
|
||||||
// the object's real Galaxy area; config includes contribute the config
|
|
||||||
// fallback area (Discovery.Area else DefaultArea).
|
|
||||||
List<(string Reference, string SourceObject, string Area)> ordered = [];
|
|
||||||
HashSet<string> seen = new(StringComparer.OrdinalIgnoreCase);
|
|
||||||
|
|
||||||
if (discovery.UseGalaxyRepository)
|
|
||||||
{
|
|
||||||
List<GalaxyAlarmAttributeRow> rows;
|
|
||||||
try
|
|
||||||
{
|
|
||||||
rows = await _repository.GetAlarmAttributesAsync(cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (Exception ex)
|
|
||||||
{
|
|
||||||
// Discovery being unavailable must not crash the resolver: log and
|
|
||||||
// continue with an empty discovery set. The caller decides what to
|
|
||||||
// do with the (possibly config-only) result.
|
|
||||||
_logger.LogWarning(
|
|
||||||
ex,
|
|
||||||
"Galaxy Repository alarm-attribute discovery failed; continuing with configuration-only watch-list.");
|
|
||||||
rows = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (GalaxyAlarmAttributeRow row in rows)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(row.FullTagReference) || !seen.Add(row.FullTagReference))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ordered.Add((row.FullTagReference, row.SourceObjectReference, row.Area));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (string include in discovery.IncludeAttributes)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrEmpty(include) || !seen.Add(include))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
ordered.Add((include, DeriveSourceObject(include), configFallbackArea));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove excluded references (case-insensitive), but only when GR discovery
|
|
||||||
// is active. ExcludeAttributes is documented as "Ignored when
|
|
||||||
// UseGalaxyRepository is false" (AlarmDiscoveryOptions.ExcludeAttributes).
|
|
||||||
// Whitespace-only entries are skipped, consistent with the include guard above.
|
|
||||||
if (discovery.UseGalaxyRepository)
|
|
||||||
{
|
|
||||||
HashSet<string> excluded = new(
|
|
||||||
discovery.ExcludeAttributes.Where(e => !string.IsNullOrWhiteSpace(e)),
|
|
||||||
StringComparer.OrdinalIgnoreCase);
|
|
||||||
if (excluded.Count > 0)
|
|
||||||
{
|
|
||||||
ordered.RemoveAll(e => excluded.Contains(e.Reference));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Resolve subtag names with safe fallbacks.
|
|
||||||
string active = string.IsNullOrEmpty(options.Fallback.Subtags.Active)
|
|
||||||
? DefaultActiveSubtag
|
|
||||||
: options.Fallback.Subtags.Active;
|
|
||||||
string acked = string.IsNullOrEmpty(options.Fallback.Subtags.Acked)
|
|
||||||
? DefaultAckedSubtag
|
|
||||||
: options.Fallback.Subtags.Acked;
|
|
||||||
string priority = options.Fallback.Subtags.Priority;
|
|
||||||
string ackComment = options.Fallback.Subtags.AckComment;
|
|
||||||
|
|
||||||
// 3. Compose one target per reference, using the PER-ENTRY area: the GR row's
|
|
||||||
// real Galaxy area (matching the alarmmgr group), or the config fallback for
|
|
||||||
// explicit includes.
|
|
||||||
List<AlarmSubtagTarget> targets = new(ordered.Count);
|
|
||||||
foreach ((string reference, string sourceObject, string area) in ordered)
|
|
||||||
{
|
|
||||||
targets.Add(new AlarmSubtagTarget
|
|
||||||
{
|
|
||||||
AlarmFullReference = ComposeFullReference(area, reference),
|
|
||||||
SourceObjectReference = sourceObject,
|
|
||||||
ActiveSubtag = $"{reference}.{active}",
|
|
||||||
AckedSubtag = $"{reference}.{acked}",
|
|
||||||
PrioritySubtag = string.IsNullOrEmpty(priority) ? string.Empty : $"{reference}.{priority}",
|
|
||||||
AckCommentSubtag = string.IsNullOrEmpty(ackComment) ? string.Empty : $"{reference}.{ackComment}",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Report the resolved count; warn when subtag mode was expected to cover
|
|
||||||
// something (GR enabled, or explicit includes were configured) but resolved
|
|
||||||
// to nothing. Only emit the Debug line when there is at least one target,
|
|
||||||
// to avoid a confusing "0 target(s)" noise line.
|
|
||||||
if (targets.Count == 0 && (discovery.UseGalaxyRepository || discovery.IncludeAttributes.Length > 0))
|
|
||||||
{
|
|
||||||
_logger.LogWarning(
|
|
||||||
"Alarm subtag watch-list resolved to zero targets; subtag-polling fallback will cover no alarms.");
|
|
||||||
}
|
|
||||||
else if (targets.Count > 0)
|
|
||||||
{
|
|
||||||
_logger.LogDebug("Resolved alarm subtag watch-list with {TargetCount} target(s).", targets.Count);
|
|
||||||
}
|
|
||||||
|
|
||||||
return targets;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Derives the source-object reference for a configuration entry: the
|
|
||||||
/// substring before the first '.', or the whole string when there is no dot.
|
|
||||||
/// </summary>
|
|
||||||
private static string DeriveSourceObject(string reference)
|
|
||||||
{
|
|
||||||
int dot = reference.IndexOf('.', StringComparison.Ordinal);
|
|
||||||
return dot < 0 ? reference : reference[..dot];
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Composes the canonical alarm full reference: <c>Galaxy!{area}.{reference}</c>
|
|
||||||
/// when an area is set, otherwise <c>Galaxy!{reference}</c>.
|
|
||||||
/// </summary>
|
|
||||||
private static string ComposeFullReference(string area, string reference) =>
|
|
||||||
string.IsNullOrEmpty(area)
|
|
||||||
? $"{ProviderLiteral}!{reference}"
|
|
||||||
: $"{ProviderLiteral}!{area}.{reference}";
|
|
||||||
}
|
|
||||||
@@ -13,7 +13,6 @@ public static class AlarmsServiceCollectionExtensions
|
|||||||
/// <returns>The service collection for chaining.</returns>
|
/// <returns>The service collection for chaining.</returns>
|
||||||
public static IServiceCollection AddGatewayAlarms(this IServiceCollection services)
|
public static IServiceCollection AddGatewayAlarms(this IServiceCollection services)
|
||||||
{
|
{
|
||||||
services.AddSingleton<IAlarmWatchListResolver, AlarmWatchListResolver>();
|
|
||||||
services.AddSingleton<GatewayAlarmMonitor>();
|
services.AddSingleton<GatewayAlarmMonitor>();
|
||||||
services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||||
services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
using System.Threading.Channels;
|
using System.Threading.Channels;
|
||||||
using Google.Protobuf.WellKnownTypes;
|
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||||
@@ -25,8 +23,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2);
|
private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2);
|
||||||
|
|
||||||
private readonly ISessionManager _sessionManager;
|
private readonly ISessionManager _sessionManager;
|
||||||
private readonly IAlarmWatchListResolver _watchListResolver;
|
|
||||||
private readonly GatewayMetrics _metrics;
|
|
||||||
private readonly AlarmsOptions _options;
|
private readonly AlarmsOptions _options;
|
||||||
private readonly ILogger<GatewayAlarmMonitor> _logger;
|
private readonly ILogger<GatewayAlarmMonitor> _logger;
|
||||||
|
|
||||||
@@ -34,34 +30,20 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
|
||||||
private readonly List<Subscriber> _subscribers = [];
|
private readonly List<Subscriber> _subscribers = [];
|
||||||
|
|
||||||
// Current provider status (mode + degraded + reason + since), guarded by _sync.
|
|
||||||
// Initialized to the alarm-manager, not-degraded baseline so a late joiner sees
|
|
||||||
// a sensible status even before any OnAlarmProviderModeChanged event arrives.
|
|
||||||
private AlarmProviderMode _providerMode = AlarmProviderMode.Alarmmgr;
|
|
||||||
private bool _providerDegraded;
|
|
||||||
private string _providerReason = string.Empty;
|
|
||||||
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
|
|
||||||
|
|
||||||
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
|
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
|
||||||
private volatile string? _lastError;
|
private volatile string? _lastError;
|
||||||
private GatewaySession? _session;
|
private GatewaySession? _session;
|
||||||
|
|
||||||
/// <summary>Initializes the gateway alarm monitor.</summary>
|
/// <summary>Initializes the gateway alarm monitor.</summary>
|
||||||
/// <param name="sessionManager">Gateway session manager.</param>
|
/// <param name="sessionManager">Gateway session manager.</param>
|
||||||
/// <param name="watchListResolver">Resolver for the subtag-fallback watch-list.</param>
|
|
||||||
/// <param name="metrics">Gateway metrics sink.</param>
|
|
||||||
/// <param name="options">Gateway options carrying the alarm configuration.</param>
|
/// <param name="options">Gateway options carrying the alarm configuration.</param>
|
||||||
/// <param name="logger">Diagnostic logger.</param>
|
/// <param name="logger">Diagnostic logger.</param>
|
||||||
public GatewayAlarmMonitor(
|
public GatewayAlarmMonitor(
|
||||||
ISessionManager sessionManager,
|
ISessionManager sessionManager,
|
||||||
IAlarmWatchListResolver watchListResolver,
|
|
||||||
GatewayMetrics metrics,
|
|
||||||
IOptions<GatewayOptions> options,
|
IOptions<GatewayOptions> options,
|
||||||
ILogger<GatewayAlarmMonitor> logger)
|
ILogger<GatewayAlarmMonitor> logger)
|
||||||
{
|
{
|
||||||
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||||
_watchListResolver = watchListResolver ?? throw new ArgumentNullException(nameof(watchListResolver));
|
|
||||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
|
||||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms;
|
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms;
|
||||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||||
}
|
}
|
||||||
@@ -157,49 +139,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken)
|
private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken)
|
||||||
{
|
{
|
||||||
_state = GatewayAlarmMonitorState.Starting;
|
_state = GatewayAlarmMonitorState.Starting;
|
||||||
|
|
||||||
// Derive the lifecycle baseline from the configured forced mode so a
|
|
||||||
// ForceSubtag / ForceAlarmManager start advertises the correct mode even
|
|
||||||
// though no OnAlarmProviderModeChanged event is raised in those modes
|
|
||||||
// (only Auto/failover produces that event). ForceSubtag starts degraded.
|
|
||||||
AlarmProviderMode initialMode;
|
|
||||||
bool initialDegraded;
|
|
||||||
string initialReason;
|
|
||||||
switch (MapForcedMode(_options.Fallback.Mode))
|
|
||||||
{
|
|
||||||
case AlarmProviderMode.Subtag:
|
|
||||||
initialMode = AlarmProviderMode.Subtag;
|
|
||||||
initialDegraded = true;
|
|
||||||
initialReason = AlarmProviderReasons.ForcedSubtag;
|
|
||||||
break;
|
|
||||||
case AlarmProviderMode.Alarmmgr:
|
|
||||||
initialMode = AlarmProviderMode.Alarmmgr;
|
|
||||||
initialDegraded = false;
|
|
||||||
initialReason = string.Empty;
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
// Unspecified (Auto): the failover consumer starts on the
|
|
||||||
// alarm-manager primary and only degrades to subtag on failure.
|
|
||||||
initialMode = AlarmProviderMode.Alarmmgr;
|
|
||||||
initialDegraded = false;
|
|
||||||
initialReason = string.Empty;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
// Re-baseline the provider status for this lifecycle so a restarted
|
|
||||||
// monitor advertises the configured mode until told otherwise.
|
|
||||||
_providerMode = initialMode;
|
|
||||||
_providerDegraded = initialDegraded;
|
|
||||||
_providerReason = initialReason;
|
|
||||||
_providerSince = DateTimeOffset.UtcNow;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Align the observable gauge with the lifecycle baseline without recording
|
|
||||||
// a switch — the gauge was 0 (unknown) from construction until now.
|
|
||||||
_metrics.SetAlarmProviderMode(ModeToInt(initialMode));
|
|
||||||
|
|
||||||
GatewaySession session = await _sessionManager.OpenSessionAsync(
|
GatewaySession session = await _sessionManager.OpenSessionAsync(
|
||||||
new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
|
new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
|
||||||
MonitorClientName,
|
MonitorClientName,
|
||||||
@@ -234,15 +173,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
{
|
{
|
||||||
ApplyTransition(mxEvent.OnAlarmTransition);
|
ApplyTransition(mxEvent.OnAlarmTransition);
|
||||||
}
|
}
|
||||||
else if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmProviderModeChanged }
|
|
||||||
&& mxEvent.OnAlarmProviderModeChanged is not null)
|
|
||||||
{
|
|
||||||
await ApplyProviderModeChangeAsync(
|
|
||||||
session.SessionId,
|
|
||||||
mxEvent.OnAlarmProviderModeChanged,
|
|
||||||
linked.Token)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
finally
|
finally
|
||||||
@@ -279,33 +209,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
|
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
IReadOnlyList<AlarmSubtagTarget> watchList = await _watchListResolver
|
|
||||||
.ResolveAsync(_options, cancellationToken)
|
|
||||||
.ConfigureAwait(false);
|
|
||||||
|
|
||||||
AlarmProviderMode forcedMode = MapForcedMode(_options.Fallback.Mode);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Alarm subscribe: forcedMode={ForcedMode} configMode={ConfigMode} watchList={WatchListCount}.",
|
|
||||||
forcedMode, _options.Fallback.Mode, watchList.Count);
|
|
||||||
|
|
||||||
// When the forced mode is Unspecified (the "Auto" case) and the resolved
|
|
||||||
// watch-list is empty — the common alarmmgr-only deployment — the command
|
|
||||||
// is identical-in-effect to the historical SubscribeAlarms (wnwrap only):
|
|
||||||
// the worker builds the wnwrap consumer and no subtag watch-list.
|
|
||||||
SubscribeAlarmsCommand command = new()
|
|
||||||
{
|
|
||||||
SubscriptionExpression = subscription,
|
|
||||||
ForcedMode = forcedMode,
|
|
||||||
Failover = new AlarmFailoverConfig
|
|
||||||
{
|
|
||||||
ConsecutiveFailureThreshold = _options.Fallback.ConsecutiveFailureThreshold,
|
|
||||||
FailbackProbeIntervalSeconds = _options.Fallback.FailbackProbeIntervalSeconds,
|
|
||||||
FailbackStableProbes = _options.Fallback.FailbackStableProbes,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
command.WatchList.AddRange(watchList);
|
|
||||||
|
|
||||||
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
||||||
sessionId,
|
sessionId,
|
||||||
new WorkerCommand
|
new WorkerCommand
|
||||||
@@ -313,7 +216,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Command = new MxCommand
|
Command = new MxCommand
|
||||||
{
|
{
|
||||||
Kind = MxCommandKind.SubscribeAlarms,
|
Kind = MxCommandKind.SubscribeAlarms,
|
||||||
SubscribeAlarms = command,
|
SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = subscription },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
cancellationToken)
|
cancellationToken)
|
||||||
@@ -407,104 +310,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handles the worker's provider-mode-change event: updates the stored provider
|
|
||||||
// status, broadcasts it to every subscriber (provider status is global, not
|
|
||||||
// alarm-scoped), records the switch metric, and forces a cache reconcile so the
|
|
||||||
// active-alarm set reflects whatever the new mode reports.
|
|
||||||
private async Task ApplyProviderModeChangeAsync(
|
|
||||||
string sessionId,
|
|
||||||
OnAlarmProviderModeChangedEvent change,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
AlarmProviderMode toMode = change.Mode;
|
|
||||||
string reason = change.Reason ?? string.Empty;
|
|
||||||
|
|
||||||
AlarmProviderStatus status;
|
|
||||||
int fromModeInt;
|
|
||||||
lock (_sync)
|
|
||||||
{
|
|
||||||
fromModeInt = ModeToInt(_providerMode);
|
|
||||||
_providerMode = toMode;
|
|
||||||
_providerDegraded = toMode == AlarmProviderMode.Subtag;
|
|
||||||
_providerReason = reason;
|
|
||||||
_providerSince = DateTimeOffset.UtcNow;
|
|
||||||
status = BuildProviderStatus();
|
|
||||||
BroadcastToAll(new AlarmFeedMessage { ProviderStatus = status });
|
|
||||||
}
|
|
||||||
|
|
||||||
AlarmProviderSwitchReason switchReason = toMode switch
|
|
||||||
{
|
|
||||||
AlarmProviderMode.Subtag => AlarmProviderSwitchReason.Failover,
|
|
||||||
AlarmProviderMode.Alarmmgr => AlarmProviderSwitchReason.Failback,
|
|
||||||
_ => AlarmProviderSwitchReason.Unknown,
|
|
||||||
};
|
|
||||||
_metrics.AlarmProviderSwitched(fromModeInt, ModeToInt(toMode), switchReason);
|
|
||||||
|
|
||||||
_logger.LogInformation(
|
|
||||||
"Alarm provider mode changed to {Mode} (degraded={Degraded}): {Reason}",
|
|
||||||
toMode,
|
|
||||||
status.Degraded,
|
|
||||||
reason);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
// Intentionally awaited OUTSIDE _sync: ReconcileAsync acquires _sync itself,
|
|
||||||
// so holding it across the await here would deadlock. Subscribers therefore
|
|
||||||
// see the ProviderStatus push (above) slightly before the cache is re-seeded
|
|
||||||
// by the reconcile — an accepted brief inconsistency.
|
|
||||||
await ReconcileAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch (OperationCanceledException)
|
|
||||||
{
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
_logger.LogDebug(
|
|
||||||
exception,
|
|
||||||
"Reconcile after alarm provider mode change failed; keeping the current cache.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Caller holds _sync. Builds an AlarmProviderStatus snapshot of the current state.
|
|
||||||
private AlarmProviderStatus BuildProviderStatus()
|
|
||||||
{
|
|
||||||
return new AlarmProviderStatus
|
|
||||||
{
|
|
||||||
Mode = _providerMode,
|
|
||||||
Degraded = _providerDegraded,
|
|
||||||
Reason = _providerReason,
|
|
||||||
Since = Timestamp.FromDateTimeOffset(_providerSince),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps the configured fallback mode string to the forced provider mode the
|
|
||||||
// worker honours. Case-insensitive; anything other than the two force values
|
|
||||||
// (including the default "Auto") yields Unspecified ("let the worker decide").
|
|
||||||
private static AlarmProviderMode MapForcedMode(string? mode)
|
|
||||||
{
|
|
||||||
if (string.Equals(mode, "ForceAlarmManager", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return AlarmProviderMode.Alarmmgr;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
return AlarmProviderMode.Subtag;
|
|
||||||
}
|
|
||||||
|
|
||||||
return AlarmProviderMode.Unspecified;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Maps the provider-mode enum to the integer the metric expects
|
|
||||||
// (alarmmgr=1, subtag=2, unknown/unspecified=0).
|
|
||||||
private static int ModeToInt(AlarmProviderMode mode) => mode switch
|
|
||||||
{
|
|
||||||
AlarmProviderMode.Alarmmgr => 1,
|
|
||||||
AlarmProviderMode.Subtag => 2,
|
|
||||||
_ => 0,
|
|
||||||
};
|
|
||||||
|
|
||||||
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||||
// a synthetic transition for any alarm the live stream missed.
|
// a synthetic transition for any alarm the live stream missed.
|
||||||
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
||||||
@@ -569,23 +374,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Caller holds _sync. Pushes a feed message to every subscriber regardless of
|
|
||||||
// its alarm-filter prefix. Used for provider-status messages, which are global
|
|
||||||
// rather than scoped to a single alarm reference.
|
|
||||||
private void BroadcastToAll(AlarmFeedMessage message)
|
|
||||||
{
|
|
||||||
for (int index = _subscribers.Count - 1; index >= 0; index--)
|
|
||||||
{
|
|
||||||
Subscriber subscriber = _subscribers[index];
|
|
||||||
if (!subscriber.Channel.Writer.TryWrite(message))
|
|
||||||
{
|
|
||||||
subscriber.Channel.Writer.TryComplete(new InvalidOperationException(
|
|
||||||
"Alarm feed subscriber fell behind and was dropped; reconnect to re-snapshot."));
|
|
||||||
_subscribers.RemoveAt(index);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void ClearCache()
|
private void ClearCache()
|
||||||
{
|
{
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
@@ -610,14 +398,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Subscriber subscriber = new(channel, prefix);
|
Subscriber subscriber = new(channel, prefix);
|
||||||
|
|
||||||
ActiveAlarmSnapshot[] snapshot;
|
ActiveAlarmSnapshot[] snapshot;
|
||||||
AlarmProviderStatus providerStatus;
|
|
||||||
lock (_sync)
|
lock (_sync)
|
||||||
{
|
{
|
||||||
// Register before snapshotting under the same lock so neither a
|
// Register before snapshotting under the same lock so no transition
|
||||||
// transition nor a provider-mode change can slip between the snapshot
|
// can slip between the snapshot and the live stream.
|
||||||
// and the live stream.
|
|
||||||
_subscribers.Add(subscriber);
|
_subscribers.Add(subscriber);
|
||||||
providerStatus = BuildProviderStatus();
|
|
||||||
snapshot = _alarms.Values
|
snapshot = _alarms.Values
|
||||||
.Where(alarm => prefix.Length == 0
|
.Where(alarm => prefix.Length == 0
|
||||||
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
|
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
|
||||||
@@ -627,10 +412,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
// Emit the current provider status first so a late joiner immediately
|
|
||||||
// learns the mode (and whether the feed is degraded) before any alarms.
|
|
||||||
yield return new AlarmFeedMessage { ProviderStatus = providerStatus };
|
|
||||||
|
|
||||||
foreach (ActiveAlarmSnapshot alarm in snapshot)
|
foreach (ActiveAlarmSnapshot alarm in snapshot)
|
||||||
{
|
{
|
||||||
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
|
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
|
||||||
@@ -843,8 +624,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Description = transition.Description,
|
Description = transition.Description,
|
||||||
OperatorUser = transition.OperatorUser,
|
OperatorUser = transition.OperatorUser,
|
||||||
OperatorComment = transition.OperatorComment,
|
OperatorComment = transition.OperatorComment,
|
||||||
Degraded = transition.Degraded,
|
|
||||||
SourceProvider = transition.SourceProvider,
|
|
||||||
};
|
};
|
||||||
if (transition.OriginalRaiseTimestamp is not null)
|
if (transition.OriginalRaiseTimestamp is not null)
|
||||||
{
|
{
|
||||||
@@ -881,8 +660,6 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
Description = snapshot.Description,
|
Description = snapshot.Description,
|
||||||
OperatorUser = snapshot.OperatorUser,
|
OperatorUser = snapshot.OperatorUser,
|
||||||
OperatorComment = snapshot.OperatorComment,
|
OperatorComment = snapshot.OperatorComment,
|
||||||
Degraded = snapshot.Degraded,
|
|
||||||
SourceProvider = snapshot.SourceProvider,
|
|
||||||
};
|
};
|
||||||
if (snapshot.OriginalRaiseTimestamp is not null)
|
if (snapshot.OriginalRaiseTimestamp is not null)
|
||||||
{
|
{
|
||||||
@@ -911,6 +688,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
|||||||
|
|
||||||
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
|
/// <summary>Determines whether the alarm reference matches this subscriber's filter.</summary>
|
||||||
/// <param name="reference">The alarm reference to match.</param>
|
/// <param name="reference">The alarm reference to match.</param>
|
||||||
|
/// <returns>True if the reference starts with this subscriber's prefix or no prefix is set.</returns>
|
||||||
public bool Matches(string reference)
|
public bool Matches(string reference)
|
||||||
{
|
{
|
||||||
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
|
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
|
||||||
|
|||||||
@@ -1,30 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Resolves the subtag watch-list the gateway sends to the worker when the
|
|
||||||
/// central alarm monitor operates in subtag-polling fallback mode. Merges
|
|
||||||
/// Galaxy Repository alarm-attribute discovery with the configured
|
|
||||||
/// include/exclude overrides and composes the per-attribute subtag item
|
|
||||||
/// addresses from the configured subtag names.
|
|
||||||
/// </summary>
|
|
||||||
public interface IAlarmWatchListResolver
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Builds the subtag watch-list for the supplied alarm configuration.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="options">Alarm configuration carrying discovery and subtag-name settings.</param>
|
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>
|
|
||||||
/// The resolved <see cref="AlarmSubtagTarget"/> watch-list, possibly empty.
|
|
||||||
/// Discovery being unavailable never throws — it yields an empty (or
|
|
||||||
/// config-only) list and the caller decides what to do with it. Cancellation
|
|
||||||
/// is the one exception: a triggered <paramref name="cancellationToken"/>
|
|
||||||
/// still propagates an <see cref="OperationCanceledException"/>.
|
|
||||||
/// </returns>
|
|
||||||
Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
|
|
||||||
AlarmsOptions options,
|
|
||||||
CancellationToken cancellationToken = default);
|
|
||||||
}
|
|
||||||
@@ -46,6 +46,7 @@ public interface IGatewayAlarmService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
|
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
|
||||||
/// <param name="cancellationToken">Token that ends the subscription.</param>
|
/// <param name="cancellationToken">Token that ends the subscription.</param>
|
||||||
|
/// <returns>An async enumerable of alarm feed messages.</returns>
|
||||||
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||||
string? alarmFilterPrefix,
|
string? alarmFilterPrefix,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
@@ -57,6 +58,7 @@ public interface IGatewayAlarmService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">The acknowledge request.</param>
|
/// <param name="request">The acknowledge request.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the call.</param>
|
/// <param name="cancellationToken">Token to cancel the call.</param>
|
||||||
|
/// <returns>A task that resolves to the acknowledge reply.</returns>
|
||||||
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -1,131 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controls how the central alarm monitor selects between the MXAccess
|
|
||||||
/// alarm-manager subscription and the subtag-polling fallback, and
|
|
||||||
/// governs the failure-detection thresholds used when switching.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AlarmFallbackOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Selects the operating mode for the alarm-manager ↔ subtag fallback
|
|
||||||
/// mechanism. Accepted values (case-insensitive):
|
|
||||||
/// <list type="bullet">
|
|
||||||
/// <item><c>Auto</c> — use the alarm manager; switch to subtag polling
|
|
||||||
/// automatically when <see cref="ConsecutiveFailureThreshold"/> failures
|
|
||||||
/// are detected, and probe for failback.</item>
|
|
||||||
/// <item><c>ForceAlarmManager</c> — always use the alarm manager;
|
|
||||||
/// never fall back.</item>
|
|
||||||
/// <item><c>ForceSubtag</c> — always use subtag polling;
|
|
||||||
/// never try the alarm manager.</item>
|
|
||||||
/// </list>
|
|
||||||
/// Default is <c>Auto</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Mode { get; init; } = "Auto";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of consecutive alarm-manager failures before the monitor
|
|
||||||
/// switches to subtag-polling fallback. Must be at least 1. Default 3.
|
|
||||||
/// </summary>
|
|
||||||
public int ConsecutiveFailureThreshold { get; init; } = 3;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// How often (in seconds) the monitor sends a probe to the alarm manager
|
|
||||||
/// while operating in subtag-polling fallback mode, to detect recovery.
|
|
||||||
/// Must be at least 1. Default 30.
|
|
||||||
/// </summary>
|
|
||||||
public int FailbackProbeIntervalSeconds { get; init; } = 30;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Number of consecutive successful probes required before the monitor
|
|
||||||
/// considers the alarm manager recovered and switches back. Must be at
|
|
||||||
/// least 1. Default 3.
|
|
||||||
/// </summary>
|
|
||||||
public int FailbackStableProbes { get; init; } = 3;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Controls how the monitor discovers the set of objects to poll when
|
|
||||||
/// operating in subtag-polling fallback mode.
|
|
||||||
/// </summary>
|
|
||||||
public AlarmDiscoveryOptions Discovery { get; init; } = new();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configures the subtag names the monitor reads when polling alarm state
|
|
||||||
/// in subtag-fallback mode.
|
|
||||||
/// </summary>
|
|
||||||
public AlarmSubtagNameOptions Subtags { get; init; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Governs how the alarm monitor discovers objects to include in subtag-polling
|
|
||||||
/// fallback mode. Either the Galaxy Repository query (when
|
|
||||||
/// <see cref="UseGalaxyRepository"/> is <c>true</c>) or an explicit
|
|
||||||
/// <see cref="IncludeAttributes"/> list must be supplied when
|
|
||||||
/// <c>MxGateway:Alarms:Fallback:Mode</c> is <c>ForceSubtag</c>.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AlarmDiscoveryOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// When <c>true</c> the monitor queries the Galaxy Repository SQL database
|
|
||||||
/// to enumerate alarm objects for the configured area. Default <c>true</c>.
|
|
||||||
/// </summary>
|
|
||||||
public bool UseGalaxyRepository { get; init; } = true;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Galaxy area to scope the Repository query to. When empty the monitor
|
|
||||||
/// falls back to <see cref="AlarmsOptions.DefaultArea"/>. Ignored when
|
|
||||||
/// <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Area { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Explicit list of MXAccess attribute paths to include in subtag polling,
|
|
||||||
/// supplementing (or replacing, when <see cref="UseGalaxyRepository"/> is
|
|
||||||
/// <c>false</c>) the Repository-derived list. Default empty.
|
|
||||||
/// </summary>
|
|
||||||
public string[] IncludeAttributes { get; init; } = Array.Empty<string>();
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Attribute paths to exclude from the Repository-derived poll list.
|
|
||||||
/// Ignored when <see cref="UseGalaxyRepository"/> is <c>false</c>.
|
|
||||||
/// Default empty.
|
|
||||||
/// </summary>
|
|
||||||
public string[] ExcludeAttributes { get; init; } = Array.Empty<string>();
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configures the subtag names read by the alarm monitor when it is operating
|
|
||||||
/// in subtag-polling fallback mode. Names are matched against MXAccess item
|
|
||||||
/// handles; validation against the live MXAccess attribute list occurs at
|
|
||||||
/// runtime, not at startup.
|
|
||||||
/// Defaults are the confirmed AVEVA <c>AlarmExtension</c> primitive field names,
|
|
||||||
/// verified against the live ZB Galaxy <c>attribute_definition</c> rows.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class AlarmSubtagNameOptions
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Subtag name for the in-alarm boolean. Confirmed AVEVA <c>AlarmExtension</c>
|
|
||||||
/// field name. Default <c>InAlarm</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Active { get; init; } = "InAlarm";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Subtag name for the acknowledged boolean. Confirmed AVEVA <c>AlarmExtension</c>
|
|
||||||
/// field name. Default <c>Acked</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Acked { get; init; } = "Acked";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Subtag name for the acknowledgement comment write target. Writing this subtag
|
|
||||||
/// performs the acknowledge in AVEVA. Confirmed AVEVA <c>AlarmExtension</c>
|
|
||||||
/// field name. When empty the ack-comment write path is disabled.
|
|
||||||
/// Default <c>AckMsg</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string AckComment { get; init; } = "AckMsg";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Subtag name for the alarm priority / severity. Confirmed AVEVA
|
|
||||||
/// <c>AlarmExtension</c> field name. Default <c>Priority</c>.
|
|
||||||
/// </summary>
|
|
||||||
public string Priority { get; init; } = "Priority";
|
|
||||||
}
|
|
||||||
@@ -45,12 +45,4 @@ public sealed class AlarmsOptions
|
|||||||
/// the monitor floors it at 5 seconds.
|
/// the monitor floors it at 5 seconds.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public int ReconcileIntervalSeconds { get; init; } = 30;
|
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Configuration for the alarm-manager ↔ subtag fallback mechanism:
|
|
||||||
/// operating mode, failure-detection thresholds, discovery, and subtag
|
|
||||||
/// names. Defaults (Mode = "Auto") preserve behaviour when the section is
|
|
||||||
/// omitted from configuration.
|
|
||||||
/// </summary>
|
|
||||||
public AlarmFallbackOptions Fallback { get; init; } = new();
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,6 @@ public sealed class DashboardOptions
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public bool RequireHttpsCookie { get; init; } = true;
|
public bool RequireHttpsCookie { get; init; } = true;
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard auth cookie name. When null/blank (the default) the canonical
|
|
||||||
/// <see cref="ZB.MOM.WW.MxGateway.Server.Dashboard.DashboardAuthenticationDefaults.CookieName"/>
|
|
||||||
/// is used. Override it (<c>MxGateway:Dashboard:CookieName</c>) to give a distinct name to a
|
|
||||||
/// gateway that shares a hostname with another gateway instance — browser cookies are scoped
|
|
||||||
/// by host+path but NOT by port, so two instances on the same host would otherwise clobber
|
|
||||||
/// each other's dashboard session under a shared cookie name. Changing this signs out
|
|
||||||
/// existing dashboard sessions on next deploy.
|
|
||||||
/// </summary>
|
|
||||||
public string? CookieName { get; init; }
|
|
||||||
|
|
||||||
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
||||||
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
||||||
|
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
private const int MinimumMaxMessageBytes = 1024;
|
private const int MinimumMaxMessageBytes = 1024;
|
||||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Validates gateway configuration options.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="builder">The accumulator to record failures on.</param>
|
|
||||||
/// <param name="options">Gateway options to validate.</param>
|
|
||||||
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
protected override void Validate(ValidationBuilder builder, GatewayOptions options)
|
||||||
{
|
{
|
||||||
ValidateAuthentication(options.Authentication, builder);
|
ValidateAuthentication(options.Authentication, builder);
|
||||||
@@ -231,8 +227,6 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
builder);
|
builder);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static readonly string[] ValidAlarmFallbackModes = ["Auto", "ForceAlarmManager", "ForceSubtag"];
|
|
||||||
|
|
||||||
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
private static void ValidateAlarms(AlarmsOptions options, ValidationBuilder builder)
|
||||||
{
|
{
|
||||||
if (!options.Enabled)
|
if (!options.Enabled)
|
||||||
@@ -257,46 +251,6 @@ public sealed class GatewayOptionsValidator : OptionsValidatorBase<GatewayOption
|
|||||||
builder.Add(
|
builder.Add(
|
||||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||||
}
|
}
|
||||||
|
|
||||||
ValidateAlarmFallback(options.Fallback, builder);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateAlarmFallback(AlarmFallbackOptions fallback, ValidationBuilder builder)
|
|
||||||
{
|
|
||||||
// Validate Mode is one of the recognised values (case-insensitive).
|
|
||||||
bool modeValid = Array.Exists(
|
|
||||||
ValidAlarmFallbackModes,
|
|
||||||
m => string.Equals(m, fallback.Mode, StringComparison.OrdinalIgnoreCase));
|
|
||||||
|
|
||||||
if (!modeValid)
|
|
||||||
{
|
|
||||||
builder.Add(
|
|
||||||
$"MxGateway:Alarms:Fallback:Mode must be one of: {string.Join(", ", ValidAlarmFallbackModes)} (was '{fallback.Mode}').");
|
|
||||||
}
|
|
||||||
|
|
||||||
// ForceSubtag requires either Galaxy Repository discovery or an explicit IncludeAttributes list.
|
|
||||||
if (modeValid
|
|
||||||
&& string.Equals(fallback.Mode, "ForceSubtag", StringComparison.OrdinalIgnoreCase)
|
|
||||||
&& !fallback.Discovery.UseGalaxyRepository
|
|
||||||
&& fallback.Discovery.IncludeAttributes.Length == 0)
|
|
||||||
{
|
|
||||||
builder.Add(
|
|
||||||
"MxGateway:Alarms:Fallback ForceSubtag requires Galaxy Repository discovery or a non-empty Discovery:IncludeAttributes list.");
|
|
||||||
}
|
|
||||||
|
|
||||||
// Floor validation: numeric thresholds must be at least 1.
|
|
||||||
AddIfNotPositive(
|
|
||||||
fallback.ConsecutiveFailureThreshold,
|
|
||||||
"MxGateway:Alarms:Fallback:ConsecutiveFailureThreshold must be greater than zero.",
|
|
||||||
builder);
|
|
||||||
AddIfNotPositive(
|
|
||||||
fallback.FailbackProbeIntervalSeconds,
|
|
||||||
"MxGateway:Alarms:Fallback:FailbackProbeIntervalSeconds must be greater than zero.",
|
|
||||||
builder);
|
|
||||||
AddIfNotPositive(
|
|
||||||
fallback.FailbackStableProbes,
|
|
||||||
"MxGateway:Alarms:Fallback:FailbackStableProbes must be greater than zero.",
|
|
||||||
builder);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const int MinimumCertValidityYears = 1;
|
private const int MinimumCertValidityYears = 1;
|
||||||
|
|||||||
@@ -8,5 +8,6 @@ public interface IGatewayConfigurationProvider
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns the validated and effective gateway configuration.
|
/// Returns the validated and effective gateway configuration.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The <see cref="EffectiveGatewayConfiguration"/> with validated defaults applied.</returns>
|
||||||
EffectiveGatewayConfiguration GetEffectiveConfiguration();
|
EffectiveGatewayConfiguration GetEffectiveConfiguration();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
|||||||
await ConnectHubAsync().ConfigureAwait(false);
|
await ConnectHubAsync().ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Disposes the SignalR hub connection and suppresses finalization.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_hub is not null)
|
if (_hub is not null)
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
@page "/alarms"
|
@page "/alarms"
|
||||||
@implements IAsyncDisposable
|
@implements IAsyncDisposable
|
||||||
@using Microsoft.AspNetCore.SignalR.Client
|
|
||||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs
|
|
||||||
@inject IDashboardLiveDataService LiveData
|
@inject IDashboardLiveDataService LiveData
|
||||||
@inject IOptions<GatewayOptions> GatewayOptions
|
@inject IOptions<GatewayOptions> GatewayOptions
|
||||||
@inject DashboardHubConnectionFactory HubFactory
|
|
||||||
|
|
||||||
<PageTitle>Dashboard Alarms</PageTitle>
|
<PageTitle>Dashboard Alarms</PageTitle>
|
||||||
|
|
||||||
@@ -13,12 +10,6 @@
|
|||||||
<h1>Alarms</h1>
|
<h1>Alarms</h1>
|
||||||
<div class="text-secondary">@HeaderLine()</div>
|
<div class="text-secondary">@HeaderLine()</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<span class="badge @_providerStatus.BadgeCssClass"
|
|
||||||
title="@ProviderStatusTitle()">
|
|
||||||
@_providerStatus.Label
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!GatewayOptions.Value.Alarms.Enabled)
|
@if (!GatewayOptions.Value.Alarms.Enabled)
|
||||||
@@ -172,44 +163,10 @@
|
|||||||
private readonly CancellationTokenSource _cts = new();
|
private readonly CancellationTokenSource _cts = new();
|
||||||
private Task? _pollTask;
|
private Task? _pollTask;
|
||||||
|
|
||||||
private DashboardAlarmProviderStatus _providerStatus = DashboardAlarmProviderStatus.Healthy;
|
|
||||||
private HubConnection? _alarmsHub;
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
protected override void OnInitialized()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
_pollTask = PollLoopAsync();
|
_pollTask = PollLoopAsync();
|
||||||
_ = AttachAlarmsHubAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
private string? ProviderStatusTitle()
|
|
||||||
{
|
|
||||||
return _providerStatus.IsDegraded && !string.IsNullOrWhiteSpace(_providerStatus.Reason)
|
|
||||||
? _providerStatus.Reason
|
|
||||||
: null;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task AttachAlarmsHubAsync()
|
|
||||||
{
|
|
||||||
_alarmsHub = HubFactory.Create("/hubs/alarms");
|
|
||||||
_alarmsHub.On<AlarmFeedMessage>(AlarmsHub.AlarmMessage, async message =>
|
|
||||||
{
|
|
||||||
if (message.PayloadCase == AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
|
|
||||||
{
|
|
||||||
_providerStatus = DashboardAlarmProviderStatus.FromFeed(message);
|
|
||||||
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _alarmsHub.StartAsync(_cts.Token).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// The badge is best-effort; it stays at the healthy default until
|
|
||||||
// the hub reconnects and delivers a fresh provider-status message.
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private string HeaderLine()
|
private string HeaderLine()
|
||||||
@@ -311,19 +268,6 @@
|
|||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await _cts.CancelAsync();
|
await _cts.CancelAsync();
|
||||||
|
|
||||||
if (_alarmsHub is not null)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await _alarmsHub.DisposeAsync();
|
|
||||||
}
|
|
||||||
catch
|
|
||||||
{
|
|
||||||
// Disposal-time errors are best-effort.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (_pollTask is not null)
|
if (_pollTask is not null)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -6,19 +6,13 @@
|
|||||||
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
cookie scheme's LoginPath="/login" redirect lands here for unauthenticated users.
|
||||||
|
|
||||||
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
The card is the shared kit's <LoginCard>: it renders a NATIVE static
|
||||||
<form method="post" action="/auth/login"> (username/password + hidden returnUrl). A native
|
<form method="post" action="/login"> (username/password + hidden returnUrl). A native
|
||||||
form submit is not a Blazor event, so it reaches the minimal-API POST /auth/login endpoint
|
form submit is not a Blazor event, so it reaches the minimal-API POST /login endpoint
|
||||||
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
regardless of this app's InteractiveServer render mode. <AntiforgeryToken/> supplies the
|
||||||
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks.
|
token that PostLoginAsync's antiforgery.ValidateRequestAsync checks. *@
|
||||||
|
|
||||||
NOTE: the POST target is /auth/login, NOT /login. This @page lives at "/login" and the
|
|
||||||
Razor Components endpoint matches ALL methods, so a POST to /login collided with the
|
|
||||||
minimal-API MapPost("/login") and threw AmbiguousMatchException (HTTP 500). Posting to a
|
|
||||||
distinct /auth/login path (mirroring ScadaBridge) keeps the GET page and POST handler from
|
|
||||||
sharing a route. *@
|
|
||||||
@attribute [AllowAnonymous]
|
@attribute [AllowAnonymous]
|
||||||
|
|
||||||
<LoginCard Product="MXAccess Gateway" Action="/auth/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
<LoginCard Product="MXAccess Gateway" Action="/login" ReturnUrl="@ReturnUrl" Error="@Error">
|
||||||
<AntiforgeryToken />
|
<AntiforgeryToken />
|
||||||
</LoginCard>
|
</LoginCard>
|
||||||
|
|
||||||
|
|||||||
@@ -1,101 +0,0 @@
|
|||||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
||||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Dashboard projection of an <see cref="AlarmProviderStatus" /> message
|
|
||||||
/// carried on the alarm feed. Maps the protobuf provider mode / degraded
|
|
||||||
/// flag into Bootstrap-only display fields so the Alarms page can render a
|
|
||||||
/// status badge without touching protobuf types.
|
|
||||||
/// </summary>
|
|
||||||
public sealed record DashboardAlarmProviderStatus(
|
|
||||||
AlarmProviderMode Mode,
|
|
||||||
bool IsDegraded,
|
|
||||||
string Label,
|
|
||||||
string BadgeCssClass,
|
|
||||||
string Reason,
|
|
||||||
DateTimeOffset? SinceUtc)
|
|
||||||
{
|
|
||||||
/// <summary>Badge label shown when the alarm-manager provider is healthy.</summary>
|
|
||||||
public const string AlarmManagerLabel = "Alarm Manager";
|
|
||||||
|
|
||||||
/// <summary>Badge label shown when the feed has fallen back to subtag monitoring.</summary>
|
|
||||||
public const string DegradedLabel = "Subtag monitoring (degraded)";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Badge label shown when the feed is in subtag monitoring because it was
|
|
||||||
/// deliberately configured (<c>Fallback:Mode=ForceSubtag</c>), as opposed
|
|
||||||
/// to an unexpected failover. A stable, intended state rather than a fault.
|
|
||||||
/// </summary>
|
|
||||||
public const string ForcedSubtagLabel = "Subtag monitoring (forced)";
|
|
||||||
|
|
||||||
private const string HealthyBadge = "bg-success";
|
|
||||||
private const string DegradedBadge = "bg-warning text-dark";
|
|
||||||
|
|
||||||
// Cyan/info badge: visually distinct from the amber failover-degraded badge —
|
|
||||||
// forced subtag is an intentional configuration, not an alarm-manager fault.
|
|
||||||
private const string ForcedSubtagBadge = "bg-info text-dark";
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// The default status assumed before the first provider-status message
|
|
||||||
/// arrives: healthy alarm-manager mode.
|
|
||||||
/// </summary>
|
|
||||||
public static DashboardAlarmProviderStatus Healthy { get; } = new(
|
|
||||||
Mode: AlarmProviderMode.Alarmmgr,
|
|
||||||
IsDegraded: false,
|
|
||||||
Label: AlarmManagerLabel,
|
|
||||||
BadgeCssClass: HealthyBadge,
|
|
||||||
Reason: string.Empty,
|
|
||||||
SinceUtc: null);
|
|
||||||
|
|
||||||
/// <summary>Projects an alarm-feed provider-status payload into a dashboard badge model.</summary>
|
|
||||||
/// <param name="status">The provider-status payload from an <see cref="AlarmFeedMessage" />.</param>
|
|
||||||
/// <returns>The projected dashboard status.</returns>
|
|
||||||
public static DashboardAlarmProviderStatus FromProviderStatus(AlarmProviderStatus status)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(status);
|
|
||||||
|
|
||||||
// Treat the explicit degraded flag and the SUBTAG mode as equivalent;
|
|
||||||
// the contract sets degraded=true whenever mode == SUBTAG, but guard
|
|
||||||
// against either being set independently.
|
|
||||||
bool degraded = status.Degraded || status.Mode == AlarmProviderMode.Subtag;
|
|
||||||
string reason = status.Reason ?? string.Empty;
|
|
||||||
|
|
||||||
// A configured ForceSubtag start carries the well-known forced reason and
|
|
||||||
// is a deliberate mode, not a failover — render it distinctly so an
|
|
||||||
// operator isn't alarmed by a "(degraded)" badge for an intended config.
|
|
||||||
bool forced = degraded
|
|
||||||
&& status.Mode == AlarmProviderMode.Subtag
|
|
||||||
&& string.Equals(reason, AlarmProviderReasons.ForcedSubtag, StringComparison.Ordinal);
|
|
||||||
|
|
||||||
string label = !degraded ? AlarmManagerLabel : forced ? ForcedSubtagLabel : DegradedLabel;
|
|
||||||
string badge = !degraded ? HealthyBadge : forced ? ForcedSubtagBadge : DegradedBadge;
|
|
||||||
|
|
||||||
return new DashboardAlarmProviderStatus(
|
|
||||||
Mode: status.Mode,
|
|
||||||
IsDegraded: degraded,
|
|
||||||
Label: label,
|
|
||||||
BadgeCssClass: badge,
|
|
||||||
Reason: reason,
|
|
||||||
SinceUtc: status.Since?.ToDateTimeOffset());
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Projects an alarm-feed message into a dashboard badge model.</summary>
|
|
||||||
/// <param name="message">An alarm-feed message whose payload is a provider status.</param>
|
|
||||||
/// <returns>The projected dashboard status.</returns>
|
|
||||||
/// <exception cref="ArgumentException">The message does not carry a provider-status payload.</exception>
|
|
||||||
public static DashboardAlarmProviderStatus FromFeed(AlarmFeedMessage message)
|
|
||||||
{
|
|
||||||
ArgumentNullException.ThrowIfNull(message);
|
|
||||||
|
|
||||||
if (message.PayloadCase != AlarmFeedMessage.PayloadOneofCase.ProviderStatus)
|
|
||||||
{
|
|
||||||
throw new ArgumentException(
|
|
||||||
"Alarm-feed message does not carry a provider-status payload.",
|
|
||||||
nameof(message));
|
|
||||||
}
|
|
||||||
|
|
||||||
return FromProviderStatus(message.ProviderStatus);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -6,6 +6,7 @@ public sealed class DashboardApiKeyAuthorization
|
|||||||
{
|
{
|
||||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
/// <summary>Determines whether the user can manage API keys.</summary>
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
/// <param name="user">The authenticated user principal.</param>
|
||||||
|
/// <returns>True if the user is an authenticated admin; otherwise false.</returns>
|
||||||
public bool CanManage(ClaimsPrincipal user)
|
public bool CanManage(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
if (user.Identity?.IsAuthenticated != true)
|
if (user.Identity?.IsAuthenticated != true)
|
||||||
|
|||||||
@@ -20,17 +20,13 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||||
private const string PepperUnavailableMarker = "pepper unavailable";
|
private const string PepperUnavailableMarker = "pepper unavailable";
|
||||||
|
|
||||||
/// <summary>Determines whether the user can manage API keys.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
public bool CanManage(ClaimsPrincipal user)
|
public bool CanManage(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
return authorization.CanManage(user);
|
return authorization.CanManage(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Creates an API key asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="request">The request payload.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
DashboardApiKeyManagementRequest request,
|
DashboardApiKeyManagementRequest request,
|
||||||
@@ -82,10 +78,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Revokes an API key asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="keyId">The API key identifier.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string keyId,
|
string keyId,
|
||||||
@@ -120,10 +113,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Rotates an API key secret asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="keyId">The API key identifier.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string keyId,
|
string keyId,
|
||||||
@@ -170,10 +160,7 @@ public sealed class DashboardApiKeyManagementService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes a revoked API key asynchronously.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="user">The authenticated user principal.</param>
|
|
||||||
/// <param name="keyId">The API key identifier.</param>
|
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
|
||||||
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
|
public async Task<DashboardApiKeyManagementResult> DeleteAsync(
|
||||||
ClaimsPrincipal user,
|
ClaimsPrincipal user,
|
||||||
string keyId,
|
string keyId,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed record DashboardAuthenticationResult(
|
|||||||
/// Creates a successful authentication result.
|
/// Creates a successful authentication result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="principal">Authenticated principal.</param>
|
/// <param name="principal">Authenticated principal.</param>
|
||||||
|
/// <returns>A successful <see cref="DashboardAuthenticationResult"/> wrapping the principal.</returns>
|
||||||
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
|
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticationResult(true, principal, null);
|
return new DashboardAuthenticationResult(true, principal, null);
|
||||||
@@ -32,6 +33,7 @@ public sealed record DashboardAuthenticationResult(
|
|||||||
/// Creates a failed authentication result.
|
/// Creates a failed authentication result.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
|
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
|
||||||
|
/// <returns>A failed <see cref="DashboardAuthenticationResult"/> with the given message.</returns>
|
||||||
public static DashboardAuthenticationResult Fail(string failureMessage)
|
public static DashboardAuthenticationResult Fail(string failureMessage)
|
||||||
{
|
{
|
||||||
return new DashboardAuthenticationResult(false, null, failureMessage);
|
return new DashboardAuthenticationResult(false, null, failureMessage);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public static class DashboardConnectionStringDisplay
|
|||||||
{
|
{
|
||||||
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
|
/// <summary>Returns a sanitized Galaxy Repository connection string for display.</summary>
|
||||||
/// <param name="connectionString">The connection string to sanitize.</param>
|
/// <param name="connectionString">The connection string to sanitize.</param>
|
||||||
|
/// <returns>A sanitized connection string with credentials removed, or <c>"[invalid connection string]"</c> if parsing fails.</returns>
|
||||||
public static string GalaxyRepositoryConnectionString(string connectionString)
|
public static string GalaxyRepositoryConnectionString(string connectionString)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
|
|||||||
@@ -29,14 +29,8 @@ public static class DashboardEndpointRouteBuilderExtensions
|
|||||||
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
// kit's <LoginCard>. Its [AllowAnonymous] attribute overrides the
|
||||||
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
// RequireAuthorization(ViewerPolicy) that MapRazorComponents<App>() applies,
|
||||||
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
// so the cookie scheme's LoginPath="/login" redirect resolves for anonymous users.
|
||||||
//
|
|
||||||
// The credential POST is mapped to /auth/login, NOT /login. The @page "/login"
|
|
||||||
// Razor Components endpoint matches ALL HTTP methods, so a MapPost("/login") shared
|
|
||||||
// the "/login" route with it and every POST threw AmbiguousMatchException (HTTP 500).
|
|
||||||
// A distinct /auth/login path (as ScadaBridge does) keeps the GET page and the POST
|
|
||||||
// handler on separate routes. The <LoginCard Action="/auth/login"> form posts here.
|
|
||||||
endpoints.MapPost(
|
endpoints.MapPost(
|
||||||
"/auth/login",
|
"/login",
|
||||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||||
PostLoginAsync(httpContext, antiforgery, authenticator))
|
PostLoginAsync(httpContext, antiforgery, authenticator))
|
||||||
.AllowAnonymous()
|
.AllowAnonymous()
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ internal static class DashboardGalaxyProjector
|
|||||||
{
|
{
|
||||||
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
|
/// <summary>Projects the cache entry to a dashboard Galaxy summary.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
|
/// <returns>The precomputed <see cref="DashboardGalaxySummary"/> from the cache entry.</returns>
|
||||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||||
{
|
{
|
||||||
return entry.DashboardSummary;
|
return entry.DashboardSummary;
|
||||||
|
|||||||
@@ -17,7 +17,10 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
|||||||
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
|
public sealed class DashboardGroupRoleMapper(IOptions<GatewayOptions> options)
|
||||||
: IGroupRoleMapper<string>
|
: IGroupRoleMapper<string>
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>Maps LDAP group memberships to dashboard roles using the configured group-to-role rules.</summary>
|
||||||
|
/// <param name="groups">The list of LDAP group names or distinguished names for the authenticated user.</param>
|
||||||
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the role mapping for the supplied groups.</returns>
|
||||||
public Task<GroupRoleMapping<string>> MapAsync(
|
public Task<GroupRoleMapping<string>> MapAsync(
|
||||||
IReadOnlyList<string> groups,
|
IReadOnlyList<string> groups,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ internal static class DashboardGroupRoleMapping
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
/// <param name="groups">The collection of LDAP groups the user belongs to.</param>
|
||||||
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
/// <param name="groupToRole">The mapping from group names to dashboard role names.</param>
|
||||||
|
/// <returns>The distinct set of dashboard roles matched from the user's groups.</returns>
|
||||||
internal static IReadOnlyList<string> MapGroupsToRoles(
|
internal static IReadOnlyList<string> MapGroupsToRoles(
|
||||||
IEnumerable<string> groups,
|
IEnumerable<string> groups,
|
||||||
IReadOnlyDictionary<string, string> groupToRole)
|
IReadOnlyDictionary<string, string> groupToRole)
|
||||||
@@ -61,6 +62,7 @@ internal static class DashboardGroupRoleMapping
|
|||||||
|
|
||||||
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
/// <summary>Extracts the first RDN value from a distinguished name.</summary>
|
||||||
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
/// <param name="distinguishedName">The LDAP distinguished name.</param>
|
||||||
|
/// <returns>The value portion of the first RDN component, or the full string if no <c>=</c> is found.</returns>
|
||||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||||
{
|
{
|
||||||
int equalsIndex = distinguishedName.IndexOf('=');
|
int equalsIndex = distinguishedName.IndexOf('=');
|
||||||
|
|||||||
@@ -192,7 +192,8 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Releases resources and closes the associated gateway session.</summary>
|
||||||
|
/// <returns>A task that represents the asynchronous dispose operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
/// Application configuration, used to bind the shared LDAP provider's options
|
/// Application configuration, used to bind the shared LDAP provider's options
|
||||||
/// from the <c>MxGateway:Ldap</c> section.
|
/// from the <c>MxGateway:Ldap</c> section.
|
||||||
/// </param>
|
/// </param>
|
||||||
|
/// <returns>The <paramref name="services"/> collection for chaining.</returns>
|
||||||
public static IServiceCollection AddGatewayDashboard(
|
public static IServiceCollection AddGatewayDashboard(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration)
|
IConfiguration configuration)
|
||||||
@@ -66,8 +67,6 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
|
ZbCookieDefaults.Apply(cookieOptions, requireHttps: true, idleTimeout: TimeSpan.FromHours(8));
|
||||||
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
|
// Cookie name, path, and redirect paths are MxGateway-specific — set after Apply
|
||||||
// so they are never overwritten by the shared helper (Apply intentionally skips name).
|
// so they are never overwritten by the shared helper (Apply intentionally skips name).
|
||||||
// This is the canonical default; it is overridden per-environment from
|
|
||||||
// DashboardOptions.CookieName by the PostConfigure below.
|
|
||||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||||
cookieOptions.Cookie.Path = "/";
|
cookieOptions.Cookie.Path = "/";
|
||||||
cookieOptions.LoginPath = "/login";
|
cookieOptions.LoginPath = "/login";
|
||||||
@@ -79,22 +78,13 @@ public static class DashboardServiceCollectionExtensions
|
|||||||
_ => { });
|
_ => { });
|
||||||
|
|
||||||
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
|
// Honour DashboardOptions.RequireHttpsCookie (default true / Always; set false for dev
|
||||||
// HTTP deployments → SameAsRequest) and the optional per-environment cookie-name
|
// HTTP deployments → SameAsRequest). This overrides the Apply default above.
|
||||||
// override. Both run after the inline AddCookie config above, so they win.
|
|
||||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||||
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
.Configure<IOptions<GatewayOptions>>((cookieOptions, gatewayOptions) =>
|
||||||
{
|
{
|
||||||
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
|
cookieOptions.Cookie.SecurePolicy = gatewayOptions.Value.Dashboard.RequireHttpsCookie
|
||||||
? CookieSecurePolicy.Always
|
? CookieSecurePolicy.Always
|
||||||
: CookieSecurePolicy.SameAsRequest;
|
: CookieSecurePolicy.SameAsRequest;
|
||||||
|
|
||||||
// Config-driven cookie name (MxGateway:Dashboard:CookieName). Null/blank keeps
|
|
||||||
// the canonical default set above, so a misconfiguration cannot unname the cookie.
|
|
||||||
var cookieName = gatewayOptions.Value.Dashboard.CookieName;
|
|
||||||
if (!string.IsNullOrWhiteSpace(cookieName))
|
|
||||||
{
|
|
||||||
cookieOptions.Cookie.Name = cookieName;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
services.AddAuthorization(authorization =>
|
services.AddAuthorization(authorization =>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ public sealed record DashboardSessionAdminResult(
|
|||||||
{
|
{
|
||||||
/// <summary>Creates a successful result with the given message.</summary>
|
/// <summary>Creates a successful result with the given message.</summary>
|
||||||
/// <param name="message">The result message.</param>
|
/// <param name="message">The result message.</param>
|
||||||
|
/// <returns>A <see cref="DashboardSessionAdminResult"/> with <c>Succeeded</c> set to <c>true</c>.</returns>
|
||||||
public static DashboardSessionAdminResult Success(string message)
|
public static DashboardSessionAdminResult Success(string message)
|
||||||
{
|
{
|
||||||
return new DashboardSessionAdminResult(true, message);
|
return new DashboardSessionAdminResult(true, message);
|
||||||
@@ -13,6 +14,7 @@ public sealed record DashboardSessionAdminResult(
|
|||||||
|
|
||||||
/// <summary>Creates a failed result with the given message.</summary>
|
/// <summary>Creates a failed result with the given message.</summary>
|
||||||
/// <param name="message">The result message.</param>
|
/// <param name="message">The result message.</param>
|
||||||
|
/// <returns>A <see cref="DashboardSessionAdminResult"/> with <c>Succeeded</c> set to <c>false</c>.</returns>
|
||||||
public static DashboardSessionAdminResult Fail(string message)
|
public static DashboardSessionAdminResult Fail(string message)
|
||||||
{
|
{
|
||||||
return new DashboardSessionAdminResult(false, message);
|
return new DashboardSessionAdminResult(false, message);
|
||||||
|
|||||||
@@ -65,10 +65,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Gets a current dashboard snapshot of gateway state.
|
|
||||||
/// </summary>
|
|
||||||
/// <returns>Dashboard snapshot.</returns>
|
|
||||||
public DashboardSnapshot GetSnapshot()
|
public DashboardSnapshot GetSnapshot()
|
||||||
{
|
{
|
||||||
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
|
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
|
||||||
@@ -100,11 +97,7 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Watches dashboard snapshots at regular intervals asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>Async enumerable of dashboard snapshots.</returns>
|
|
||||||
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
public async IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
@@ -195,7 +188,6 @@ public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
|||||||
new("mxgateway.workers.exited", snapshot.WorkerExits),
|
new("mxgateway.workers.exited", snapshot.WorkerExits),
|
||||||
new("mxgateway.heartbeats.failed", snapshot.HeartbeatFailures),
|
new("mxgateway.heartbeats.failed", snapshot.HeartbeatFailures),
|
||||||
new("mxgateway.grpc.streams.disconnected", snapshot.StreamDisconnects),
|
new("mxgateway.grpc.streams.disconnected", snapshot.StreamDisconnects),
|
||||||
new("mxgateway.alarms.provider_switches", snapshot.AlarmProviderSwitchCount),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
metrics.AddRange(snapshot.CommandFailuresByMethod
|
metrics.AddRange(snapshot.CommandFailuresByMethod
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ public sealed class HubTokenService
|
|||||||
|
|
||||||
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary>
|
||||||
/// <param name="user">The claims principal representing the user.</param>
|
/// <param name="user">The claims principal representing the user.</param>
|
||||||
|
/// <returns>The time-limited bearer token string.</returns>
|
||||||
public string Issue(ClaimsPrincipal user)
|
public string Issue(ClaimsPrincipal user)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(user);
|
ArgumentNullException.ThrowIfNull(user);
|
||||||
@@ -52,6 +53,7 @@ public sealed class HubTokenService
|
|||||||
|
|
||||||
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
/// <summary>Validates a token and returns the equivalent claims principal; null when invalid or expired.</summary>
|
||||||
/// <param name="token">The token string to validate.</param>
|
/// <param name="token">The token string to validate.</param>
|
||||||
|
/// <returns>The claims principal if the token is valid, or null if invalid or expired.</returns>
|
||||||
public ClaimsPrincipal? Validate(string? token)
|
public ClaimsPrincipal? Validate(string? token)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(token))
|
if (string.IsNullOrEmpty(token))
|
||||||
|
|||||||
@@ -14,9 +14,7 @@ public sealed class DashboardEventBroadcaster(
|
|||||||
IHubContext<EventsHub> hubContext,
|
IHubContext<EventsHub> hubContext,
|
||||||
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
|
ILogger<DashboardEventBroadcaster> logger) : IDashboardEventBroadcaster
|
||||||
{
|
{
|
||||||
/// <summary>Publishes an MX event to connected dashboard clients.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="sessionId">The session identifier.</param>
|
|
||||||
/// <param name="mxEvent">The MX event to publish.</param>
|
|
||||||
public void Publish(string sessionId, MxEvent mxEvent)
|
public void Publish(string sessionId, MxEvent mxEvent)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
|
if (string.IsNullOrEmpty(sessionId) || mxEvent is null)
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ public sealed class EventsHub : Hub
|
|||||||
/// dedicated authorization policy applied to the hub method itself.
|
/// dedicated authorization policy applied to the hub method itself.
|
||||||
/// </remarks>
|
/// </remarks>
|
||||||
/// <param name="sessionId">Session id to subscribe the caller to.</param>
|
/// <param name="sessionId">Session id to subscribe the caller to.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public Task SubscribeSession(string sessionId)
|
public Task SubscribeSession(string sessionId)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(sessionId))
|
if (string.IsNullOrWhiteSpace(sessionId))
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ public interface IDashboardAuthenticator
|
|||||||
/// <param name="username">Username to authenticate.</param>
|
/// <param name="username">Username to authenticate.</param>
|
||||||
/// <param name="password">Password to authenticate.</param>
|
/// <param name="password">Password to authenticate.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the authentication result.</returns>
|
||||||
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||||
string? username,
|
string? username,
|
||||||
string? password,
|
string? password,
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ public interface IDashboardBrowseService
|
|||||||
{
|
{
|
||||||
/// <summary>Returns root browse nodes (objects with no parent).</summary>
|
/// <summary>Returns root browse nodes (objects with no parent).</summary>
|
||||||
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
||||||
|
/// <returns>The root-level browse result.</returns>
|
||||||
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
|
BrowseLevelResult GetRoots(BrowseFilterArgs filter);
|
||||||
|
|
||||||
/// <summary>Returns the direct children of the given parent gobject id.</summary>
|
/// <summary>Returns the direct children of the given parent gobject id.</summary>
|
||||||
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
|
/// <param name="parentGobjectId">The Galaxy gobject id of the parent to expand.</param>
|
||||||
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
/// <param name="filter">Filter arguments forwarded to the projector.</param>
|
||||||
|
/// <returns>The children browse result for the specified parent.</returns>
|
||||||
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
|
BrowseLevelResult GetChildren(int parentGobjectId, BrowseFilterArgs filter);
|
||||||
|
|
||||||
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
|
/// <summary>Current Galaxy cache sequence. Bumps after each successful refresh.</summary>
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ public interface IDashboardSnapshotService
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Gets the current dashboard snapshot.
|
/// Gets the current dashboard snapshot.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>The most recent <see cref="DashboardSnapshot"/>.</returns>
|
||||||
DashboardSnapshot GetSnapshot();
|
DashboardSnapshot GetSnapshot();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Watches for changes to the dashboard state as an async enumerable.
|
/// Watches for changes to the dashboard state as an async enumerable.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>An async sequence of <see cref="DashboardSnapshot"/> values as state changes.</returns>
|
||||||
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
|
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,15 @@ public sealed class AuthStoreHealthCheck : IHealthCheck
|
|||||||
{
|
{
|
||||||
private readonly AuthSqliteConnectionFactory _connectionFactory;
|
private readonly AuthSqliteConnectionFactory _connectionFactory;
|
||||||
|
|
||||||
|
/// <summary>Initializes a new instance of <see cref="AuthStoreHealthCheck"/> with the given connection factory.</summary>
|
||||||
|
/// <param name="connectionFactory">Factory for opening SQLite connections to the auth store.</param>
|
||||||
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
|
public AuthStoreHealthCheck(AuthSqliteConnectionFactory connectionFactory) =>
|
||||||
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
_connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory));
|
||||||
|
|
||||||
|
/// <summary>Runs a lightweight connectivity probe against the SQLite authentication store.</summary>
|
||||||
|
/// <param name="context">Health check context supplied by the framework.</param>
|
||||||
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the health check result.</returns>
|
||||||
public async Task<HealthCheckResult> CheckHealthAsync(
|
public async Task<HealthCheckResult> CheckHealthAsync(
|
||||||
HealthCheckContext context,
|
HealthCheckContext context,
|
||||||
CancellationToken cancellationToken = default)
|
CancellationToken cancellationToken = default)
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ public static class GatewayLogRedactor
|
|||||||
/// Determines whether a command method bears credentials.
|
/// Determines whether a command method bears credentials.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="commandMethod">The command method name to check.</param>
|
/// <param name="commandMethod">The command method name to check.</param>
|
||||||
|
/// <returns><c>true</c> if the method carries credentials; otherwise <c>false</c>.</returns>
|
||||||
public static bool IsCredentialBearingCommand(string? commandMethod)
|
public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||||
{
|
{
|
||||||
return commandMethod is not null
|
return commandMethod is not null
|
||||||
@@ -29,6 +30,7 @@ public static class GatewayLogRedactor
|
|||||||
/// Redacts the API key secret portion of a Bearer authorization header.
|
/// Redacts the API key secret portion of a Bearer authorization header.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="authorizationHeader">The authorization header value to redact.</param>
|
/// <param name="authorizationHeader">The authorization header value to redact.</param>
|
||||||
|
/// <returns>The header with the secret portion replaced by <see cref="RedactedValue"/>, or the original if no key is detected.</returns>
|
||||||
public static string? RedactApiKey(string? authorizationHeader)
|
public static string? RedactApiKey(string? authorizationHeader)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||||
@@ -62,6 +64,7 @@ public static class GatewayLogRedactor
|
|||||||
/// Redacts the client identity if it contains an API key.
|
/// Redacts the client identity if it contains an API key.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="clientIdentity">The client identity string to redact.</param>
|
/// <param name="clientIdentity">The client identity string to redact.</param>
|
||||||
|
/// <returns>The redacted identity string, or the original if no key pattern is found.</returns>
|
||||||
public static string? RedactClientIdentity(string? clientIdentity)
|
public static string? RedactClientIdentity(string? clientIdentity)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(clientIdentity))
|
if (string.IsNullOrWhiteSpace(clientIdentity))
|
||||||
@@ -80,6 +83,7 @@ public static class GatewayLogRedactor
|
|||||||
/// <param name="commandMethod">The command method name to check for credentials.</param>
|
/// <param name="commandMethod">The command method name to check for credentials.</param>
|
||||||
/// <param name="value">The command value to redact.</param>
|
/// <param name="value">The command value to redact.</param>
|
||||||
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
|
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
|
||||||
|
/// <returns>The original value when logging is enabled and the command is not credential-bearing; otherwise <see cref="RedactedValue"/>.</returns>
|
||||||
public static object? RedactCommandValue(
|
public static object? RedactCommandValue(
|
||||||
string? commandMethod,
|
string? commandMethod,
|
||||||
object? value,
|
object? value,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ public sealed record GatewayLogScope(
|
|||||||
string? ClientIdentity = null)
|
string? ClientIdentity = null)
|
||||||
{
|
{
|
||||||
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
|
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
|
||||||
|
/// <returns>A dictionary of non-null scope properties with sensitive fields redacted.</returns>
|
||||||
public IReadOnlyDictionary<string, object?> ToDictionary()
|
public IReadOnlyDictionary<string, object?> ToDictionary()
|
||||||
{
|
{
|
||||||
Dictionary<string, object?> values = [];
|
Dictionary<string, object?> values = [];
|
||||||
|
|||||||
+1
@@ -19,6 +19,7 @@ public static class GatewayRequestLoggingMiddlewareExtensions
|
|||||||
|
|
||||||
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
|
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
|
||||||
/// <param name="app">Application builder.</param>
|
/// <param name="app">Application builder.</param>
|
||||||
|
/// <returns>The <paramref name="app"/> instance for chaining.</returns>
|
||||||
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(app);
|
ArgumentNullException.ThrowIfNull(app);
|
||||||
|
|||||||
@@ -1,48 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// One alarm-bearing attribute discovered by
|
|
||||||
/// <see cref="GalaxyRepository.GetAlarmAttributesAsync"/>: an attribute whose owning
|
|
||||||
/// object configures an <c>AlarmExtension</c> primitive (the same
|
|
||||||
/// <c>is_alarm</c> detection used by <see cref="GalaxyRepository.GetAttributesAsync"/>).
|
|
||||||
/// Used to build the subtag-fallback watch-list.
|
|
||||||
/// </summary>
|
|
||||||
public sealed class GalaxyAlarmAttributeRow
|
|
||||||
{
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the alarm-bearing attribute reference (e.g. <c>Tank01.Level.HiHi</c>),
|
|
||||||
/// matching the <c>full_tag_reference</c> projection of
|
|
||||||
/// <see cref="GalaxyRepository.GetAttributesAsync"/>.
|
|
||||||
/// </summary>
|
|
||||||
public string FullTagReference { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the owning object reference (e.g. <c>Tank01</c>). This is the Galaxy
|
|
||||||
/// <c>tag_name</c> — the segment that precedes the first attribute dot in
|
|
||||||
/// <see cref="FullTagReference"/>.
|
|
||||||
/// </summary>
|
|
||||||
public string SourceObjectReference { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the owning object's Galaxy area (e.g. <c>TestArea</c>) — the alarm group.
|
|
||||||
/// <para>
|
|
||||||
/// Resolved via <c>gobject.area_gobject_id</c> in <c>AlarmAttributesSql</c>. The
|
|
||||||
/// watch-list resolver composes the canonical <c>Galaxy!{area}.{reference}</c> from
|
|
||||||
/// this so the synthesized reference's group matches the native alarmmgr (wnwrap)
|
|
||||||
/// for reference parity. May be <see cref="string.Empty"/> when the object has no
|
|
||||||
/// area; the resolver then falls back to the configured area.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public string Area { get; init; } = string.Empty;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Gets the writable ack-comment attribute address.
|
|
||||||
/// <para>
|
|
||||||
/// The Galaxy Repository schema does not expose an ack-comment subtag address
|
|
||||||
/// directly, so this is always <see cref="string.Empty"/> here. The watch-list
|
|
||||||
/// resolver (a later task) composes the concrete address from configuration plus
|
|
||||||
/// <see cref="SourceObjectReference"/> / <see cref="FullTagReference"/>.
|
|
||||||
/// </para>
|
|
||||||
/// </summary>
|
|
||||||
public string AckCommentSubtag { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
@@ -27,6 +27,7 @@ public static class GalaxyBrowseProjector
|
|||||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||||
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
/// <param name="offset">Zero-based offset into the filtered child list.</param>
|
||||||
/// <param name="pageSize">Maximum number of children to return.</param>
|
/// <param name="pageSize">Maximum number of children to return.</param>
|
||||||
|
/// <returns>A page of children with total count and filter signature.</returns>
|
||||||
public static GalaxyBrowseChildrenResult ProjectChildren(
|
public static GalaxyBrowseChildrenResult ProjectChildren(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
@@ -71,6 +72,7 @@ public static class GalaxyBrowseProjector
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry to query.</param>
|
||||||
/// <param name="request">The browse-children request.</param>
|
/// <param name="request">The browse-children request.</param>
|
||||||
|
/// <returns>The resolved parent gobject id, or 0 for roots.</returns>
|
||||||
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
public static int ResolveParentId(GalaxyHierarchyCacheEntry entry, BrowseChildrenRequest request)
|
||||||
{
|
{
|
||||||
switch (request.ParentCase)
|
switch (request.ParentCase)
|
||||||
@@ -257,6 +259,7 @@ public static class GalaxyBrowseProjector
|
|||||||
/// <param name="request">The browse-children request.</param>
|
/// <param name="request">The browse-children request.</param>
|
||||||
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
/// <param name="browseSubtreeGlobs">Optional API-key browse-subtree constraints.</param>
|
||||||
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
/// <param name="parentId">Resolved parent gobject id (0 for roots).</param>
|
||||||
|
/// <returns>A hex-encoded SHA-256 prefix that uniquely identifies the filter combination.</returns>
|
||||||
public static string ComputeFilterSignature(
|
public static string ComputeFilterSignature(
|
||||||
BrowseChildrenRequest request,
|
BrowseChildrenRequest request,
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||||
|
|||||||
@@ -20,9 +20,7 @@ public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
|
|||||||
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
||||||
private GalaxyDeployEventInfo? _latest;
|
private GalaxyDeployEventInfo? _latest;
|
||||||
|
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// The most recent deploy event, or null if none has been published.
|
|
||||||
/// </summary>
|
|
||||||
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ public static class GalaxyGlobMatcher
|
|||||||
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
|
/// <summary>Determines whether a value matches a glob pattern (with * and ? wildcards).</summary>
|
||||||
/// <param name="value">The value to test against the glob pattern.</param>
|
/// <param name="value">The value to test against the glob pattern.</param>
|
||||||
/// <param name="glob">The glob pattern with * and ? wildcards.</param>
|
/// <param name="glob">The glob pattern with * and ? wildcards.</param>
|
||||||
|
/// <returns><see langword="true"/> if the value matches the glob pattern; otherwise <see langword="false"/>.</returns>
|
||||||
public static bool IsMatch(string value, string glob)
|
public static bool IsMatch(string value, string glob)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(glob))
|
if (string.IsNullOrWhiteSpace(glob))
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
_snapshotStore = snapshotStore;
|
_snapshotStore = snapshotStore;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
/// <inheritdoc />
|
||||||
public GalaxyHierarchyCacheEntry Current
|
public GalaxyHierarchyCacheEntry Current
|
||||||
{
|
{
|
||||||
get
|
get
|
||||||
@@ -74,9 +74,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
|
||||||
public async Task RefreshAsync(CancellationToken cancellationToken)
|
public async Task RefreshAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -90,9 +88,7 @@ public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
|
||||||
/// <returns>Asynchronous task representing the wait operation.</returns>
|
|
||||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return _firstLoad.Task.WaitAsync(cancellationToken);
|
return _firstLoad.Task.WaitAsync(cancellationToken);
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="request">The discovery hierarchy request.</param>
|
/// <param name="request">The discovery hierarchy request.</param>
|
||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
|
/// <returns>The query result containing matching objects.</returns>
|
||||||
public static GalaxyHierarchyQueryResult Project(
|
public static GalaxyHierarchyQueryResult Project(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
@@ -44,6 +45,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
/// <param name="offset">The zero-based offset into the result set.</param>
|
/// <param name="offset">The zero-based offset into the result set.</param>
|
||||||
/// <param name="pageSize">The maximum number of results to return.</param>
|
/// <param name="pageSize">The maximum number of results to return.</param>
|
||||||
|
/// <returns>The query result containing the requested page of matching objects.</returns>
|
||||||
public static GalaxyHierarchyQueryResult Project(
|
public static GalaxyHierarchyQueryResult Project(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
@@ -131,6 +133,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
|
/// <summary>Finds an object in the hierarchy by its tag address.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="tagAddress">The tag address to search for.</param>
|
/// <param name="tagAddress">The tag address to search for.</param>
|
||||||
|
/// <returns>The matching Galaxy object, or <c>null</c> if not found.</returns>
|
||||||
public static GalaxyObject? FindObjectForTag(
|
public static GalaxyObject? FindObjectForTag(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
string tagAddress)
|
string tagAddress)
|
||||||
@@ -148,6 +151,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
|
/// <summary>Finds an attribute in the hierarchy by its tag address.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="tagAddress">The tag address to search for.</param>
|
/// <param name="tagAddress">The tag address to search for.</param>
|
||||||
|
/// <returns>The matching Galaxy attribute, or <c>null</c> if not found.</returns>
|
||||||
public static GalaxyAttribute? FindAttributeForTag(
|
public static GalaxyAttribute? FindAttributeForTag(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
string tagAddress)
|
string tagAddress)
|
||||||
@@ -165,6 +169,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
|
/// <summary>Gets the contained path for an object by its gobject ID.</summary>
|
||||||
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
/// <param name="entry">The Galaxy hierarchy cache entry.</param>
|
||||||
/// <param name="gobjectId">The Galaxy object ID.</param>
|
/// <param name="gobjectId">The Galaxy object ID.</param>
|
||||||
|
/// <returns>The contained path string, or an empty string if the object is not found.</returns>
|
||||||
public static string GetContainedPath(
|
public static string GetContainedPath(
|
||||||
GalaxyHierarchyCacheEntry entry,
|
GalaxyHierarchyCacheEntry entry,
|
||||||
int gobjectId)
|
int gobjectId)
|
||||||
@@ -282,6 +287,7 @@ public static class GalaxyHierarchyProjector
|
|||||||
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
/// <summary>Computes a stable filter signature for memoization purposes.</summary>
|
||||||
/// <param name="request">The discovery hierarchy request.</param>
|
/// <param name="request">The discovery hierarchy request.</param>
|
||||||
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
/// <param name="browseSubtreeGlobs">Optional glob patterns to filter browse subtrees.</param>
|
||||||
|
/// <returns>A string key that uniquely identifies the combination of filter parameters.</returns>
|
||||||
public static string ComputeFilterSignature(
|
public static string ComputeFilterSignature(
|
||||||
DiscoverHierarchyRequest request,
|
DiscoverHierarchyRequest request,
|
||||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||||
|
|||||||
@@ -15,8 +15,7 @@ namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
||||||
{
|
{
|
||||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
try
|
try
|
||||||
@@ -31,8 +30,7 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
catch (InvalidOperationException) { return false; }
|
catch (InvalidOperationException) { return false; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
using SqlConnection conn = new(options.ConnectionString);
|
using SqlConnection conn = new(options.ConnectionString);
|
||||||
@@ -43,8 +41,7 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
return result is DateTime dt ? dt : null;
|
return result is DateTime dt ? dt : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
List<GalaxyHierarchyRow> rows = new();
|
List<GalaxyHierarchyRow> rows = new();
|
||||||
@@ -81,8 +78,7 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
/// <inheritdoc />
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||||
{
|
{
|
||||||
List<GalaxyAttributeRow> rows = new();
|
List<GalaxyAttributeRow> rows = new();
|
||||||
@@ -114,64 +110,6 @@ public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyR
|
|||||||
return rows;
|
return rows;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves only the alarm-bearing attributes for the subtag-fallback watch-list.
|
|
||||||
/// Alarm detection is identical to <see cref="GetAttributesAsync"/>: a row is
|
|
||||||
/// alarm-bearing when its owning object configures an <c>AlarmExtension</c>
|
|
||||||
/// primitive (the same <c>is_alarm</c> projection, here applied as a SQL filter).
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
public async Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default)
|
|
||||||
{
|
|
||||||
List<GalaxyAlarmAttributeRow> rows = new();
|
|
||||||
|
|
||||||
using SqlConnection conn = new(options.ConnectionString);
|
|
||||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
using SqlCommand cmd = new(AlarmAttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
|
||||||
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
|
||||||
|
|
||||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
|
||||||
{
|
|
||||||
rows.Add(MapAlarmRow(
|
|
||||||
fullTagReference: reader.GetString(0),
|
|
||||||
sourceObjectReference: reader.GetString(1),
|
|
||||||
area: reader.GetString(2)));
|
|
||||||
}
|
|
||||||
return rows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Maps a raw alarm-attribute reader row to a <see cref="GalaxyAlarmAttributeRow"/>.
|
|
||||||
/// <para>
|
|
||||||
/// <paramref name="sourceObjectReference"/> is the Galaxy <c>tag_name</c> (the
|
|
||||||
/// owning object), and <paramref name="fullTagReference"/> is
|
|
||||||
/// <c>tag_name + '.' + attribute_name</c> — the same composition the
|
|
||||||
/// <c>full_tag_reference</c> projection of <see cref="AttributesSql"/> produces.
|
|
||||||
/// <see cref="GalaxyAlarmAttributeRow.AckCommentSubtag"/> is left empty here; the
|
|
||||||
/// schema does not expose an ack-comment address and the watch-list resolver
|
|
||||||
/// composes it later.
|
|
||||||
/// </para>
|
|
||||||
/// <paramref name="area"/> is the owning object's real Galaxy area (its alarm
|
|
||||||
/// group), resolved via <c>gobject.area_gobject_id</c>; the watch-list resolver
|
|
||||||
/// composes the canonical reference from it so the synthesized reference's group
|
|
||||||
/// matches what the native alarmmgr (wnwrap) emits.
|
|
||||||
/// Exposed internally so the derivation can be unit-tested without a database.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fullTagReference">The alarm-bearing attribute reference.</param>
|
|
||||||
/// <param name="sourceObjectReference">The owning object reference (tag name).</param>
|
|
||||||
/// <param name="area">The owning object's Galaxy area (the alarm group).</param>
|
|
||||||
internal static GalaxyAlarmAttributeRow MapAlarmRow(
|
|
||||||
string fullTagReference,
|
|
||||||
string sourceObjectReference,
|
|
||||||
string area) => new()
|
|
||||||
{
|
|
||||||
FullTagReference = fullTagReference,
|
|
||||||
SourceObjectReference = sourceObjectReference,
|
|
||||||
Area = area,
|
|
||||||
AckCommentSubtag = string.Empty,
|
|
||||||
};
|
|
||||||
|
|
||||||
private const string HierarchySql = @"
|
private const string HierarchySql = @"
|
||||||
;WITH template_chain AS (
|
;WITH template_chain AS (
|
||||||
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
||||||
@@ -306,62 +244,5 @@ SELECT
|
|||||||
FROM ranked r
|
FROM ranked r
|
||||||
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
||||||
WHERE r.rn = 1
|
WHERE r.rn = 1
|
||||||
ORDER BY r.tag_name, r.attribute_name";
|
|
||||||
|
|
||||||
// Alarm-only discovery for the subtag-fallback watch-list. This reuses the candidate/ranked
|
|
||||||
// CTE shape and the same `AlarmExtension`-based detection as AttributesSql. Unlike
|
|
||||||
// AttributesSql it keeps only the user-attribute (dynamic_attribute) candidate branch: an
|
|
||||||
// alarm anchor is always a user attribute, so the primitive-instance branch AttributesSql
|
|
||||||
// carries would be filtered out here anyway — a row qualifies only when its user attribute
|
|
||||||
// anchors an `AlarmExtension` primitive on the owning object. It projects just what the
|
|
||||||
// watch-list needs — full_tag_reference (tag_name +
|
|
||||||
// '.' + attribute_name, matching AttributesSql) and the owning object's tag_name as
|
|
||||||
// source_object_reference. The array `[]` suffix is intentionally omitted: an
|
|
||||||
// alarm-bearing attribute is a scalar anchor, not an array body. It also projects the
|
|
||||||
// owning object's real Galaxy area (via gobject.area_gobject_id) as area_name so the
|
|
||||||
// watch-list resolver composes a reference whose group matches the native alarmmgr.
|
|
||||||
private const string AlarmAttributesSql = @"
|
|
||||||
;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
|
|
||||||
),
|
|
||||||
candidate AS (
|
|
||||||
SELECT
|
|
||||||
dpc.gobject_id, g.tag_name, da.attribute_name, dpc.depth
|
|
||||||
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
|
|
||||||
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 AS (
|
|
||||||
SELECT c.*, ROW_NUMBER() OVER (
|
|
||||||
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.depth) AS rn
|
|
||||||
FROM candidate c
|
|
||||||
)
|
|
||||||
SELECT
|
|
||||||
r.tag_name + '.' + r.attribute_name AS full_tag_reference,
|
|
||||||
r.tag_name AS source_object_reference,
|
|
||||||
ISNULL(area.tag_name, '') AS area_name
|
|
||||||
FROM ranked r
|
|
||||||
INNER JOIN gobject g ON g.gobject_id = r.gobject_id
|
|
||||||
LEFT JOIN gobject area ON area.gobject_id = g.area_gobject_id
|
|
||||||
WHERE r.rn = 1
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1 FROM deployed_package_chain dpc2
|
|
||||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc2.package_id AND pi.primitive_name = r.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 = r.gobject_id
|
|
||||||
)
|
|
||||||
ORDER BY r.tag_name, r.attribute_name";
|
ORDER BY r.tag_name, r.attribute_name";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IGalaxyHierarchyCache
|
|||||||
/// refresh.
|
/// refresh.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task RefreshAsync(CancellationToken cancellationToken);
|
Task RefreshAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -21,5 +22,6 @@ public interface IGalaxyHierarchyCache
|
|||||||
/// very first request after gateway start.
|
/// very first request after gateway start.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
|
Task WaitForFirstLoadAsync(CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public interface IGalaxyHierarchySnapshotStore
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="snapshot">The browse dataset to persist.</param>
|
/// <param name="snapshot">The browse dataset to persist.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous save operation.</returns>
|
||||||
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,25 +14,21 @@ public interface IGalaxyRepository
|
|||||||
{
|
{
|
||||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to <see langword="true"/> if the connection succeeds; otherwise <see langword="false"/>.</returns>
|
||||||
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
Task<bool> TestConnectionAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the last deploy time, or <see langword="null"/> if not available.</returns>
|
||||||
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the list of hierarchy rows.</returns>
|
||||||
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
|
Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the list of attribute rows.</returns>
|
||||||
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default);
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Retrieves only the alarm-bearing attributes (those whose owning object
|
|
||||||
/// configures an <c>AlarmExtension</c> primitive) for building the
|
|
||||||
/// subtag-fallback watch-list.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
|
||||||
Task<List<GalaxyAlarmAttributeRow>> GetAlarmAttributesAsync(CancellationToken ct = default);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,12 +18,7 @@ public sealed class EventStreamService(
|
|||||||
IDashboardEventBroadcaster dashboardEventBroadcaster,
|
IDashboardEventBroadcaster dashboardEventBroadcaster,
|
||||||
ILogger<EventStreamService> logger) : IEventStreamService
|
ILogger<EventStreamService> logger) : IEventStreamService
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <inheritdoc />
|
||||||
/// Streams events from a session to the client asynchronously.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="request">Stream events request.</param>
|
|
||||||
/// <param name="cancellationToken">Cancellation token.</param>
|
|
||||||
/// <returns>Async enumerable of MX events.</returns>
|
|
||||||
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static class GalaxyProtoMapper
|
|||||||
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
|
/// <summary>Maps Galaxy hierarchy and attribute rows to Galaxy object protos.</summary>
|
||||||
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
|
/// <param name="hierarchy">Hierarchy rows from Galaxy Repository.</param>
|
||||||
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
|
/// <param name="attributes">Attribute rows from Galaxy Repository.</param>
|
||||||
|
/// <returns>An enumerable of mapped Galaxy object protos.</returns>
|
||||||
public static IEnumerable<GalaxyObject> MapHierarchy(
|
public static IEnumerable<GalaxyObject> MapHierarchy(
|
||||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||||
@@ -30,6 +31,7 @@ public static class GalaxyProtoMapper
|
|||||||
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
|
/// <summary>Maps a Galaxy hierarchy row to a Galaxy object proto.</summary>
|
||||||
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
|
/// <param name="row">Hierarchy row from Galaxy Repository.</param>
|
||||||
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
|
/// <param name="attributesByGobjectId">Attributes indexed by gobject ID.</param>
|
||||||
|
/// <returns>The mapped Galaxy object proto.</returns>
|
||||||
public static GalaxyObject MapObject(
|
public static GalaxyObject MapObject(
|
||||||
GalaxyHierarchyRow row,
|
GalaxyHierarchyRow row,
|
||||||
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
|
IReadOnlyDictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId)
|
||||||
@@ -60,6 +62,7 @@ public static class GalaxyProtoMapper
|
|||||||
|
|
||||||
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
|
/// <summary>Maps a Galaxy attribute row to a Galaxy attribute proto.</summary>
|
||||||
/// <param name="row">Attribute row from Galaxy Repository.</param>
|
/// <param name="row">Attribute row from Galaxy Repository.</param>
|
||||||
|
/// <returns>The mapped Galaxy attribute proto.</returns>
|
||||||
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
|
public static GalaxyAttribute MapAttribute(GalaxyAttributeRow row) => new()
|
||||||
{
|
{
|
||||||
AttributeName = row.AttributeName,
|
AttributeName = row.AttributeName,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ public interface IEventStreamService
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Request payload.</param>
|
/// <param name="request">Request payload.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>An async enumerable of MXAccess events.</returns>
|
||||||
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
IAsyncEnumerable<MxEvent> StreamEventsAsync(
|
||||||
StreamEventsRequest request,
|
StreamEventsRequest request,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -162,15 +162,6 @@ public sealed class MxAccessGatewayService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
|
||||||
/// Surfaces the public AcknowledgeAlarm RPC. Acknowledgement is
|
|
||||||
/// session-less: the gateway routes it through the always-on
|
|
||||||
/// <see cref="IGatewayAlarmService"/> monitor session. An
|
|
||||||
/// <c>alarm_full_reference</c> that parses as a canonical GUID forwards
|
|
||||||
/// to <c>AcknowledgeAlarmCommand</c>; a <c>Provider!Group.Tag</c>
|
|
||||||
/// reference forwards to <c>AcknowledgeAlarmByNameCommand</c>; anything
|
|
||||||
/// else returns an <c>InvalidRequest</c> diagnostic in the reply.
|
|
||||||
/// </remarks>
|
|
||||||
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
public override async Task<AcknowledgeAlarmReply> AcknowledgeAlarm(
|
||||||
AcknowledgeAlarmRequest request,
|
AcknowledgeAlarmRequest request,
|
||||||
ServerCallContext context)
|
ServerCallContext context)
|
||||||
@@ -193,14 +184,6 @@ public sealed class MxAccessGatewayService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
|
||||||
/// Surfaces the public StreamAlarms RPC — the session-less central
|
|
||||||
/// alarm feed. The stream opens with one <c>active_alarm</c> per
|
|
||||||
/// currently-active alarm, then a single <c>snapshot_complete</c>, then
|
|
||||||
/// a <c>transition</c> for every subsequent change. Served by the
|
|
||||||
/// gateway's always-on <see cref="IGatewayAlarmService"/> monitor; any
|
|
||||||
/// number of clients fan out from the single monitor.
|
|
||||||
/// </remarks>
|
|
||||||
public override async Task StreamAlarms(
|
public override async Task StreamAlarms(
|
||||||
StreamAlarmsRequest request,
|
StreamAlarmsRequest request,
|
||||||
IServerStreamWriter<AlarmFeedMessage> responseStream,
|
IServerStreamWriter<AlarmFeedMessage> responseStream,
|
||||||
@@ -224,12 +207,6 @@ public sealed class MxAccessGatewayService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <inheritdoc />
|
||||||
/// <remarks>
|
|
||||||
/// Snapshot of the active-alarm cache maintained by the gateway's
|
|
||||||
/// always-on alarm monitor. Streams one <see cref="ActiveAlarmSnapshot"/>
|
|
||||||
/// per currently-active alarm and completes — no transitions are
|
|
||||||
/// emitted. Use <c>StreamAlarms</c> for a live transition feed.
|
|
||||||
/// </remarks>
|
|
||||||
public override async Task QueryActiveAlarms(
|
public override async Task QueryActiveAlarms(
|
||||||
QueryActiveAlarmsRequest request,
|
QueryActiveAlarmsRequest request,
|
||||||
IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
|
IServerStreamWriter<ActiveAlarmSnapshot> responseStream,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Maps a gRPC MX command request to a worker command.
|
/// Maps a gRPC MX command request to a worker command.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="request">Request payload.</param>
|
/// <param name="request">Request payload.</param>
|
||||||
|
/// <returns>The mapped worker command.</returns>
|
||||||
public WorkerCommand MapCommand(MxCommandRequest request)
|
public WorkerCommand MapCommand(MxCommandRequest request)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
@@ -39,6 +40,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Maps a worker command reply to a gRPC MX command reply.
|
/// Maps a worker command reply to a gRPC MX command reply.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="reply">Worker command reply.</param>
|
/// <param name="reply">Worker command reply.</param>
|
||||||
|
/// <returns>The mapped gRPC command reply.</returns>
|
||||||
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
|
public MxCommandReply MapCommandReply(WorkerCommandReply reply)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(reply);
|
ArgumentNullException.ThrowIfNull(reply);
|
||||||
@@ -58,6 +60,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Maps a worker event to a gRPC MX event.
|
/// Maps a worker event to a gRPC MX event.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="workerEvent">Worker event to map.</param>
|
/// <param name="workerEvent">Worker event to map.</param>
|
||||||
|
/// <returns>The mapped gRPC MX event.</returns>
|
||||||
public MxEvent MapEvent(WorkerEvent workerEvent)
|
public MxEvent MapEvent(WorkerEvent workerEvent)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(workerEvent);
|
ArgumentNullException.ThrowIfNull(workerEvent);
|
||||||
@@ -73,6 +76,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates an OK protocol status.
|
/// Creates an OK protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Ok"/>.</returns>
|
||||||
public static ProtocolStatus Ok(string message = "OK")
|
public static ProtocolStatus Ok(string message = "OK")
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -86,6 +90,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates an InvalidRequest protocol status.
|
/// Creates an InvalidRequest protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.InvalidRequest"/>.</returns>
|
||||||
public static ProtocolStatus InvalidRequest(string message)
|
public static ProtocolStatus InvalidRequest(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -99,6 +104,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a SessionNotFound protocol status.
|
/// Creates a SessionNotFound protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotFound"/>.</returns>
|
||||||
public static ProtocolStatus SessionNotFound(string message)
|
public static ProtocolStatus SessionNotFound(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -112,6 +118,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a SessionNotReady protocol status.
|
/// Creates a SessionNotReady protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.SessionNotReady"/>.</returns>
|
||||||
public static ProtocolStatus SessionNotReady(string message)
|
public static ProtocolStatus SessionNotReady(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -125,6 +132,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a WorkerUnavailable protocol status.
|
/// Creates a WorkerUnavailable protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.WorkerUnavailable"/>.</returns>
|
||||||
public static ProtocolStatus WorkerUnavailable(string message)
|
public static ProtocolStatus WorkerUnavailable(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -138,6 +146,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a Timeout protocol status.
|
/// Creates a Timeout protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Timeout"/>.</returns>
|
||||||
public static ProtocolStatus Timeout(string message)
|
public static ProtocolStatus Timeout(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -151,6 +160,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a Canceled protocol status.
|
/// Creates a Canceled protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.Canceled"/>.</returns>
|
||||||
public static ProtocolStatus Canceled(string message)
|
public static ProtocolStatus Canceled(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
@@ -164,6 +174,7 @@ public sealed class MxAccessGrpcMapper
|
|||||||
/// Creates a ProtocolViolation protocol status.
|
/// Creates a ProtocolViolation protocol status.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">Status message.</param>
|
/// <param name="message">Status message.</param>
|
||||||
|
/// <returns>A <see cref="ProtocolStatus"/> with code <see cref="ProtocolStatusCode.ProtocolViolation"/>.</returns>
|
||||||
public static ProtocolStatus ProtocolViolation(string message)
|
public static ProtocolStatus ProtocolViolation(string message)
|
||||||
{
|
{
|
||||||
return new ProtocolStatus
|
return new ProtocolStatus
|
||||||
|
|||||||
@@ -1,20 +0,0 @@
|
|||||||
namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Bounded classification of an alarm-provider switch, used as the low-cardinality
|
|
||||||
/// <c>reason</c> tag on the <c>mxgateway.alarms.provider_switches</c> counter. The
|
|
||||||
/// worker supplies a free-text reason (e.g. <c>"primary PollOnce failed"</c>) that
|
|
||||||
/// stays in the structured log; only this bounded value reaches the metric tag so the
|
|
||||||
/// time series cannot fan out on operation-specific text.
|
|
||||||
/// </summary>
|
|
||||||
public enum AlarmProviderSwitchReason
|
|
||||||
{
|
|
||||||
/// <summary>The switch direction could not be classified.</summary>
|
|
||||||
Unknown = 0,
|
|
||||||
|
|
||||||
/// <summary>Switched from the primary (alarmmgr) provider to the subtag standby — degraded.</summary>
|
|
||||||
Failover = 1,
|
|
||||||
|
|
||||||
/// <summary>Switched back from the subtag standby to the primary (alarmmgr) provider — recovered.</summary>
|
|
||||||
Failback = 2,
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
using System.Collections.Concurrent;
|
using System.Collections.Concurrent;
|
||||||
using System.Diagnostics.Metrics;
|
using System.Diagnostics.Metrics;
|
||||||
using System.Globalization;
|
|
||||||
|
|
||||||
namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
namespace ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||||
|
|
||||||
@@ -23,7 +22,6 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
private readonly Counter<long> _heartbeatFailuresCounter;
|
private readonly Counter<long> _heartbeatFailuresCounter;
|
||||||
private readonly Counter<long> _streamDisconnectsCounter;
|
private readonly Counter<long> _streamDisconnectsCounter;
|
||||||
private readonly Counter<long> _retryAttemptsCounter;
|
private readonly Counter<long> _retryAttemptsCounter;
|
||||||
private readonly Counter<long> _alarmProviderSwitchesCounter;
|
|
||||||
private readonly Histogram<double> _workerStartupLatencyHistogram;
|
private readonly Histogram<double> _workerStartupLatencyHistogram;
|
||||||
private readonly Histogram<double> _commandLatencyHistogram;
|
private readonly Histogram<double> _commandLatencyHistogram;
|
||||||
private readonly Histogram<double> _eventStreamSendLatencyHistogram;
|
private readonly Histogram<double> _eventStreamSendLatencyHistogram;
|
||||||
@@ -36,7 +34,6 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
private int _workersRunning;
|
private int _workersRunning;
|
||||||
private int _workerEventQueueDepth;
|
private int _workerEventQueueDepth;
|
||||||
private int _grpcEventStreamQueueDepth;
|
private int _grpcEventStreamQueueDepth;
|
||||||
private int _alarmProviderMode;
|
|
||||||
private long _sessionsOpened;
|
private long _sessionsOpened;
|
||||||
private long _sessionsClosed;
|
private long _sessionsClosed;
|
||||||
private long _commandsStarted;
|
private long _commandsStarted;
|
||||||
@@ -50,7 +47,6 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
private long _heartbeatFailures;
|
private long _heartbeatFailures;
|
||||||
private long _streamDisconnects;
|
private long _streamDisconnects;
|
||||||
private long _retryAttempts;
|
private long _retryAttempts;
|
||||||
private long _alarmProviderSwitches;
|
|
||||||
private bool _disposed;
|
private bool _disposed;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -72,7 +68,6 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
_heartbeatFailuresCounter = _meter.CreateCounter<long>("mxgateway.heartbeats.failed");
|
||||||
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
_streamDisconnectsCounter = _meter.CreateCounter<long>("mxgateway.grpc.streams.disconnected");
|
||||||
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
_retryAttemptsCounter = _meter.CreateCounter<long>("mxgateway.retries.attempted");
|
||||||
_alarmProviderSwitchesCounter = _meter.CreateCounter<long>("mxgateway.alarms.provider_switches");
|
|
||||||
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
_workerStartupLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.workers.startup.duration", "s");
|
||||||
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
_commandLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.commands.duration", "s");
|
||||||
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
_eventStreamSendLatencyHistogram = _meter.CreateHistogram<double>("mxgateway.events.stream_send.duration", "s");
|
||||||
@@ -81,7 +76,6 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
_meter.CreateObservableGauge("mxgateway.workers.running", GetWorkersRunning);
|
||||||
_meter.CreateObservableGauge("mxgateway.events.worker_queue.depth", GetWorkerEventQueueDepth);
|
_meter.CreateObservableGauge("mxgateway.events.worker_queue.depth", GetWorkerEventQueueDepth);
|
||||||
_meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth);
|
_meter.CreateObservableGauge("mxgateway.events.grpc_stream_queue.depth", GetGrpcEventStreamQueueDepth);
|
||||||
_meter.CreateObservableGauge("mxgateway.alarms.provider_mode", GetAlarmProviderMode);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -383,44 +377,10 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
_retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area));
|
_retryAttemptsCounter.Add(1, new KeyValuePair<string, object?>("area", area));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
|
||||||
/// Records that the alarm provider switched modes, increments the switch count, and updates the
|
|
||||||
/// current provider mode gauge.
|
|
||||||
/// </summary>
|
|
||||||
/// <param name="fromMode">Provider mode before the switch (1=alarmmgr, 2=subtag, 0=unknown).</param>
|
|
||||||
/// <param name="toMode">Provider mode after the switch (1=alarmmgr, 2=subtag, 0=unknown).</param>
|
|
||||||
/// <param name="reason">Bounded switch classification used as the counter's <c>reason</c> tag.</param>
|
|
||||||
public void AlarmProviderSwitched(int fromMode, int toMode, AlarmProviderSwitchReason reason)
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
_alarmProviderMode = toMode;
|
|
||||||
_alarmProviderSwitches++;
|
|
||||||
}
|
|
||||||
|
|
||||||
_alarmProviderSwitchesCounter.Add(
|
|
||||||
1,
|
|
||||||
new KeyValuePair<string, object?>("from", fromMode.ToString(CultureInfo.InvariantCulture)),
|
|
||||||
new KeyValuePair<string, object?>("to", toMode.ToString(CultureInfo.InvariantCulture)),
|
|
||||||
new KeyValuePair<string, object?>("reason", ReasonTag(reason)));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string ReasonTag(AlarmProviderSwitchReason reason) => reason switch
|
|
||||||
{
|
|
||||||
AlarmProviderSwitchReason.Failover => "failover",
|
|
||||||
AlarmProviderSwitchReason.Failback => "failback",
|
|
||||||
_ => "unknown",
|
|
||||||
};
|
|
||||||
|
|
||||||
/// <summary>Sets the current alarm provider-mode gauge without recording a switch (e.g. startup baseline).</summary>
|
|
||||||
public void SetAlarmProviderMode(int mode)
|
|
||||||
{
|
|
||||||
lock (_syncRoot) { _alarmProviderMode = mode; }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Returns a snapshot of all current metric values.
|
/// Returns a snapshot of all current metric values.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
/// <returns>A consistent snapshot of the current metric counters and gauges.</returns>
|
||||||
public GatewayMetricsSnapshot GetSnapshot()
|
public GatewayMetricsSnapshot GetSnapshot()
|
||||||
{
|
{
|
||||||
lock (_syncRoot)
|
lock (_syncRoot)
|
||||||
@@ -443,7 +403,6 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
HeartbeatFailures: _heartbeatFailures,
|
HeartbeatFailures: _heartbeatFailures,
|
||||||
StreamDisconnects: _streamDisconnects,
|
StreamDisconnects: _streamDisconnects,
|
||||||
RetryAttempts: _retryAttempts,
|
RetryAttempts: _retryAttempts,
|
||||||
AlarmProviderSwitchCount: _alarmProviderSwitches,
|
|
||||||
CommandFailuresByMethod: new Dictionary<string, long>(_commandFailuresByMethod, StringComparer.OrdinalIgnoreCase),
|
CommandFailuresByMethod: new Dictionary<string, long>(_commandFailuresByMethod, StringComparer.OrdinalIgnoreCase),
|
||||||
EventsByFamily: new Dictionary<string, long>(_eventsByFamily, StringComparer.OrdinalIgnoreCase),
|
EventsByFamily: new Dictionary<string, long>(_eventsByFamily, StringComparer.OrdinalIgnoreCase),
|
||||||
EventsBySession: new Dictionary<string, long>(_eventsBySession, StringComparer.Ordinal),
|
EventsBySession: new Dictionary<string, long>(_eventsBySession, StringComparer.Ordinal),
|
||||||
@@ -497,14 +456,6 @@ public sealed class GatewayMetrics : IDisposable
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private int GetAlarmProviderMode()
|
|
||||||
{
|
|
||||||
lock (_syncRoot)
|
|
||||||
{
|
|
||||||
return _alarmProviderMode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void Increment(Dictionary<string, long> values, string key)
|
private static void Increment(Dictionary<string, long> values, string key)
|
||||||
{
|
{
|
||||||
values.TryGetValue(key, out long currentValue);
|
values.TryGetValue(key, out long currentValue);
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ public sealed record GatewayMetricsSnapshot(
|
|||||||
long HeartbeatFailures,
|
long HeartbeatFailures,
|
||||||
long StreamDisconnects,
|
long StreamDisconnects,
|
||||||
long RetryAttempts,
|
long RetryAttempts,
|
||||||
long AlarmProviderSwitchCount,
|
|
||||||
IReadOnlyDictionary<string, long> CommandFailuresByMethod,
|
IReadOnlyDictionary<string, long> CommandFailuresByMethod,
|
||||||
IReadOnlyDictionary<string, long> EventsByFamily,
|
IReadOnlyDictionary<string, long> EventsByFamily,
|
||||||
IReadOnlyDictionary<string, long> EventsBySession,
|
IReadOnlyDictionary<string, long> EventsBySession,
|
||||||
|
|||||||
@@ -19,7 +19,10 @@ public sealed class CanonicalAuditWriter(
|
|||||||
SqliteCanonicalAuditStore store,
|
SqliteCanonicalAuditStore store,
|
||||||
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
|
ILogger<CanonicalAuditWriter> logger) : IAuditWriter
|
||||||
{
|
{
|
||||||
/// <inheritdoc />
|
/// <summary>Persists the audit event to the canonical store; swallows and logs any write failure rather than propagating it.</summary>
|
||||||
|
/// <param name="auditEvent">The audit event to persist.</param>
|
||||||
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
public async Task WriteAsync(AuditEvent auditEvent, CancellationToken cancellationToken = default)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(auditEvent);
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
||||||
|
|||||||
+8
-2
@@ -43,7 +43,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
|
|||||||
/// <summary>The library's keyless schema-init event type.</summary>
|
/// <summary>The library's keyless schema-init event type.</summary>
|
||||||
private const string InitDbEventType = "init-db";
|
private const string InitDbEventType = "init-db";
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Converts the library audit entry to a canonical <see cref="AuditEvent"/> and forwards it through the gateway's audit writer.</summary>
|
||||||
|
/// <param name="entry">The API key audit entry to append.</param>
|
||||||
|
/// <param name="ct">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
|
public async Task AppendAsync(ApiKeyAuditEntry entry, CancellationToken ct)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(entry);
|
ArgumentNullException.ThrowIfNull(entry);
|
||||||
@@ -71,7 +74,10 @@ public sealed class CanonicalForwardingApiKeyAuditStore(
|
|||||||
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
|
await auditWriter.WriteAsync(auditEvent, ct).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>Reads recent audit events from the canonical store and maps them back to library-compatible <see cref="ApiKeyAuditEntry"/> records.</summary>
|
||||||
|
/// <param name="limit">Maximum number of entries to return.</param>
|
||||||
|
/// <param name="ct">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that resolves to the most recent audit entries, up to <paramref name="limit"/> items.</returns>
|
||||||
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
public async Task<IReadOnlyList<ApiKeyAuditEntry>> ListRecentAsync(int limit, CancellationToken ct)
|
||||||
{
|
{
|
||||||
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
|
IReadOnlyList<AuditEvent> events = await store.ListRecentAsync(limit, ct).ConfigureAwait(false);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
|
|||||||
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
|
/// <summary>Inserts a canonical audit event into the <c>audit_event</c> table.</summary>
|
||||||
/// <param name="auditEvent">The canonical event to persist.</param>
|
/// <param name="auditEvent">The canonical event to persist.</param>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
|
public async Task InsertAsync(AuditEvent auditEvent, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(auditEvent);
|
ArgumentNullException.ThrowIfNull(auditEvent);
|
||||||
@@ -79,6 +80,7 @@ public sealed class SqliteCanonicalAuditStore(AuthSqliteConnectionFactory connec
|
|||||||
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
|
/// <summary>Returns the most recent canonical audit events, newest first.</summary>
|
||||||
/// <param name="limit">Maximum number of events to return.</param>
|
/// <param name="limit">Maximum number of events to return.</param>
|
||||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||||
|
/// <returns>A task that resolves to the most recent audit events, up to <paramref name="limit"/>.</returns>
|
||||||
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
|
public async Task<IReadOnlyList<AuditEvent>> ListRecentAsync(int limit, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (limit <= 0)
|
if (limit <= 0)
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public sealed class ApiKeyAdminCliRunner(ApiKeyAdminCommands commands)
|
|||||||
/// <param name="command">API key administration command to execute.</param>
|
/// <param name="command">API key administration command to execute.</param>
|
||||||
/// <param name="output">Text writer for command output.</param>
|
/// <param name="output">Text writer for command output.</param>
|
||||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||||
|
/// <returns>A task that resolves to the exit code (always 0 on success).</returns>
|
||||||
public async Task<int> RunAsync(
|
public async Task<int> RunAsync(
|
||||||
ApiKeyAdminCommand command,
|
ApiKeyAdminCommand command,
|
||||||
TextWriter output,
|
TextWriter output,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user