rename: prefix gateway projects/namespaces with ZB.MOM.WW + sln→slnx
Apply the ZB.MOM.WW. prefix to all gateway-side projects, folders,
.csproj/.sln contents, C# namespaces, using directives, generated proto
C# (csharp_namespace + checked-in generated files), InternalsVisibleTo
attributes, project-name string literals (LoadProject, .sln lookups,
worker exe paths, staticwebassets manifest), and the install/script/doc
references that point at any of the above. Migrate the solution from
.sln to .slnx via `dotnet sln migrate` and delete the old file.
External-runtime identifiers are intentionally NOT prefixed so external
configuration keeps working:
- GatewayMetrics.cs MeterName ("MxGateway.Server")
- DashboardAuthenticationDefaults Scheme/Policy ("MxGateway.Dashboard")
- GatewayRequestLoggingMiddleware logger category ("MxGateway.Request")
- StaRuntime thread name ("MxGateway.Worker.STA")
- appsettings.json root section "MxGateway" + env-var prefix
MxGateway__... and secret-name MxGateway:ApiKeyPepper
- C:\ProgramData\MxGateway\ data dir paths
Also fixes two tests that were not rename-related but became visible
while validating the rename:
- WorkerLiveMxAccessSmokeTests.ShutDownAsync: cancellation that the
gateway service correctly maps to RpcException(Cancelled) per gRPC
convention was being misclassified as a stream fault. Added a sibling
catch on RpcException with StatusCode.Cancelled.
- IntegrationTestEnvironment.ResolveRepositoryRoot: extracted IsRepositoryRoot
and made it accept either a .git marker OR a .sln/.slnx next to src/
so the worker-exe walker works in non-git working copies.
clients/proto/proto-inputs.json's protoRoot updated to point at
src/ZB.MOM.WW.MxGateway.Contracts/Protos.
Verified by `dotnet build` and a full `dotnet test` of the .slnx with
MXGATEWAY_RUN_LIVE_{MXACCESS,LDAP,GALAXY}_TESTS=1:
Tests: 472/472 pass
Worker.Tests: 280/280 pass (4 dev-rig [Fact(Skip=...)] skipped)
IntegrationTests: 18/18 pass
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,22 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
|
||||
/// <summary>Service-collection wiring for the gateway's central alarm monitor.</summary>
|
||||
public static class AlarmsServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers the always-on <see cref="GatewayAlarmMonitor"/> as both
|
||||
/// the <see cref="IGatewayAlarmService"/> singleton and a hosted
|
||||
/// service, so it starts with the gateway host and is shared by the
|
||||
/// gRPC alarm surface and the dashboard.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to register services in.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGatewayAlarms(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<GatewayAlarmMonitor>();
|
||||
services.AddSingleton<IGatewayAlarmService>(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||
services.AddHostedService(provider => provider.GetRequiredService<GatewayAlarmMonitor>());
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,693 @@
|
||||
using System.Threading.Channels;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
|
||||
/// <summary>
|
||||
/// The gateway's always-on alarm monitor and broker. It owns one
|
||||
/// gateway-managed worker session dedicated to alarms, keeps an in-process
|
||||
/// cache of the active-alarm set fed by that session's transition events
|
||||
/// (reconciled periodically against the worker's snapshot), and fans the
|
||||
/// feed out to any number of <see cref="StreamAsync"/> subscribers.
|
||||
/// The session is re-opened transparently if the worker faults.
|
||||
/// </summary>
|
||||
public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmService
|
||||
{
|
||||
private const string MonitorClientName = "gateway-alarm-monitor";
|
||||
private const string BackendName = "Galaxy";
|
||||
private const int SubscriberQueueCapacity = 2048;
|
||||
private static readonly TimeSpan RestartBackoff = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan StartupGrace = TimeSpan.FromSeconds(2);
|
||||
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly AlarmsOptions _options;
|
||||
private readonly ILogger<GatewayAlarmMonitor> _logger;
|
||||
|
||||
private readonly object _sync = new();
|
||||
private readonly Dictionary<string, ActiveAlarmSnapshot> _alarms = new(StringComparer.Ordinal);
|
||||
private readonly List<Subscriber> _subscribers = [];
|
||||
|
||||
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
|
||||
private volatile string? _lastError;
|
||||
private GatewaySession? _session;
|
||||
|
||||
/// <summary>Initializes the gateway alarm monitor.</summary>
|
||||
/// <param name="sessionManager">Gateway session manager.</param>
|
||||
/// <param name="options">Gateway options carrying the alarm configuration.</param>
|
||||
/// <param name="logger">Diagnostic logger.</param>
|
||||
public GatewayAlarmMonitor(
|
||||
ISessionManager sessionManager,
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<GatewayAlarmMonitor> logger)
|
||||
{
|
||||
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||
_options = (options ?? throw new ArgumentNullException(nameof(options))).Value.Alarms;
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public GatewayAlarmMonitorState State => _state;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? LastError => _lastError;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? WorkerProcessId
|
||||
{
|
||||
get { lock (_sync) { return _session?.WorkerProcessId; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
return _alarms.Values.Select(alarm => alarm.Clone()).ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
if (!_options.Enabled)
|
||||
{
|
||||
_state = GatewayAlarmMonitorState.Disabled;
|
||||
_logger.LogInformation("Gateway alarm monitor disabled (MxGateway:Alarms:Enabled is false).");
|
||||
return;
|
||||
}
|
||||
|
||||
string subscription = ResolveSubscription();
|
||||
if (string.IsNullOrWhiteSpace(subscription))
|
||||
{
|
||||
_state = GatewayAlarmMonitorState.Faulted;
|
||||
_lastError = "MxGateway:Alarms is enabled but no SubscriptionExpression / DefaultArea is configured.";
|
||||
_logger.LogError("{Diagnostic}", _lastError);
|
||||
return;
|
||||
}
|
||||
|
||||
// Brief grace so worker-process launching and startup orphan cleanup
|
||||
// settle before the monitor opens its own session.
|
||||
try
|
||||
{
|
||||
await Task.Delay(StartupGrace, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
while (!stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
try
|
||||
{
|
||||
await RunMonitorAsync(subscription, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
break;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_state = GatewayAlarmMonitorState.Faulted;
|
||||
_lastError = exception.Message;
|
||||
_logger.LogWarning(
|
||||
exception,
|
||||
"Gateway alarm monitor lifecycle faulted; restarting in {Backoff}.",
|
||||
RestartBackoff);
|
||||
try
|
||||
{
|
||||
await Task.Delay(RestartBackoff, stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_state = GatewayAlarmMonitorState.Disabled;
|
||||
}
|
||||
|
||||
// One monitoring lifecycle: open a session, subscribe alarms, reconcile,
|
||||
// then consume transition events until the session ends or is cancelled.
|
||||
private async Task RunMonitorAsync(string subscription, CancellationToken stoppingToken)
|
||||
{
|
||||
_state = GatewayAlarmMonitorState.Starting;
|
||||
GatewaySession session = await _sessionManager.OpenSessionAsync(
|
||||
new SessionOpenRequest(BackendName, MonitorClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
|
||||
MonitorClientName,
|
||||
stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
lock (_sync) { _session = session; }
|
||||
|
||||
try
|
||||
{
|
||||
await SubscribeAlarmsAsync(session.SessionId, subscription, stoppingToken).ConfigureAwait(false);
|
||||
await ReconcileAsync(session.SessionId, stoppingToken).ConfigureAwait(false);
|
||||
|
||||
_state = GatewayAlarmMonitorState.Monitoring;
|
||||
_lastError = null;
|
||||
_logger.LogInformation(
|
||||
"Gateway alarm monitor active on {Subscription} (session {SessionId}, worker pid {WorkerPid}).",
|
||||
subscription,
|
||||
session.SessionId,
|
||||
session.WorkerProcessId);
|
||||
|
||||
using CancellationTokenSource linked = CancellationTokenSource.CreateLinkedTokenSource(stoppingToken);
|
||||
Task reconcileLoop = ReconcileLoopAsync(session.SessionId, linked.Token);
|
||||
try
|
||||
{
|
||||
await foreach (WorkerEvent workerEvent in _sessionManager
|
||||
.ReadEventsAsync(session.SessionId, linked.Token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
MxEvent? mxEvent = workerEvent.Event;
|
||||
if (mxEvent is { BodyCase: MxEvent.BodyOneofCase.OnAlarmTransition }
|
||||
&& mxEvent.OnAlarmTransition is not null)
|
||||
{
|
||||
ApplyTransition(mxEvent.OnAlarmTransition);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await linked.CancelAsync().ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await reconcileLoop.ConfigureAwait(false);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Reconcile-loop teardown errors are not actionable here.
|
||||
}
|
||||
}
|
||||
|
||||
// The event stream ended without cancellation — the worker session
|
||||
// closed or faulted. Surface it so the supervisor loop restarts.
|
||||
throw new InvalidOperationException("Alarm monitor worker event stream ended.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_sync) { _session = null; }
|
||||
ClearCache();
|
||||
try
|
||||
{
|
||||
await _sessionManager.CloseSessionAsync(session.SessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "Closing alarm monitor session {SessionId} failed.", session.SessionId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async Task SubscribeAlarmsAsync(string sessionId, string subscription, CancellationToken cancellationToken)
|
||||
{
|
||||
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
||||
sessionId,
|
||||
new WorkerCommand
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.SubscribeAlarms,
|
||||
SubscribeAlarms = new SubscribeAlarmsCommand { SubscriptionExpression = subscription },
|
||||
},
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
ProtocolStatusCode? code = reply.Reply?.ProtocolStatus?.Code;
|
||||
if (code != ProtocolStatusCode.Ok)
|
||||
{
|
||||
string diagnostic = reply.Reply?.DiagnosticMessage
|
||||
?? reply.Reply?.ProtocolStatus?.Message
|
||||
?? $"status {code}";
|
||||
throw new InvalidOperationException($"Worker rejected SubscribeAlarms: {diagnostic}");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconcileLoopAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
int seconds = Math.Max(5, _options.ReconcileIntervalSeconds);
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(seconds));
|
||||
while (await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await ReconcileAsync(sessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "Alarm reconcile pass failed; keeping the current cache.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task ReconcileAsync(string sessionId, CancellationToken cancellationToken)
|
||||
{
|
||||
WorkerCommandReply reply = await _sessionManager.InvokeAsync(
|
||||
sessionId,
|
||||
new WorkerCommand
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.QueryActiveAlarms,
|
||||
QueryActiveAlarmsCommand = new QueryActiveAlarmsCommand { AlarmFilterPrefix = string.Empty },
|
||||
},
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (reply.Reply?.ProtocolStatus?.Code != ProtocolStatusCode.Ok)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms;
|
||||
if (payload is not null)
|
||||
{
|
||||
ApplyReconcile(payload.Snapshots);
|
||||
}
|
||||
}
|
||||
|
||||
// Applies a live transition to the cache and broadcasts it to subscribers.
|
||||
private void ApplyTransition(OnAlarmTransitionEvent transition)
|
||||
{
|
||||
string reference = transition.AlarmFullReference ?? string.Empty;
|
||||
if (reference.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
if (transition.TransitionKind == AlarmTransitionKind.Clear)
|
||||
{
|
||||
_alarms.Remove(reference);
|
||||
}
|
||||
else
|
||||
{
|
||||
_alarms[reference] = SnapshotFromTransition(transition);
|
||||
}
|
||||
|
||||
Broadcast(new AlarmFeedMessage { Transition = transition }, reference);
|
||||
}
|
||||
}
|
||||
|
||||
// Replaces the cache with the worker's authoritative snapshot, broadcasting
|
||||
// a synthetic transition for any alarm the live stream missed.
|
||||
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots)
|
||||
{
|
||||
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
|
||||
foreach (ActiveAlarmSnapshot snapshot in snapshots)
|
||||
{
|
||||
if (!string.IsNullOrEmpty(snapshot.AlarmFullReference))
|
||||
{
|
||||
next[snapshot.AlarmFullReference] = snapshot;
|
||||
}
|
||||
}
|
||||
|
||||
lock (_sync)
|
||||
{
|
||||
foreach (KeyValuePair<string, ActiveAlarmSnapshot> existing in _alarms)
|
||||
{
|
||||
if (!next.ContainsKey(existing.Key))
|
||||
{
|
||||
Broadcast(
|
||||
new AlarmFeedMessage { Transition = TransitionFromSnapshot(existing.Value, AlarmTransitionKind.Clear) },
|
||||
existing.Key);
|
||||
}
|
||||
}
|
||||
|
||||
foreach (KeyValuePair<string, ActiveAlarmSnapshot> incoming in next)
|
||||
{
|
||||
if (!_alarms.ContainsKey(incoming.Key))
|
||||
{
|
||||
Broadcast(
|
||||
new AlarmFeedMessage { Transition = TransitionFromSnapshot(incoming.Value, AlarmTransitionKind.Raise) },
|
||||
incoming.Key);
|
||||
}
|
||||
}
|
||||
|
||||
_alarms.Clear();
|
||||
foreach (KeyValuePair<string, ActiveAlarmSnapshot> incoming in next)
|
||||
{
|
||||
_alarms[incoming.Key] = incoming.Value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Caller holds _sync. Pushes a feed message to every matching subscriber;
|
||||
// a subscriber that has fallen behind is completed with an error and dropped.
|
||||
private void Broadcast(AlarmFeedMessage message, string reference)
|
||||
{
|
||||
for (int index = _subscribers.Count - 1; index >= 0; index--)
|
||||
{
|
||||
Subscriber subscriber = _subscribers[index];
|
||||
if (!subscriber.Matches(reference))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
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()
|
||||
{
|
||||
lock (_sync)
|
||||
{
|
||||
_alarms.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
string prefix = alarmFilterPrefix ?? string.Empty;
|
||||
Channel<AlarmFeedMessage> channel = Channel.CreateBounded<AlarmFeedMessage>(
|
||||
new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.Wait,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
Subscriber subscriber = new(channel, prefix);
|
||||
|
||||
ActiveAlarmSnapshot[] snapshot;
|
||||
lock (_sync)
|
||||
{
|
||||
// Register before snapshotting under the same lock so no transition
|
||||
// can slip between the snapshot and the live stream.
|
||||
_subscribers.Add(subscriber);
|
||||
snapshot = _alarms.Values
|
||||
.Where(alarm => prefix.Length == 0
|
||||
|| alarm.AlarmFullReference.StartsWith(prefix, StringComparison.Ordinal))
|
||||
.Select(alarm => alarm.Clone())
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
foreach (ActiveAlarmSnapshot alarm in snapshot)
|
||||
{
|
||||
yield return new AlarmFeedMessage { ActiveAlarm = alarm };
|
||||
}
|
||||
|
||||
yield return new AlarmFeedMessage { SnapshotComplete = true };
|
||||
|
||||
await foreach (AlarmFeedMessage message in channel.Reader
|
||||
.ReadAllAsync(cancellationToken)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
yield return message;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
lock (_sync) { _subscribers.Remove(subscriber); }
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
string? sessionId;
|
||||
lock (_sync) { sessionId = _session?.SessionId; }
|
||||
if (sessionId is null || _state != GatewayAlarmMonitorState.Monitoring)
|
||||
{
|
||||
return new AcknowledgeAlarmReply
|
||||
{
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.WorkerUnavailable,
|
||||
Message = "Gateway alarm monitor is not currently active.",
|
||||
},
|
||||
DiagnosticMessage = _lastError ?? "Alarm monitor is not running.",
|
||||
};
|
||||
}
|
||||
|
||||
MxCommand? command = BuildAcknowledgeCommand(request, out string? parseError);
|
||||
if (command is null)
|
||||
{
|
||||
return new AcknowledgeAlarmReply
|
||||
{
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.InvalidRequest,
|
||||
Message = parseError ?? "Invalid acknowledge request.",
|
||||
},
|
||||
DiagnosticMessage = parseError ?? "Invalid acknowledge request.",
|
||||
};
|
||||
}
|
||||
|
||||
WorkerCommandReply workerReply = await _sessionManager
|
||||
.InvokeAsync(sessionId, new WorkerCommand { Command = command }, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
MxCommandReply mxReply = workerReply.Reply ?? new MxCommandReply
|
||||
{
|
||||
ProtocolStatus = new ProtocolStatus
|
||||
{
|
||||
Code = ProtocolStatusCode.ProtocolViolation,
|
||||
Message = "Worker reply did not include an MxCommandReply.",
|
||||
},
|
||||
};
|
||||
|
||||
AcknowledgeAlarmReply reply = new()
|
||||
{
|
||||
CorrelationId = request.ClientCorrelationId,
|
||||
ProtocolStatus = mxReply.ProtocolStatus ?? new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
DiagnosticMessage = mxReply.DiagnosticMessage ?? string.Empty,
|
||||
};
|
||||
if (mxReply.HasHresult)
|
||||
{
|
||||
reply.Hresult = mxReply.Hresult;
|
||||
}
|
||||
|
||||
return reply;
|
||||
}
|
||||
|
||||
private string ResolveSubscription()
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(_options.SubscriptionExpression))
|
||||
{
|
||||
return _options.SubscriptionExpression;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(_options.DefaultArea))
|
||||
{
|
||||
return $@"\\{Environment.MachineName}\Galaxy!{_options.DefaultArea}";
|
||||
}
|
||||
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private static MxCommand? BuildAcknowledgeCommand(AcknowledgeAlarmRequest request, out string? parseError)
|
||||
{
|
||||
parseError = null;
|
||||
if (string.IsNullOrWhiteSpace(request.AlarmFullReference))
|
||||
{
|
||||
parseError = "alarm_full_reference is required.";
|
||||
return null;
|
||||
}
|
||||
|
||||
string comment = request.Comment ?? string.Empty;
|
||||
string operatorUser = request.OperatorUser ?? string.Empty;
|
||||
|
||||
if (Guid.TryParse(request.AlarmFullReference, out Guid guid))
|
||||
{
|
||||
return new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarm,
|
||||
AcknowledgeAlarmCommand = new AcknowledgeAlarmCommand
|
||||
{
|
||||
AlarmGuid = guid.ToString(),
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (TryParseAlarmReference(request.AlarmFullReference, out string provider, out string group, out string alarm))
|
||||
{
|
||||
return new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AcknowledgeAlarmByName,
|
||||
AcknowledgeAlarmByNameCommand = new AcknowledgeAlarmByNameCommand
|
||||
{
|
||||
AlarmName = alarm,
|
||||
ProviderName = provider,
|
||||
GroupName = group,
|
||||
Comment = comment,
|
||||
OperatorUser = operatorUser,
|
||||
OperatorNode = string.Empty,
|
||||
OperatorDomain = string.Empty,
|
||||
OperatorFullName = string.Empty,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
parseError = "alarm_full_reference must be a canonical GUID or 'Provider!Group.Tag' format.";
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses an alarm reference of the form <c>Provider!Group.Tag</c>: the
|
||||
/// first <c>!</c> splits provider from <c>Group.Tag</c>; the first
|
||||
/// <c>.</c> after the <c>!</c> splits group from tag.
|
||||
/// </summary>
|
||||
/// <param name="reference">The full alarm reference.</param>
|
||||
/// <param name="providerName">The parsed provider.</param>
|
||||
/// <param name="groupName">The parsed group/area.</param>
|
||||
/// <param name="alarmName">The parsed tag/alarm name.</param>
|
||||
/// <returns>true on a well-formed reference; otherwise false.</returns>
|
||||
public static bool TryParseAlarmReference(
|
||||
string? reference,
|
||||
out string providerName,
|
||||
out string groupName,
|
||||
out string alarmName)
|
||||
{
|
||||
providerName = string.Empty;
|
||||
groupName = string.Empty;
|
||||
alarmName = string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(reference))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
int bang = reference!.IndexOf('!', StringComparison.Ordinal);
|
||||
if (bang <= 0 || bang == reference.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string left = reference[..bang];
|
||||
string right = reference[(bang + 1)..];
|
||||
int dot = right.IndexOf('.', StringComparison.Ordinal);
|
||||
if (dot <= 0 || dot == right.Length - 1)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
providerName = left;
|
||||
groupName = right[..dot];
|
||||
alarmName = right[(dot + 1)..];
|
||||
return true;
|
||||
}
|
||||
|
||||
private static ActiveAlarmSnapshot SnapshotFromTransition(OnAlarmTransitionEvent transition)
|
||||
{
|
||||
ActiveAlarmSnapshot snapshot = new()
|
||||
{
|
||||
AlarmFullReference = transition.AlarmFullReference,
|
||||
SourceObjectReference = transition.SourceObjectReference,
|
||||
AlarmTypeName = transition.AlarmTypeName,
|
||||
Severity = transition.Severity,
|
||||
CurrentState = transition.TransitionKind == AlarmTransitionKind.Acknowledge
|
||||
? AlarmConditionState.ActiveAcked
|
||||
: AlarmConditionState.Active,
|
||||
Category = transition.Category,
|
||||
Description = transition.Description,
|
||||
OperatorUser = transition.OperatorUser,
|
||||
OperatorComment = transition.OperatorComment,
|
||||
};
|
||||
if (transition.OriginalRaiseTimestamp is not null)
|
||||
{
|
||||
snapshot.OriginalRaiseTimestamp = transition.OriginalRaiseTimestamp;
|
||||
}
|
||||
if (transition.TransitionTimestamp is not null)
|
||||
{
|
||||
snapshot.LastTransitionTimestamp = transition.TransitionTimestamp;
|
||||
}
|
||||
if (transition.CurrentValue is not null)
|
||||
{
|
||||
snapshot.CurrentValue = transition.CurrentValue;
|
||||
}
|
||||
if (transition.LimitValue is not null)
|
||||
{
|
||||
snapshot.LimitValue = transition.LimitValue;
|
||||
}
|
||||
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
private static OnAlarmTransitionEvent TransitionFromSnapshot(
|
||||
ActiveAlarmSnapshot snapshot,
|
||||
AlarmTransitionKind kind)
|
||||
{
|
||||
OnAlarmTransitionEvent transition = new()
|
||||
{
|
||||
AlarmFullReference = snapshot.AlarmFullReference,
|
||||
SourceObjectReference = snapshot.SourceObjectReference,
|
||||
AlarmTypeName = snapshot.AlarmTypeName,
|
||||
TransitionKind = kind,
|
||||
Severity = snapshot.Severity,
|
||||
Category = snapshot.Category,
|
||||
Description = snapshot.Description,
|
||||
OperatorUser = snapshot.OperatorUser,
|
||||
OperatorComment = snapshot.OperatorComment,
|
||||
};
|
||||
if (snapshot.OriginalRaiseTimestamp is not null)
|
||||
{
|
||||
transition.OriginalRaiseTimestamp = snapshot.OriginalRaiseTimestamp;
|
||||
}
|
||||
if (snapshot.LastTransitionTimestamp is not null)
|
||||
{
|
||||
transition.TransitionTimestamp = snapshot.LastTransitionTimestamp;
|
||||
}
|
||||
if (snapshot.CurrentValue is not null)
|
||||
{
|
||||
transition.CurrentValue = snapshot.CurrentValue;
|
||||
}
|
||||
if (snapshot.LimitValue is not null)
|
||||
{
|
||||
transition.LimitValue = snapshot.LimitValue;
|
||||
}
|
||||
|
||||
return transition;
|
||||
}
|
||||
|
||||
private sealed class Subscriber(Channel<AlarmFeedMessage> channel, string prefix)
|
||||
{
|
||||
public Channel<AlarmFeedMessage> Channel { get; } = channel;
|
||||
|
||||
public bool Matches(string reference)
|
||||
{
|
||||
return prefix.Length == 0 || reference.StartsWith(prefix, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
|
||||
/// <summary>Lifecycle state of the gateway's central alarm monitor.</summary>
|
||||
public enum GatewayAlarmMonitorState
|
||||
{
|
||||
/// <summary>Alarm monitoring is switched off (<c>MxGateway:Alarms:Enabled</c> is false).</summary>
|
||||
Disabled,
|
||||
|
||||
/// <summary>The monitor is opening or re-opening its worker session.</summary>
|
||||
Starting,
|
||||
|
||||
/// <summary>The monitor is connected and tracking the active-alarm set.</summary>
|
||||
Monitoring,
|
||||
|
||||
/// <summary>The monitor's last lifecycle attempt failed; a restart is pending.</summary>
|
||||
Faulted,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The gateway's always-on alarm broker. A single gateway-owned worker
|
||||
/// session monitors the AVEVA alarm provider; this service caches the
|
||||
/// current active-alarm set and fans it out to any number of clients —
|
||||
/// no client needs to open its own worker session to see alarms.
|
||||
/// </summary>
|
||||
public interface IGatewayAlarmService
|
||||
{
|
||||
/// <summary>Current monitor lifecycle state.</summary>
|
||||
GatewayAlarmMonitorState State { get; }
|
||||
|
||||
/// <summary>Diagnostic message from the most recent fault, or null.</summary>
|
||||
string? LastError { get; }
|
||||
|
||||
/// <summary>Process id of the worker backing the monitor, when one is attached.</summary>
|
||||
int? WorkerProcessId { get; }
|
||||
|
||||
/// <summary>A point-in-time copy of the current active-alarm set.</summary>
|
||||
IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Attaches to the central alarm feed. The returned stream yields one
|
||||
/// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a
|
||||
/// single <c>snapshot_complete</c> sentinel, then a <c>transition</c>
|
||||
/// for every subsequent change.
|
||||
/// </summary>
|
||||
/// <param name="alarmFilterPrefix">Optional alarm-reference prefix scoping the feed.</param>
|
||||
/// <param name="cancellationToken">Token that ends the subscription.</param>
|
||||
IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Acknowledges an alarm through the monitor's worker session. Never
|
||||
/// throws — transport and monitor-state failures surface in the
|
||||
/// reply's <see cref="AcknowledgeAlarmReply.ProtocolStatus"/>.
|
||||
/// </summary>
|
||||
/// <param name="request">The acknowledge request.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the call.</param>
|
||||
Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration for the gateway's always-on central alarm monitor
|
||||
/// (<see cref="Alarms.GatewayAlarmMonitor"/>). When <see cref="Enabled"/>
|
||||
/// is true the gateway opens one gateway-owned worker session dedicated to
|
||||
/// alarms, caches the active-alarm set, and fans it out to every client
|
||||
/// through the <c>StreamAlarms</c> RPC — no client opens its own session
|
||||
/// to see alarms.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults preserve current behaviour (alarm monitoring disabled).
|
||||
/// Operators opt in by setting <c>MxGateway:Alarms:Enabled = true</c> and
|
||||
/// supplying a canonical <c>\\<machine>\Galaxy!<area></c>
|
||||
/// subscription expression. The literal "Galaxy" provider is correct
|
||||
/// regardless of the configured Galaxy database name (the wnwrap consumer
|
||||
/// does not accept the database name as the provider).
|
||||
/// </remarks>
|
||||
public sealed class AlarmsOptions
|
||||
{
|
||||
/// <summary>Gate the gateway's always-on central alarm monitor. Default false.</summary>
|
||||
public bool Enabled { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// AVEVA alarm-subscription expression the monitor subscribes on
|
||||
/// startup. When empty and <see cref="Enabled"/> is true, the gateway
|
||||
/// falls back to <c>\\$(MachineName)\Galaxy!$(DefaultArea)</c> if
|
||||
/// <see cref="DefaultArea"/> is set; otherwise the monitor faults with
|
||||
/// a configuration diagnostic.
|
||||
/// </summary>
|
||||
public string SubscriptionExpression { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// Optional area name used to compose a default subscription when
|
||||
/// <see cref="SubscriptionExpression"/> is empty. Combined with
|
||||
/// <c>Environment.MachineName</c> as
|
||||
/// <c>\\<MachineName>\Galaxy!<DefaultArea></c>.
|
||||
/// </summary>
|
||||
public string DefaultArea { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// How often the monitor reconciles its in-process alarm cache against
|
||||
/// the worker's authoritative active-alarm snapshot, catching any
|
||||
/// transitions the live poll-and-diff feed missed. Default 30 seconds;
|
||||
/// the monitor floors it at 5 seconds.
|
||||
/// </summary>
|
||||
public int ReconcileIntervalSeconds { get; init; } = 30;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public enum AuthenticationMode
|
||||
{
|
||||
ApiKey,
|
||||
Disabled
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class AuthenticationOptions
|
||||
{
|
||||
/// <summary>Gets the authentication mode.</summary>
|
||||
public AuthenticationMode Mode { get; init; } = AuthenticationMode.ApiKey;
|
||||
|
||||
/// <summary>Gets the SQLite database path for authentication credentials.</summary>
|
||||
public string SqlitePath { get; init; } = @"C:\ProgramData\MxGateway\gateway-auth.db";
|
||||
|
||||
/// <summary>Gets the secret manager name for API key pepper.</summary>
|
||||
public string PepperSecretName { get; init; } = "MxGateway:ApiKeyPepper";
|
||||
|
||||
/// <summary>Gets whether database migrations should run on startup.</summary>
|
||||
public bool RunMigrationsOnStartup { get; init; } = true;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class DashboardOptions
|
||||
{
|
||||
/// <summary>Gets whether the dashboard is enabled.</summary>
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
/// <summary>Gets the dashboard URL path base.</summary>
|
||||
public string PathBase { get; init; } = "/dashboard";
|
||||
|
||||
/// <summary>Gets whether dashboard access requires admin scope.</summary>
|
||||
public bool RequireAdminScope { get; init; } = true;
|
||||
|
||||
/// <summary>Gets whether anonymous localhost access to dashboard is allowed.</summary>
|
||||
public bool AllowAnonymousLocalhost { get; init; } = true;
|
||||
|
||||
/// <summary>Gets the dashboard snapshot update interval in milliseconds.</summary>
|
||||
public int SnapshotIntervalMilliseconds { get; init; } = 1_000;
|
||||
|
||||
/// <summary>Gets the maximum number of recent faults to display.</summary>
|
||||
public int RecentFaultLimit { get; init; } = 100;
|
||||
|
||||
/// <summary>Gets the maximum number of recent sessions to display.</summary>
|
||||
public int RecentSessionLimit { get; init; } = 200;
|
||||
|
||||
/// <summary>Gets whether to show full tag values in the dashboard.</summary>
|
||||
public bool ShowTagValues { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveAuthenticationConfiguration(
|
||||
string Mode,
|
||||
string SqlitePath,
|
||||
string PepperSecretName,
|
||||
bool RunMigrationsOnStartup);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveDashboardConfiguration(
|
||||
bool Enabled,
|
||||
string PathBase,
|
||||
bool RequireAdminScope,
|
||||
bool AllowAnonymousLocalhost,
|
||||
int SnapshotIntervalMilliseconds,
|
||||
int RecentFaultLimit,
|
||||
int RecentSessionLimit,
|
||||
bool ShowTagValues);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveEventConfiguration(
|
||||
int QueueCapacity,
|
||||
string BackpressurePolicy);
|
||||
@@ -0,0 +1,10 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveGatewayConfiguration(
|
||||
EffectiveAuthenticationConfiguration Authentication,
|
||||
EffectiveLdapConfiguration Ldap,
|
||||
EffectiveWorkerConfiguration Worker,
|
||||
EffectiveSessionConfiguration Sessions,
|
||||
EffectiveEventConfiguration Events,
|
||||
EffectiveDashboardConfiguration Dashboard,
|
||||
EffectiveProtocolConfiguration Protocol);
|
||||
@@ -0,0 +1,15 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveLdapConfiguration(
|
||||
bool Enabled,
|
||||
string Server,
|
||||
int Port,
|
||||
bool UseTls,
|
||||
bool AllowInsecureLdap,
|
||||
string SearchBase,
|
||||
string ServiceAccountDn,
|
||||
string ServiceAccountPassword,
|
||||
string UserNameAttribute,
|
||||
string DisplayNameAttribute,
|
||||
string GroupAttribute,
|
||||
string RequiredGroup);
|
||||
@@ -0,0 +1,5 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveProtocolConfiguration(
|
||||
uint WorkerProtocolVersion,
|
||||
int MaxGrpcMessageBytes);
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveSessionConfiguration(
|
||||
int DefaultCommandTimeoutSeconds,
|
||||
int MaxSessions,
|
||||
int MaxPendingCommandsPerSession,
|
||||
int DefaultLeaseSeconds,
|
||||
int LeaseSweepIntervalSeconds,
|
||||
bool AllowMultipleEventSubscribers);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed record EffectiveWorkerConfiguration(
|
||||
string ExecutablePath,
|
||||
string? WorkingDirectory,
|
||||
string RequiredArchitecture,
|
||||
int StartupTimeoutSeconds,
|
||||
int ShutdownTimeoutSeconds,
|
||||
int HeartbeatIntervalSeconds,
|
||||
int HeartbeatGraceSeconds,
|
||||
int MaxMessageBytes);
|
||||
@@ -0,0 +1,8 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public enum EventBackpressurePolicy
|
||||
{
|
||||
FailFast,
|
||||
|
||||
DisconnectSubscriber
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class EventOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the event queue capacity.
|
||||
/// </summary>
|
||||
public int QueueCapacity { get; init; } = 10_000;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the backpressure policy for event queue overflow.
|
||||
/// </summary>
|
||||
public EventBackpressurePolicy BackpressurePolicy { get; init; } = EventBackpressurePolicy.FailFast;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>Provides the effective gateway configuration with sensitive values redacted.</summary>
|
||||
public sealed class GatewayConfigurationProvider(IOptions<GatewayOptions> options) : IGatewayConfigurationProvider
|
||||
{
|
||||
/// <summary>Marker string for redacted sensitive configuration values.</summary>
|
||||
public const string RedactedValue = "[redacted]";
|
||||
|
||||
/// <inheritdoc />
|
||||
public EffectiveGatewayConfiguration GetEffectiveConfiguration()
|
||||
{
|
||||
GatewayOptions value = options.Value;
|
||||
|
||||
return new EffectiveGatewayConfiguration(
|
||||
Authentication: new EffectiveAuthenticationConfiguration(
|
||||
Mode: value.Authentication.Mode.ToString(),
|
||||
SqlitePath: value.Authentication.SqlitePath,
|
||||
PepperSecretName: RedactedValue,
|
||||
RunMigrationsOnStartup: value.Authentication.RunMigrationsOnStartup),
|
||||
Ldap: new EffectiveLdapConfiguration(
|
||||
Enabled: value.Ldap.Enabled,
|
||||
Server: value.Ldap.Server,
|
||||
Port: value.Ldap.Port,
|
||||
UseTls: value.Ldap.UseTls,
|
||||
AllowInsecureLdap: value.Ldap.AllowInsecureLdap,
|
||||
SearchBase: value.Ldap.SearchBase,
|
||||
ServiceAccountDn: value.Ldap.ServiceAccountDn,
|
||||
ServiceAccountPassword: RedactedValue,
|
||||
UserNameAttribute: value.Ldap.UserNameAttribute,
|
||||
DisplayNameAttribute: value.Ldap.DisplayNameAttribute,
|
||||
GroupAttribute: value.Ldap.GroupAttribute,
|
||||
RequiredGroup: value.Ldap.RequiredGroup),
|
||||
Worker: new EffectiveWorkerConfiguration(
|
||||
ExecutablePath: value.Worker.ExecutablePath,
|
||||
WorkingDirectory: value.Worker.WorkingDirectory,
|
||||
RequiredArchitecture: value.Worker.RequiredArchitecture.ToString(),
|
||||
StartupTimeoutSeconds: value.Worker.StartupTimeoutSeconds,
|
||||
ShutdownTimeoutSeconds: value.Worker.ShutdownTimeoutSeconds,
|
||||
HeartbeatIntervalSeconds: value.Worker.HeartbeatIntervalSeconds,
|
||||
HeartbeatGraceSeconds: value.Worker.HeartbeatGraceSeconds,
|
||||
MaxMessageBytes: value.Worker.MaxMessageBytes),
|
||||
Sessions: new EffectiveSessionConfiguration(
|
||||
DefaultCommandTimeoutSeconds: value.Sessions.DefaultCommandTimeoutSeconds,
|
||||
MaxSessions: value.Sessions.MaxSessions,
|
||||
MaxPendingCommandsPerSession: value.Sessions.MaxPendingCommandsPerSession,
|
||||
DefaultLeaseSeconds: value.Sessions.DefaultLeaseSeconds,
|
||||
LeaseSweepIntervalSeconds: value.Sessions.LeaseSweepIntervalSeconds,
|
||||
AllowMultipleEventSubscribers: value.Sessions.AllowMultipleEventSubscribers),
|
||||
Events: new EffectiveEventConfiguration(
|
||||
QueueCapacity: value.Events.QueueCapacity,
|
||||
BackpressurePolicy: value.Events.BackpressurePolicy.ToString()),
|
||||
Dashboard: new EffectiveDashboardConfiguration(
|
||||
Enabled: value.Dashboard.Enabled,
|
||||
PathBase: value.Dashboard.PathBase,
|
||||
RequireAdminScope: value.Dashboard.RequireAdminScope,
|
||||
AllowAnonymousLocalhost: value.Dashboard.AllowAnonymousLocalhost,
|
||||
SnapshotIntervalMilliseconds: value.Dashboard.SnapshotIntervalMilliseconds,
|
||||
RecentFaultLimit: value.Dashboard.RecentFaultLimit,
|
||||
RecentSessionLimit: value.Dashboard.RecentSessionLimit,
|
||||
ShowTagValues: value.Dashboard.ShowTagValues),
|
||||
Protocol: new EffectiveProtocolConfiguration(
|
||||
value.Protocol.WorkerProtocolVersion,
|
||||
value.Protocol.MaxGrpcMessageBytes));
|
||||
}
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public static class GatewayConfigurationServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers gateway configuration services in the dependency injection container.</summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGatewayConfiguration(this IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddOptions<GatewayOptions>()
|
||||
.BindConfiguration(GatewayOptions.SectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton<IValidateOptions<GatewayOptions>, GatewayOptionsValidator>();
|
||||
services.AddSingleton<IGatewayConfigurationProvider, GatewayConfigurationProvider>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayOptions
|
||||
{
|
||||
public const string SectionName = "MxGateway";
|
||||
|
||||
/// <summary>
|
||||
/// Gets authentication configuration options.
|
||||
/// </summary>
|
||||
public AuthenticationOptions Authentication { get; init; } = new();
|
||||
|
||||
public LdapOptions Ldap { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets worker process configuration options.
|
||||
/// </summary>
|
||||
public WorkerOptions Worker { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets session management configuration options.
|
||||
/// </summary>
|
||||
public SessionOptions Sessions { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets event stream configuration options.
|
||||
/// </summary>
|
||||
public EventOptions Events { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets dashboard configuration options.
|
||||
/// </summary>
|
||||
public DashboardOptions Dashboard { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets protocol configuration options.
|
||||
/// </summary>
|
||||
public ProtocolOptions Protocol { get; init; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Gets alarm-subsystem configuration options. Drives the gateway's
|
||||
/// auto-subscribe-on-session-open hook; default values preserve legacy
|
||||
/// behaviour (alarms disabled).
|
||||
/// </summary>
|
||||
public AlarmsOptions Alarms { get; init; } = new();
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class GatewayOptionsValidator : IValidateOptions<GatewayOptions>
|
||||
{
|
||||
private const int MinimumMaxMessageBytes = 1024;
|
||||
private const int MaximumMaxMessageBytes = 256 * 1024 * 1024;
|
||||
|
||||
/// <summary>
|
||||
/// Validates gateway configuration options.
|
||||
/// </summary>
|
||||
/// <param name="name">Options name.</param>
|
||||
/// <param name="options">Gateway options to validate.</param>
|
||||
/// <returns>Validation result.</returns>
|
||||
public ValidateOptionsResult Validate(string? name, GatewayOptions options)
|
||||
{
|
||||
List<string> failures = [];
|
||||
|
||||
ValidateAuthentication(options.Authentication, failures);
|
||||
ValidateLdap(options.Ldap, failures);
|
||||
ValidateWorker(options.Worker, failures);
|
||||
ValidateSessions(options.Sessions, failures);
|
||||
ValidateEvents(options.Events, failures);
|
||||
ValidateDashboard(options.Dashboard, failures);
|
||||
ValidateProtocol(options.Protocol, failures);
|
||||
ValidateAlarms(options.Alarms, failures);
|
||||
|
||||
return failures.Count == 0
|
||||
? ValidateOptionsResult.Success
|
||||
: ValidateOptionsResult.Fail(failures);
|
||||
}
|
||||
|
||||
private static void ValidateAuthentication(AuthenticationOptions options, List<string> failures)
|
||||
{
|
||||
if (!Enum.IsDefined(options.Mode))
|
||||
{
|
||||
failures.Add("MxGateway:Authentication:Mode must be a supported authentication mode.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (options.Mode == AuthenticationMode.ApiKey)
|
||||
{
|
||||
AddIfBlank(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath is required when API-key authentication is enabled.",
|
||||
failures);
|
||||
AddIfInvalidPath(
|
||||
options.SqlitePath,
|
||||
"MxGateway:Authentication:SqlitePath must be a valid filesystem path.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.PepperSecretName,
|
||||
"MxGateway:Authentication:PepperSecretName is required when API-key authentication is enabled.",
|
||||
failures);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateLdap(LdapOptions options, List<string> failures)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
AddIfBlank(options.Server, "MxGateway:Ldap:Server is required when LDAP login is enabled.", failures);
|
||||
AddIfBlank(options.SearchBase, "MxGateway:Ldap:SearchBase is required when LDAP login is enabled.", failures);
|
||||
AddIfBlank(
|
||||
options.ServiceAccountDn,
|
||||
"MxGateway:Ldap:ServiceAccountDn is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.ServiceAccountPassword,
|
||||
"MxGateway:Ldap:ServiceAccountPassword is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.UserNameAttribute,
|
||||
"MxGateway:Ldap:UserNameAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.DisplayNameAttribute,
|
||||
"MxGateway:Ldap:DisplayNameAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.GroupAttribute,
|
||||
"MxGateway:Ldap:GroupAttribute is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfBlank(
|
||||
options.RequiredGroup,
|
||||
"MxGateway:Ldap:RequiredGroup is required when LDAP login is enabled.",
|
||||
failures);
|
||||
AddIfNotPositive(options.Port, "MxGateway:Ldap:Port must be greater than zero.", failures);
|
||||
|
||||
if (!options.UseTls && !options.AllowInsecureLdap)
|
||||
{
|
||||
failures.Add("MxGateway:Ldap:AllowInsecureLdap must be true when UseTls is false.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateWorker(WorkerOptions options, List<string> failures)
|
||||
{
|
||||
AddIfBlank(options.ExecutablePath, "MxGateway:Worker:ExecutablePath is required.", failures);
|
||||
AddIfInvalidPath(
|
||||
options.ExecutablePath,
|
||||
"MxGateway:Worker:ExecutablePath must be a valid filesystem path.",
|
||||
failures);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.ExecutablePath)
|
||||
&& !string.Equals(Path.GetExtension(options.ExecutablePath), ".exe", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
failures.Add("MxGateway:Worker:ExecutablePath must point to a .exe file.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.WorkingDirectory))
|
||||
{
|
||||
AddIfInvalidPath(
|
||||
options.WorkingDirectory,
|
||||
"MxGateway:Worker:WorkingDirectory must be a valid filesystem path.",
|
||||
failures);
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(options.RequiredArchitecture))
|
||||
{
|
||||
failures.Add("MxGateway:Worker:RequiredArchitecture must be a supported worker architecture.");
|
||||
}
|
||||
|
||||
AddIfNotPositive(
|
||||
options.StartupTimeoutSeconds,
|
||||
"MxGateway:Worker:StartupTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.StartupProbeRetryAttempts,
|
||||
"MxGateway:Worker:StartupProbeRetryAttempts must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.StartupProbeRetryDelayMilliseconds,
|
||||
"MxGateway:Worker:StartupProbeRetryDelayMilliseconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.PipeConnectAttemptTimeoutMilliseconds,
|
||||
"MxGateway:Worker:PipeConnectAttemptTimeoutMilliseconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.ShutdownTimeoutSeconds,
|
||||
"MxGateway:Worker:ShutdownTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.HeartbeatIntervalSeconds,
|
||||
"MxGateway:Worker:HeartbeatIntervalSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.HeartbeatGraceSeconds,
|
||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than zero.",
|
||||
failures);
|
||||
|
||||
if (options.HeartbeatGraceSeconds < options.HeartbeatIntervalSeconds)
|
||||
{
|
||||
failures.Add(
|
||||
"MxGateway:Worker:HeartbeatGraceSeconds must be greater than or equal to HeartbeatIntervalSeconds.");
|
||||
}
|
||||
|
||||
if (options.MaxMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Worker:MaxMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateSessions(SessionOptions options, List<string> failures)
|
||||
{
|
||||
AddIfNotPositive(
|
||||
options.DefaultCommandTimeoutSeconds,
|
||||
"MxGateway:Sessions:DefaultCommandTimeoutSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(options.MaxSessions, "MxGateway:Sessions:MaxSessions must be greater than zero.", failures);
|
||||
AddIfNotPositive(
|
||||
options.MaxPendingCommandsPerSession,
|
||||
"MxGateway:Sessions:MaxPendingCommandsPerSession must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.DefaultLeaseSeconds,
|
||||
"MxGateway:Sessions:DefaultLeaseSeconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNotPositive(
|
||||
options.LeaseSweepIntervalSeconds,
|
||||
"MxGateway:Sessions:LeaseSweepIntervalSeconds must be greater than zero.",
|
||||
failures);
|
||||
|
||||
if (options.AllowMultipleEventSubscribers)
|
||||
{
|
||||
failures.Add(
|
||||
"MxGateway:Sessions:AllowMultipleEventSubscribers is not supported until event fan-out is implemented.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateEvents(EventOptions options, List<string> failures)
|
||||
{
|
||||
AddIfNotPositive(options.QueueCapacity, "MxGateway:Events:QueueCapacity must be greater than zero.", failures);
|
||||
|
||||
if (!Enum.IsDefined(options.BackpressurePolicy))
|
||||
{
|
||||
failures.Add("MxGateway:Events:BackpressurePolicy must be a supported backpressure policy.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateDashboard(DashboardOptions options, List<string> failures)
|
||||
{
|
||||
if (options.Enabled)
|
||||
{
|
||||
AddIfBlank(options.PathBase, "MxGateway:Dashboard:PathBase is required when the dashboard is enabled.", failures);
|
||||
if (!string.IsNullOrWhiteSpace(options.PathBase) && !options.PathBase.StartsWith('/'))
|
||||
{
|
||||
failures.Add("MxGateway:Dashboard:PathBase must start with '/'.");
|
||||
}
|
||||
}
|
||||
|
||||
AddIfNotPositive(
|
||||
options.SnapshotIntervalMilliseconds,
|
||||
"MxGateway:Dashboard:SnapshotIntervalMilliseconds must be greater than zero.",
|
||||
failures);
|
||||
AddIfNegative(
|
||||
options.RecentFaultLimit,
|
||||
"MxGateway:Dashboard:RecentFaultLimit must be greater than or equal to zero.",
|
||||
failures);
|
||||
AddIfNegative(
|
||||
options.RecentSessionLimit,
|
||||
"MxGateway:Dashboard:RecentSessionLimit must be greater than or equal to zero.",
|
||||
failures);
|
||||
}
|
||||
|
||||
private static void ValidateAlarms(AlarmsOptions options, List<string> failures)
|
||||
{
|
||||
if (!options.Enabled)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// When the central alarm monitor is enabled, it needs either a canonical
|
||||
// SubscriptionExpression or a DefaultArea to compose one from. Validating
|
||||
// it at startup makes the misconfiguration fail-fast at boot, in line
|
||||
// with every other section.
|
||||
if (string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||
&& string.IsNullOrWhiteSpace(options.DefaultArea))
|
||||
{
|
||||
failures.Add(
|
||||
"MxGateway:Alarms requires either a non-blank SubscriptionExpression or a non-blank DefaultArea when Enabled is true.");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(options.SubscriptionExpression)
|
||||
&& !options.SubscriptionExpression.StartsWith(@"\\", StringComparison.Ordinal))
|
||||
{
|
||||
failures.Add(
|
||||
@"MxGateway:Alarms:SubscriptionExpression must start with '\\' (canonical \\<host>\Galaxy!<area> shape).");
|
||||
}
|
||||
}
|
||||
|
||||
private static void ValidateProtocol(ProtocolOptions options, List<string> failures)
|
||||
{
|
||||
if (options.WorkerProtocolVersion != GatewayContractInfo.WorkerProtocolVersion)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:WorkerProtocolVersion must be {GatewayContractInfo.WorkerProtocolVersion}.");
|
||||
}
|
||||
|
||||
if (options.MaxGrpcMessageBytes is < MinimumMaxMessageBytes or > MaximumMaxMessageBytes)
|
||||
{
|
||||
failures.Add(
|
||||
$"MxGateway:Protocol:MaxGrpcMessageBytes must be between {MinimumMaxMessageBytes} and {MaximumMaxMessageBytes}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfBlank(string? value, string message, List<string> failures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfNotPositive(int value, string message, List<string> failures)
|
||||
{
|
||||
if (value <= 0)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfNegative(int value, string message, List<string> failures)
|
||||
{
|
||||
if (value < 0)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddIfInvalidPath(string? value, string message, List<string> failures)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
_ = Path.GetFullPath(value);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
catch (PathTooLongException)
|
||||
{
|
||||
failures.Add(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Provides the effective gateway configuration, applying defaults and validations.
|
||||
/// </summary>
|
||||
public interface IGatewayConfigurationProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns the validated and effective gateway configuration.
|
||||
/// </summary>
|
||||
EffectiveGatewayConfiguration GetEffectiveConfiguration();
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class LdapOptions
|
||||
{
|
||||
public bool Enabled { get; init; } = true;
|
||||
|
||||
public string Server { get; init; } = "localhost";
|
||||
|
||||
public int Port { get; init; } = 3893;
|
||||
|
||||
public bool UseTls { get; init; }
|
||||
|
||||
public bool AllowInsecureLdap { get; init; } = true;
|
||||
|
||||
public string SearchBase { get; init; } = "dc=lmxopcua,dc=local";
|
||||
|
||||
public string ServiceAccountDn { get; init; } = "cn=serviceaccount,dc=lmxopcua,dc=local";
|
||||
|
||||
public string ServiceAccountPassword { get; init; } = "serviceaccount123";
|
||||
|
||||
public string UserNameAttribute { get; init; } = "cn";
|
||||
|
||||
public string DisplayNameAttribute { get; init; } = "cn";
|
||||
|
||||
public string GroupAttribute { get; init; } = "memberOf";
|
||||
|
||||
public string RequiredGroup { get; init; } = "GwAdmin";
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the worker protocol version.
|
||||
/// </summary>
|
||||
public sealed class ProtocolOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the worker protocol version.
|
||||
/// </summary>
|
||||
public uint WorkerProtocolVersion { get; init; } = GatewayContractInfo.WorkerProtocolVersion;
|
||||
|
||||
public int MaxGrpcMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class SessionOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the default command timeout in seconds.
|
||||
/// </summary>
|
||||
public int DefaultCommandTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of concurrent sessions.
|
||||
/// </summary>
|
||||
public int MaxSessions { get; init; } = 64;
|
||||
|
||||
/// <summary>
|
||||
/// Gets the maximum number of pending commands per session.
|
||||
/// </summary>
|
||||
public int MaxPendingCommandsPerSession { get; init; } = 128;
|
||||
|
||||
public int DefaultLeaseSeconds { get; init; } = 1800;
|
||||
|
||||
public int LeaseSweepIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether multiple event subscribers are allowed per session.
|
||||
/// </summary>
|
||||
public bool AllowMultipleEventSubscribers { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public enum WorkerArchitecture
|
||||
{
|
||||
X86,
|
||||
X64
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
public sealed class WorkerOptions
|
||||
{
|
||||
/// <summary>The path to the worker executable.</summary>
|
||||
public string ExecutablePath { get; init; } =
|
||||
@"src\ZB.MOM.WW.MxGateway.Worker\bin\x86\Release\ZB.MOM.WW.MxGateway.Worker.exe";
|
||||
|
||||
/// <summary>The working directory for the worker process, or null to inherit.</summary>
|
||||
public string? WorkingDirectory { get; init; }
|
||||
|
||||
/// <summary>The required processor architecture for the worker.</summary>
|
||||
public WorkerArchitecture RequiredArchitecture { get; init; } = WorkerArchitecture.X86;
|
||||
|
||||
/// <summary>The maximum time in seconds for the worker to start.</summary>
|
||||
public int StartupTimeoutSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>The number of retry attempts for the startup probe.</summary>
|
||||
public int StartupProbeRetryAttempts { get; init; } = 3;
|
||||
|
||||
/// <summary>The delay in milliseconds between startup probe retries.</summary>
|
||||
public int StartupProbeRetryDelayMilliseconds { get; init; } = 250;
|
||||
|
||||
/// <summary>The timeout in milliseconds for connecting to the worker pipe.</summary>
|
||||
public int PipeConnectAttemptTimeoutMilliseconds { get; init; } = 2000;
|
||||
|
||||
/// <summary>The maximum time in seconds for graceful shutdown.</summary>
|
||||
public int ShutdownTimeoutSeconds { get; init; } = 10;
|
||||
|
||||
/// <summary>The interval in seconds for worker heartbeats.</summary>
|
||||
public int HeartbeatIntervalSeconds { get; init; } = 5;
|
||||
|
||||
/// <summary>The grace period in seconds after a heartbeat before considering the worker unresponsive.</summary>
|
||||
public int HeartbeatGraceSeconds { get; init; } = 15;
|
||||
|
||||
/// <summary>The maximum message size in bytes for IPC communication.</summary>
|
||||
public int MaxMessageBytes { get; init; } = 16 * 1024 * 1024;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<base href="@DashboardBaseHref" />
|
||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||
<HeadOutlet @rendermode="InteractiveServer" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<Routes @rendermode="InteractiveServer" />
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/_framework/blazor.web.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@code {
|
||||
private string DashboardBaseHref
|
||||
{
|
||||
get
|
||||
{
|
||||
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
return $"{pathBase}/";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||
|
||||
public static class DashboardDisplay
|
||||
{
|
||||
/// <summary>
|
||||
/// Formats a nullable date and time value for display.
|
||||
/// </summary>
|
||||
/// <param name="value">The date and time to format.</param>
|
||||
/// <returns>Formatted date and time string or "-" if null.</returns>
|
||||
public static string DateTime(DateTimeOffset? value)
|
||||
{
|
||||
return value.HasValue
|
||||
? value.Value.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: "-";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a time span duration for display.
|
||||
/// </summary>
|
||||
/// <param name="value">The duration to format.</param>
|
||||
/// <returns>Formatted duration string.</returns>
|
||||
public static string Duration(TimeSpan value)
|
||||
{
|
||||
return value.TotalDays >= 1
|
||||
? value.ToString(@"d\.hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: value.ToString(@"hh\:mm\:ss", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a nullable text value for display.
|
||||
/// </summary>
|
||||
/// <param name="value">The text to format.</param>
|
||||
/// <returns>Formatted text or "-" if null or empty.</returns>
|
||||
public static string Text(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? "-" : value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Formats a long count value for display with thousands separator.
|
||||
/// </summary>
|
||||
/// <param name="value">The count to format.</param>
|
||||
/// <returns>Formatted count string.</returns>
|
||||
public static string Count(long value)
|
||||
{
|
||||
return value.ToString("N0", System.Globalization.CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retrieves a metric value from a snapshot by name and optional dimension.
|
||||
/// </summary>
|
||||
/// <param name="snapshot">Dashboard snapshot.</param>
|
||||
/// <param name="name">Metric name.</param>
|
||||
/// <param name="dimension">Optional metric dimension.</param>
|
||||
/// <returns>Metric value or zero if not found.</returns>
|
||||
public static long MetricValue(DashboardSnapshot snapshot, string name, string? dimension = null)
|
||||
{
|
||||
return snapshot.Metrics.FirstOrDefault(metric =>
|
||||
string.Equals(metric.Name, name, StringComparison.Ordinal)
|
||||
&& string.Equals(metric.Dimension, dimension, StringComparison.Ordinal))?.Value ?? 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.AspNetCore.Components;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||
|
||||
/// <summary>
|
||||
/// Base class for Blazor dashboard pages that watch gateway metrics snapshots.
|
||||
/// </summary>
|
||||
public abstract class DashboardPageBase : ComponentBase, IAsyncDisposable
|
||||
{
|
||||
private readonly CancellationTokenSource _disposeCancellation = new();
|
||||
private Task? _watchTask;
|
||||
|
||||
/// <summary>
|
||||
/// Service that provides gateway metric snapshots.
|
||||
/// </summary>
|
||||
[Inject]
|
||||
protected IDashboardSnapshotService SnapshotService { get; set; } = null!;
|
||||
|
||||
/// <summary>
|
||||
/// The most recent gateway metric snapshot, updated as it changes.
|
||||
/// </summary>
|
||||
protected DashboardSnapshot? Snapshot { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_watchTask = WatchSnapshotsAsync();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _disposeCancellation.CancelAsync().ConfigureAwait(false);
|
||||
if (_watchTask is not null)
|
||||
{
|
||||
await _watchTask.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_disposeCancellation.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Watches snapshot changes and triggers component refresh.
|
||||
/// </summary>
|
||||
private async Task WatchSnapshotsAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await foreach (DashboardSnapshot snapshot in SnapshotService
|
||||
.WatchSnapshotsAsync(_disposeCancellation.Token)
|
||||
.ConfigureAwait(false))
|
||||
{
|
||||
Snapshot = snapshot;
|
||||
await InvokeAsync(StateHasChanged).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (_disposeCancellation.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
@inherits LayoutComponentBase
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<div class="dashboard-shell">
|
||||
<header class="app-bar">
|
||||
<a class="brand" href=""><span class="mark">▮</span> MXAccess Gateway</a>
|
||||
<nav class="app-nav">
|
||||
<NavLink href="" Match="NavLinkMatch.All">Overview</NavLink>
|
||||
<NavLink href="sessions">Sessions</NavLink>
|
||||
<NavLink href="workers">Workers</NavLink>
|
||||
<NavLink href="events">Events</NavLink>
|
||||
<NavLink href="galaxy">Galaxy</NavLink>
|
||||
<NavLink href="browse">Browse</NavLink>
|
||||
<NavLink href="alarms">Alarms</NavLink>
|
||||
<NavLink href="apikeys">API Keys</NavLink>
|
||||
<NavLink href="settings">Settings</NavLink>
|
||||
</nav>
|
||||
<span class="spacer"></span>
|
||||
<AuthorizeView>
|
||||
<Authorized Context="authState">
|
||||
<div class="app-user">
|
||||
<span class="meta">@authState.User.Identity?.Name</span>
|
||||
<form method="post" action="@DashboardPath("/logout")">
|
||||
<AntiforgeryToken />
|
||||
<button class="btn btn-outline-secondary btn-sm" type="submit">Sign out</button>
|
||||
</form>
|
||||
</div>
|
||||
</Authorized>
|
||||
<NotAuthorized>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="@DashboardPath("/login")">Sign in</a>
|
||||
</NotAuthorized>
|
||||
</AuthorizeView>
|
||||
</header>
|
||||
<main class="page">
|
||||
@Body
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
private string DashboardPath(string relativePath)
|
||||
{
|
||||
string pathBase = GatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
return $"{pathBase}{relativePath}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
@page "/alarms"
|
||||
@page "/dashboard/alarms"
|
||||
@implements IAsyncDisposable
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@inject IOptions<GatewayOptions> GatewayOptions
|
||||
|
||||
<PageTitle>Dashboard Alarms</PageTitle>
|
||||
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Alarms</h1>
|
||||
<div class="text-secondary">@HeaderLine()</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@if (!GatewayOptions.Value.Alarms.Enabled)
|
||||
{
|
||||
<div class="alert alert-danger">
|
||||
Alarm auto-subscribe is disabled (<code>MxGateway:Alarms:Enabled</code> is false). The
|
||||
dashboard session is not subscribed to any alarm provider, so this list will stay empty.
|
||||
Enable alarms in configuration and restart the gateway.
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_queryError))
|
||||
{
|
||||
<div class="alert alert-danger">Alarm query failed: @_queryError</div>
|
||||
}
|
||||
|
||||
<section class="metric-grid compact">
|
||||
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
|
||||
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
|
||||
<MetricCard Label="Total Active" Value="@_alarms.Count.ToString("N0")" />
|
||||
<MetricCard Label="Showing" Value="@FilteredAlarms().Count.ToString("N0")" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Filters</h2>
|
||||
</div>
|
||||
<div class="alarm-filters">
|
||||
<label class="alarm-filter-check">
|
||||
<input type="checkbox" @bind="_showActive" />
|
||||
<span>Active (unacked)</span>
|
||||
</label>
|
||||
<label class="alarm-filter-check">
|
||||
<input type="checkbox" @bind="_showAcked" />
|
||||
<span>Acknowledged</span>
|
||||
</label>
|
||||
<div class="alarm-filter-field">
|
||||
<label class="form-label" for="alarm-area">Area</label>
|
||||
<select id="alarm-area" class="form-select form-select-sm" @bind="_areaFilter">
|
||||
<option value="">All areas</option>
|
||||
@foreach (string area in Areas())
|
||||
{
|
||||
<option value="@area">@area</option>
|
||||
}
|
||||
</select>
|
||||
</div>
|
||||
<div class="alarm-filter-field">
|
||||
<label class="form-label" for="alarm-sev-min">Min severity</label>
|
||||
<input id="alarm-sev-min" type="number" class="form-control form-control-sm" @bind="_minSeverity" />
|
||||
</div>
|
||||
<div class="alarm-filter-field">
|
||||
<label class="form-label" for="alarm-sev-max">Max severity</label>
|
||||
<input id="alarm-sev-max" type="number" class="form-control form-control-sm" @bind="_maxSeverity" />
|
||||
</div>
|
||||
<div class="alarm-filter-field alarm-filter-grow">
|
||||
<label class="form-label" for="alarm-search">Search</label>
|
||||
<input id="alarm-search" class="form-control form-control-sm"
|
||||
placeholder="Reference, source or description…"
|
||||
@bind="_search" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Active Alarms</h2>
|
||||
</div>
|
||||
@{
|
||||
IReadOnlyList<DashboardActiveAlarm> rows = FilteredAlarms();
|
||||
}
|
||||
@if (rows.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
@if (_alarms.Count == 0)
|
||||
{
|
||||
<span>No alarms are currently Active or ActiveAcked.</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span>No alarms match the current filters.</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Severity</th>
|
||||
<th scope="col">Alarm Reference</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Area</th>
|
||||
<th scope="col">Last Transition</th>
|
||||
<th scope="col">Operator</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardActiveAlarm alarm in rows)
|
||||
{
|
||||
<tr>
|
||||
<td><span class="alarm-state @StateClass(alarm.State)">@StateText(alarm.State)</span></td>
|
||||
<td class="alarm-severity">@alarm.Severity</td>
|
||||
<td>
|
||||
<code>@alarm.Reference</code>
|
||||
@if (!string.IsNullOrWhiteSpace(alarm.Description))
|
||||
{
|
||||
<div class="alarm-desc">@alarm.Description</div>
|
||||
}
|
||||
</td>
|
||||
<td>@DashboardDisplay.Text(alarm.Source)</td>
|
||||
<td>@DashboardDisplay.Text(alarm.AlarmType)</td>
|
||||
<td>@DashboardDisplay.Text(alarm.Area)</td>
|
||||
<td>@(alarm.LastTransition is { } ts ? DashboardDisplay.DateTime(ts) : "-")</td>
|
||||
<td>
|
||||
@DashboardDisplay.Text(alarm.OperatorUser)
|
||||
@if (!string.IsNullOrWhiteSpace(alarm.OperatorComment))
|
||||
{
|
||||
<div class="alarm-desc">@alarm.OperatorComment</div>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
<div class="browse-search-note">
|
||||
Cleared alarms are not retained — this list reflects only alarms currently Active or
|
||||
ActiveAcked, refreshed every 3 seconds.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
private readonly List<DashboardActiveAlarm> _alarms = [];
|
||||
private string? _queryError;
|
||||
private int? _workerPid;
|
||||
private DateTimeOffset? _lastRefresh;
|
||||
private int _unackedCount;
|
||||
private int _ackedCount;
|
||||
|
||||
private bool _showActive = true;
|
||||
private bool _showAcked;
|
||||
private string _areaFilter = string.Empty;
|
||||
private int _minSeverity;
|
||||
private int _maxSeverity = 1000;
|
||||
private string _search = string.Empty;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _pollTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_pollTask = PollLoopAsync();
|
||||
}
|
||||
|
||||
private string HeaderLine()
|
||||
{
|
||||
string refreshed = _lastRefresh is { } at
|
||||
? $"refreshed {DashboardDisplay.DateTime(at)}"
|
||||
: "awaiting first refresh";
|
||||
return _workerPid is int pid ? $"{refreshed} · worker pid {pid}" : refreshed;
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> Areas()
|
||||
{
|
||||
return _alarms
|
||||
.Select(alarm => alarm.Area)
|
||||
.Where(area => !string.IsNullOrWhiteSpace(area))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.OrderBy(area => area, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private IReadOnlyList<DashboardActiveAlarm> FilteredAlarms()
|
||||
{
|
||||
string query = _search.Trim();
|
||||
return _alarms
|
||||
.Where(MatchesState)
|
||||
.Where(alarm => _areaFilter.Length == 0
|
||||
|| string.Equals(alarm.Area, _areaFilter, StringComparison.OrdinalIgnoreCase))
|
||||
.Where(alarm => alarm.Severity >= _minSeverity && alarm.Severity <= _maxSeverity)
|
||||
.Where(alarm => query.Length == 0
|
||||
|| alarm.Reference.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| alarm.Source.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| alarm.Description.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
.OrderByDescending(alarm => alarm.Severity)
|
||||
.ThenByDescending(alarm => alarm.LastTransition ?? DateTimeOffset.MinValue)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private bool MatchesState(DashboardActiveAlarm alarm)
|
||||
{
|
||||
return alarm.State switch
|
||||
{
|
||||
AlarmConditionState.Active => _showActive,
|
||||
AlarmConditionState.ActiveAcked => _showAcked,
|
||||
_ => true,
|
||||
};
|
||||
}
|
||||
|
||||
private static string StateText(AlarmConditionState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AlarmConditionState.Active => "Active",
|
||||
AlarmConditionState.ActiveAcked => "Acked",
|
||||
AlarmConditionState.Inactive => "Inactive",
|
||||
_ => "Unknown",
|
||||
};
|
||||
}
|
||||
|
||||
private static string StateClass(AlarmConditionState state)
|
||||
{
|
||||
return state switch
|
||||
{
|
||||
AlarmConditionState.Active => "alarm-state-active",
|
||||
AlarmConditionState.ActiveAcked => "alarm-state-acked",
|
||||
_ => "alarm-state-other",
|
||||
};
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(3));
|
||||
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
|
||||
{
|
||||
await InvokeAsync(RefreshAlarmsAsync).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshAlarmsAsync()
|
||||
{
|
||||
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
|
||||
_queryError = result.Error;
|
||||
_workerPid = result.WorkerProcessId;
|
||||
_lastRefresh = DateTimeOffset.UtcNow;
|
||||
_alarms.Clear();
|
||||
_alarms.AddRange(result.Alarms);
|
||||
_unackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.Active);
|
||||
_ackedCount = _alarms.Count(alarm => alarm.State == AlarmConditionState.ActiveAcked);
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
if (_pollTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _pollTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
@page "/apikeys"
|
||||
@page "/dashboard/apikeys"
|
||||
@inherits DashboardPageBase
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IDashboardApiKeyManagementService ApiKeyManagementService
|
||||
|
||||
<PageTitle>Dashboard API Keys</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading API keys.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>API Keys</h1>
|
||||
<div class="text-secondary">@Snapshot.ApiKeys.Count key rows</div>
|
||||
</div>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<button type="button" class="btn btn-primary" @onclick="OpenCreateDialog">
|
||||
Create API Key
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
@if (!string.IsNullOrWhiteSpace(ResultMessage))
|
||||
{
|
||||
<div class="alert @(LastOperationSucceeded ? "alert-success" : "alert-danger")" role="alert">
|
||||
@ResultMessage
|
||||
@if (!string.IsNullOrWhiteSpace(LastGeneratedApiKey))
|
||||
{
|
||||
<div class="mt-2">
|
||||
<code class="one-time-secret">@LastGeneratedApiKey</code>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
@if (IsCreateDialogOpen)
|
||||
{
|
||||
<div class="modal-backdrop fade show"></div>
|
||||
<div class="modal fade show api-key-create-modal" role="dialog" aria-modal="true" aria-labelledby="createApiKeyTitle">
|
||||
<div class="modal-dialog modal-xl modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<EditForm Model="@CreateModel" OnSubmit="@CreateApiKeyAsync">
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title h5" id="createApiKeyTitle">Create API Key</h2>
|
||||
<button type="button" class="btn-close" aria-label="Close" @onclick="CloseCreateDialog"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="api-key-management-grid">
|
||||
<div class="mb-3">
|
||||
<label for="keyId" class="form-label">Key ID</label>
|
||||
<input id="keyId" class="form-control" @bind="CreateModel.KeyId" @bind:event="oninput" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="displayName" class="form-label">Display Name</label>
|
||||
<input id="displayName" class="form-control" @bind="CreateModel.DisplayName" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<fieldset class="mb-3">
|
||||
<legend class="form-label">Scopes</legend>
|
||||
<div class="scope-grid">
|
||||
@foreach (string scope in AvailableScopes)
|
||||
{
|
||||
<label class="form-check">
|
||||
<input class="form-check-input" type="checkbox"
|
||||
checked="@IsScopeSelected(scope)"
|
||||
@onchange="eventArgs => SetScope(scope, eventArgs)" />
|
||||
<span class="form-check-label">@scope</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
</fieldset>
|
||||
<div class="api-key-management-grid">
|
||||
<div class="mb-3">
|
||||
<label for="readSubtrees" class="form-label">Read subtrees</label>
|
||||
<textarea id="readSubtrees" class="form-control" rows="2" @bind="CreateModel.ReadSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="writeSubtrees" class="form-label">Write subtrees</label>
|
||||
<textarea id="writeSubtrees" class="form-control" rows="2" @bind="CreateModel.WriteSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="readTagGlobs" class="form-label">Read tag globs</label>
|
||||
<textarea id="readTagGlobs" class="form-control" rows="2" @bind="CreateModel.ReadTagGlobs" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="writeTagGlobs" class="form-label">Write tag globs</label>
|
||||
<textarea id="writeTagGlobs" class="form-control" rows="2" @bind="CreateModel.WriteTagGlobs" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="browseSubtrees" class="form-label">Browse subtrees</label>
|
||||
<textarea id="browseSubtrees" class="form-control" rows="2" @bind="CreateModel.BrowseSubtrees" @bind:event="oninput"></textarea>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="maxWriteClassification" class="form-label">Max write classification</label>
|
||||
<input id="maxWriteClassification" class="form-control" @bind="CreateModel.MaxWriteClassification" @bind:event="oninput" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-wrap gap-3">
|
||||
<label class="form-check">
|
||||
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadAlarmOnly" />
|
||||
<span class="form-check-label">Read alarm only</span>
|
||||
</label>
|
||||
<label class="form-check">
|
||||
<InputCheckbox class="form-check-input" @bind-Value="CreateModel.ReadHistorizedOnly" />
|
||||
<span class="form-check-label">Read historized only</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-outline-secondary" disabled="@IsBusy" @onclick="CloseCreateDialog">
|
||||
Cancel
|
||||
</button>
|
||||
<button type="submit" class="btn btn-primary" disabled="@IsBusy">Create Key</button>
|
||||
</div>
|
||||
</EditForm>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.ApiKeys.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No API keys are available for display.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Key</th>
|
||||
<th scope="col">Status</th>
|
||||
<th scope="col">Display Name</th>
|
||||
<th scope="col">Scopes</th>
|
||||
<th scope="col">Constraints</th>
|
||||
<th scope="col">Created</th>
|
||||
<th scope="col">Last Used</th>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<th scope="col">Actions</th>
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardApiKeySummary key in Snapshot.ApiKeys)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@key.KeyId</code></td>
|
||||
<td><StatusBadge Text="@(key.RevokedUtc is null ? "Active" : "Revoked")" /></td>
|
||||
<td>@DashboardDisplay.Text(key.DisplayName)</td>
|
||||
<td>@DashboardDisplay.Text(string.Join(", ", key.Scopes.Order(StringComparer.Ordinal)))</td>
|
||||
<td>@DashboardDisplay.Text(ConstraintText(key.Constraints))</td>
|
||||
<td>@DashboardDisplay.DateTime(key.CreatedUtc)</td>
|
||||
<td>@DashboardDisplay.DateTime(key.LastUsedUtc)</td>
|
||||
@if (CanManageApiKeys)
|
||||
{
|
||||
<td>
|
||||
<div class="btn-group btn-group-sm" role="group" aria-label="API key actions">
|
||||
@if (key.RevokedUtc is null)
|
||||
{
|
||||
@* Rotate clears revoked_utc, which would silently reactivate a
|
||||
deliberately revoked key. Only offer it for active keys so a
|
||||
revoked key is not un-revoked as a side effect of rotation. *@
|
||||
<button type="button" class="btn btn-outline-secondary"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RotateApiKeyAsync(key.KeyId)">
|
||||
Rotate
|
||||
</button>
|
||||
<button type="button" class="btn btn-outline-danger"
|
||||
disabled="@IsBusy"
|
||||
@onclick="() => RevokeApiKeyAsync(key.KeyId)">
|
||||
Revoke
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="text-muted small">No actions</span>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
}
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly string[] AvailableScopes =
|
||||
[
|
||||
GatewayScopes.SessionOpen,
|
||||
GatewayScopes.SessionClose,
|
||||
GatewayScopes.InvokeRead,
|
||||
GatewayScopes.InvokeWrite,
|
||||
GatewayScopes.InvokeSecure,
|
||||
GatewayScopes.EventsRead,
|
||||
GatewayScopes.MetadataRead,
|
||||
GatewayScopes.Admin
|
||||
];
|
||||
|
||||
private ApiKeyCreateModel CreateModel { get; } = new();
|
||||
|
||||
private bool CanManageApiKeys { get; set; }
|
||||
|
||||
private bool IsBusy { get; set; }
|
||||
|
||||
private bool IsCreateDialogOpen { get; set; }
|
||||
|
||||
private string? ResultMessage { get; set; }
|
||||
|
||||
private bool LastOperationSucceeded { get; set; }
|
||||
|
||||
private string? LastGeneratedApiKey { get; set; }
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
||||
.ConfigureAwait(false);
|
||||
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
||||
}
|
||||
|
||||
private async Task CreateApiKeyAsync()
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!TryBuildCreateRequest(out DashboardApiKeyManagementRequest? request, out string? validationMessage))
|
||||
{
|
||||
SetResult(DashboardApiKeyManagementResult.Fail(validationMessage ?? "API key request is invalid."));
|
||||
return;
|
||||
}
|
||||
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.CreateAsync(
|
||||
user,
|
||||
request,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RevokeApiKeyAsync(string keyId)
|
||||
{
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.RevokeAsync(
|
||||
user,
|
||||
keyId,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RotateApiKeyAsync(string keyId)
|
||||
{
|
||||
await RunManagementActionAsync(user => ApiKeyManagementService.RotateAsync(
|
||||
user,
|
||||
keyId,
|
||||
CancellationToken.None))
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task RunManagementActionAsync(
|
||||
Func<System.Security.Claims.ClaimsPrincipal, Task<DashboardApiKeyManagementResult>> action)
|
||||
{
|
||||
if (IsBusy)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
IsBusy = true;
|
||||
try
|
||||
{
|
||||
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync()
|
||||
.ConfigureAwait(false);
|
||||
CanManageApiKeys = ApiKeyManagementService.CanManage(authenticationState.User);
|
||||
DashboardApiKeyManagementResult result = await action(authenticationState.User).ConfigureAwait(false);
|
||||
SetResult(result);
|
||||
if (result.Succeeded && result.ApiKey is not null)
|
||||
{
|
||||
CreateModel.Reset();
|
||||
IsCreateDialogOpen = false;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
private void SetResult(DashboardApiKeyManagementResult result)
|
||||
{
|
||||
LastOperationSucceeded = result.Succeeded;
|
||||
ResultMessage = result.Message;
|
||||
LastGeneratedApiKey = result.ApiKey;
|
||||
}
|
||||
|
||||
private void OpenCreateDialog()
|
||||
{
|
||||
IsCreateDialogOpen = true;
|
||||
}
|
||||
|
||||
private void CloseCreateDialog()
|
||||
{
|
||||
if (!IsBusy)
|
||||
{
|
||||
IsCreateDialogOpen = false;
|
||||
}
|
||||
}
|
||||
|
||||
private bool TryBuildCreateRequest(
|
||||
[System.Diagnostics.CodeAnalysis.NotNullWhen(true)] out DashboardApiKeyManagementRequest? request,
|
||||
out string? validationMessage)
|
||||
{
|
||||
request = null;
|
||||
validationMessage = null;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
||||
&& !int.TryParse(
|
||||
CreateModel.MaxWriteClassification,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture,
|
||||
out int _))
|
||||
{
|
||||
validationMessage = "Max write classification must be an integer.";
|
||||
return false;
|
||||
}
|
||||
|
||||
int? maxWriteClassification = string.IsNullOrWhiteSpace(CreateModel.MaxWriteClassification)
|
||||
? null
|
||||
: int.Parse(
|
||||
CreateModel.MaxWriteClassification,
|
||||
System.Globalization.NumberStyles.Integer,
|
||||
System.Globalization.CultureInfo.InvariantCulture);
|
||||
|
||||
request = new DashboardApiKeyManagementRequest(
|
||||
KeyId: CreateModel.KeyId,
|
||||
DisplayName: CreateModel.DisplayName,
|
||||
Scopes: CreateModel.SelectedScopes,
|
||||
Constraints: new ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints(
|
||||
ReadSubtrees: ParseList(CreateModel.ReadSubtrees),
|
||||
WriteSubtrees: ParseList(CreateModel.WriteSubtrees),
|
||||
ReadTagGlobs: ParseList(CreateModel.ReadTagGlobs),
|
||||
WriteTagGlobs: ParseList(CreateModel.WriteTagGlobs),
|
||||
MaxWriteClassification: maxWriteClassification,
|
||||
BrowseSubtrees: ParseList(CreateModel.BrowseSubtrees),
|
||||
ReadAlarmOnly: CreateModel.ReadAlarmOnly,
|
||||
ReadHistorizedOnly: CreateModel.ReadHistorizedOnly));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private bool IsScopeSelected(string scope)
|
||||
{
|
||||
return CreateModel.SelectedScopes.Contains(scope);
|
||||
}
|
||||
|
||||
private void SetScope(string scope, ChangeEventArgs eventArgs)
|
||||
{
|
||||
bool selected = eventArgs.Value is bool value && value;
|
||||
if (selected)
|
||||
{
|
||||
CreateModel.SelectedScopes.Add(scope);
|
||||
}
|
||||
else
|
||||
{
|
||||
CreateModel.SelectedScopes.Remove(scope);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ConstraintText(ZB.MOM.WW.MxGateway.Server.Security.Authentication.ApiKeyConstraints constraints)
|
||||
{
|
||||
if (constraints.IsEmpty)
|
||||
{
|
||||
return "unconstrained";
|
||||
}
|
||||
|
||||
List<string> parts = [];
|
||||
AddList(parts, "read_subtrees", constraints.ReadSubtrees);
|
||||
AddList(parts, "write_subtrees", constraints.WriteSubtrees);
|
||||
AddList(parts, "read_tag_globs", constraints.ReadTagGlobs);
|
||||
AddList(parts, "write_tag_globs", constraints.WriteTagGlobs);
|
||||
AddList(parts, "browse_subtrees", constraints.BrowseSubtrees);
|
||||
if (constraints.MaxWriteClassification is { } max)
|
||||
{
|
||||
parts.Add($"max_write_classification={max}");
|
||||
}
|
||||
|
||||
if (constraints.ReadAlarmOnly)
|
||||
{
|
||||
parts.Add("read_alarm_only");
|
||||
}
|
||||
|
||||
if (constraints.ReadHistorizedOnly)
|
||||
{
|
||||
parts.Add("read_historized_only");
|
||||
}
|
||||
|
||||
return string.Join("; ", parts);
|
||||
}
|
||||
|
||||
private static void AddList(List<string> parts, string name, IReadOnlyList<string> values)
|
||||
{
|
||||
if (values.Count > 0)
|
||||
{
|
||||
parts.Add($"{name}=[{string.Join(", ", values)}]");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ParseList(string? value)
|
||||
{
|
||||
return (value ?? string.Empty)
|
||||
.Split([',', ';', '\r', '\n'], StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
|
||||
.Where(item => !string.IsNullOrWhiteSpace(item))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private sealed class ApiKeyCreateModel
|
||||
{
|
||||
public string KeyId { get; set; } = string.Empty;
|
||||
|
||||
public string DisplayName { get; set; } = string.Empty;
|
||||
|
||||
public HashSet<string> SelectedScopes { get; } = new(StringComparer.Ordinal);
|
||||
|
||||
public string ReadSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string WriteSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string ReadTagGlobs { get; set; } = string.Empty;
|
||||
|
||||
public string WriteTagGlobs { get; set; } = string.Empty;
|
||||
|
||||
public string BrowseSubtrees { get; set; } = string.Empty;
|
||||
|
||||
public string MaxWriteClassification { get; set; } = string.Empty;
|
||||
|
||||
public bool ReadAlarmOnly { get; set; }
|
||||
|
||||
public bool ReadHistorizedOnly { get; set; }
|
||||
|
||||
public void Reset()
|
||||
{
|
||||
KeyId = string.Empty;
|
||||
DisplayName = string.Empty;
|
||||
SelectedScopes.Clear();
|
||||
ReadSubtrees = string.Empty;
|
||||
WriteSubtrees = string.Empty;
|
||||
ReadTagGlobs = string.Empty;
|
||||
WriteTagGlobs = string.Empty;
|
||||
BrowseSubtrees = string.Empty;
|
||||
MaxWriteClassification = string.Empty;
|
||||
ReadAlarmOnly = false;
|
||||
ReadHistorizedOnly = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,423 @@
|
||||
@page "/browse"
|
||||
@page "/dashboard/browse"
|
||||
@implements IAsyncDisposable
|
||||
@inject IGalaxyHierarchyCache GalaxyCache
|
||||
@inject IDashboardLiveDataService LiveData
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
||||
@using ZB.MOM.WW.MxGateway.Server.Galaxy
|
||||
|
||||
<PageTitle>Dashboard Browse</PageTitle>
|
||||
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Browse</h1>
|
||||
<div class="text-secondary">@HeaderLine()</div>
|
||||
</div>
|
||||
<StatusBadge Text="@GalaxyCache.Current.Status.ToString()" />
|
||||
</div>
|
||||
|
||||
<div class="browse-layout">
|
||||
<section class="dashboard-section browse-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Galaxy Hierarchy</h2>
|
||||
</div>
|
||||
<input class="form-control form-control-sm browse-search"
|
||||
placeholder="Filter attributes by name or reference…"
|
||||
@bind="Search"
|
||||
@bind:event="oninput" />
|
||||
|
||||
@if (_roots.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
No Galaxy hierarchy is cached yet. The hierarchy refreshes from the
|
||||
Galaxy Repository in the background — check the Galaxy tab for status.
|
||||
</div>
|
||||
}
|
||||
else if (!string.IsNullOrWhiteSpace(Search))
|
||||
{
|
||||
@if (_searchMatches.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No attributes match “@Search”.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="browse-tree browse-search-results">
|
||||
@foreach (GalaxyAttribute hit in _searchMatches)
|
||||
{
|
||||
GalaxyAttribute row = hit;
|
||||
<div class="tree-attr"
|
||||
title="@row.FullTagReference"
|
||||
@oncontextmenu:preventDefault="true"
|
||||
@oncontextmenu="@(args => ShowMenu(args, row))"
|
||||
@ondblclick="@(() => AddTagAsync(row.FullTagReference))">
|
||||
<span class="attr-icon">·</span>
|
||||
<span class="attr-name">@row.FullTagReference</span>
|
||||
<span class="attr-type">@FormatType(row)</span>
|
||||
@if (row.IsAlarm)
|
||||
{
|
||||
<span class="attr-flag attr-flag-alarm">alarm</span>
|
||||
}
|
||||
@if (row.IsHistorized)
|
||||
{
|
||||
<span class="attr-flag attr-flag-hist">hist</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (_searchMatches.Count >= SearchResultLimit)
|
||||
{
|
||||
<div class="browse-search-note">Showing the first @SearchResultLimit matches — refine the filter.</div>
|
||||
}
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="browse-tree">
|
||||
@foreach (DashboardBrowseNode root in _roots)
|
||||
{
|
||||
<BrowseTreeNodeView Node="root"
|
||||
OnAddTag="AddTagAsync"
|
||||
OnTagContextMenu="OnTagContextMenu" />
|
||||
}
|
||||
</div>
|
||||
<div class="browse-search-note">Double-click a tag, or right-click for the menu.</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section browse-panel">
|
||||
<div class="section-heading">
|
||||
<h2>Subscription Panel</h2>
|
||||
</div>
|
||||
<div class="sub-panel-meta">
|
||||
@if (_subscribed.Count > 0)
|
||||
{
|
||||
<span>@_subscribed.Count subscribed</span>
|
||||
<span>·</span>
|
||||
<span>refresh 2s</span>
|
||||
@if (_workerPid is int pid)
|
||||
{
|
||||
<span>·</span>
|
||||
<span>worker pid @pid</span>
|
||||
}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm sub-clear" @onclick="ClearAll">Clear all</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(_readError))
|
||||
{
|
||||
<div class="alert alert-danger">Live read failed: @_readError</div>
|
||||
}
|
||||
|
||||
@if (_subscribed.Count == 0)
|
||||
{
|
||||
<div class="empty-state">
|
||||
No tags subscribed. Right-click a tag in the hierarchy and choose
|
||||
<strong>Add to subscription panel</strong> (or double-click it) to watch its
|
||||
live value, quality and source timestamp here.
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table sub-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Tag</th>
|
||||
<th scope="col">Value</th>
|
||||
<th scope="col">Type</th>
|
||||
<th scope="col">Quality</th>
|
||||
<th scope="col">Updated</th>
|
||||
<th scope="col" class="sub-actions-col"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (string tag in _subscribed)
|
||||
{
|
||||
string key = tag;
|
||||
DashboardTagValue? value = _values.GetValueOrDefault(key);
|
||||
<tr>
|
||||
<td><code>@key</code></td>
|
||||
<td class="sub-value">@(value?.ValueText ?? "…")</td>
|
||||
<td>@(value?.DataType ?? "-")</td>
|
||||
<td>
|
||||
@if (value is null)
|
||||
{
|
||||
<span class="text-secondary">…</span>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="quality-chip @(value.QualityGood ? "quality-good" : "quality-bad")">
|
||||
@value.Quality
|
||||
</span>
|
||||
@if (!string.IsNullOrWhiteSpace(value.Error))
|
||||
{
|
||||
<span class="sub-error" title="@value.Error">!</span>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td title="@TimestampTooltip(value)">@TimestampText(key, value)</td>
|
||||
<td class="sub-actions-col">
|
||||
<button type="button"
|
||||
class="btn btn-outline-secondary btn-sm"
|
||||
@onclick="@(() => RemoveTag(key))">Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@if (_menuVisible)
|
||||
{
|
||||
<div class="context-menu-overlay"
|
||||
@onclick="HideMenu"
|
||||
@oncontextmenu:preventDefault="true"
|
||||
@oncontextmenu="HideMenu"></div>
|
||||
<div class="context-menu" style="left:@(_menuX)px; top:@(_menuY)px;">
|
||||
<div class="context-menu-head">@(_menuAttribute?.AttributeName)</div>
|
||||
<button type="button" class="context-menu-item" @onclick="AddMenuTagAsync">
|
||||
Add to subscription panel
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private const int SearchResultLimit = 300;
|
||||
|
||||
private IReadOnlyList<DashboardBrowseNode> _roots = [];
|
||||
private string _search = string.Empty;
|
||||
private IReadOnlyList<GalaxyAttribute> _searchMatches = [];
|
||||
private readonly List<string> _subscribed = [];
|
||||
private readonly Dictionary<string, DashboardTagValue> _values = new(StringComparer.Ordinal);
|
||||
// Per-tag bookkeeping for the Updated column: the signature of the value
|
||||
// last seen, and when that value/quality was first observed. Lets the
|
||||
// column move only on a real change, not on every 2s poll.
|
||||
private readonly Dictionary<string, string> _valueSignature = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, DateTimeOffset> _observedChangeAt = new(StringComparer.Ordinal);
|
||||
private string? _readError;
|
||||
private int? _workerPid;
|
||||
|
||||
private bool _menuVisible;
|
||||
private int _menuX;
|
||||
private int _menuY;
|
||||
private GalaxyAttribute? _menuAttribute;
|
||||
|
||||
private readonly CancellationTokenSource _cts = new();
|
||||
private Task? _pollTask;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
_roots = DashboardBrowseTreeBuilder.Build(GalaxyCache.Current.Objects);
|
||||
_pollTask = PollLoopAsync();
|
||||
}
|
||||
|
||||
private string HeaderLine()
|
||||
{
|
||||
GalaxyHierarchyCacheEntry entry = GalaxyCache.Current;
|
||||
return $"{entry.ObjectCount:N0} objects · {entry.AttributeCount:N0} attributes · "
|
||||
+ $"{entry.AlarmAttributeCount:N0} alarm attributes";
|
||||
}
|
||||
|
||||
private string Search
|
||||
{
|
||||
get => _search;
|
||||
set
|
||||
{
|
||||
_search = value ?? string.Empty;
|
||||
_searchMatches = ComputeSearch(_search);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<GalaxyAttribute> ComputeSearch(string rawQuery)
|
||||
{
|
||||
string query = rawQuery.Trim();
|
||||
if (query.Length == 0)
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<GalaxyAttribute> matches = [];
|
||||
foreach (GalaxyObject galaxyObject in GalaxyCache.Current.Objects)
|
||||
{
|
||||
foreach (GalaxyAttribute attr in galaxyObject.Attributes)
|
||||
{
|
||||
if (attr.FullTagReference.Contains(query, StringComparison.OrdinalIgnoreCase)
|
||||
|| attr.AttributeName.Contains(query, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
matches.Add(attr);
|
||||
if (matches.Count >= SearchResultLimit)
|
||||
{
|
||||
return matches;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return matches;
|
||||
}
|
||||
|
||||
private static string FormatType(GalaxyAttribute attr)
|
||||
{
|
||||
string baseType = string.IsNullOrWhiteSpace(attr.DataTypeName) ? "type?" : attr.DataTypeName;
|
||||
if (!attr.IsArray)
|
||||
{
|
||||
return baseType;
|
||||
}
|
||||
|
||||
return attr.ArrayDimensionPresent ? $"{baseType}[{attr.ArrayDimension}]" : $"{baseType}[]";
|
||||
}
|
||||
|
||||
private Task OnTagContextMenu((MouseEventArgs Event, GalaxyAttribute Attribute) args)
|
||||
{
|
||||
ShowMenu(args.Event, args.Attribute);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private void ShowMenu(MouseEventArgs args, GalaxyAttribute attr)
|
||||
{
|
||||
_menuAttribute = attr;
|
||||
_menuX = (int)args.ClientX;
|
||||
_menuY = (int)args.ClientY;
|
||||
_menuVisible = true;
|
||||
}
|
||||
|
||||
private void HideMenu()
|
||||
{
|
||||
_menuVisible = false;
|
||||
_menuAttribute = null;
|
||||
}
|
||||
|
||||
private async Task AddMenuTagAsync()
|
||||
{
|
||||
GalaxyAttribute? attr = _menuAttribute;
|
||||
HideMenu();
|
||||
if (attr is not null)
|
||||
{
|
||||
await AddTagAsync(attr.FullTagReference);
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AddTagAsync(string fullReference)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullReference)
|
||||
|| _subscribed.Contains(fullReference, StringComparer.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_subscribed.Add(fullReference);
|
||||
await RefreshValuesAsync();
|
||||
}
|
||||
|
||||
private void RemoveTag(string tag)
|
||||
{
|
||||
_subscribed.Remove(tag);
|
||||
_values.Remove(tag);
|
||||
_valueSignature.Remove(tag);
|
||||
_observedChangeAt.Remove(tag);
|
||||
}
|
||||
|
||||
private void ClearAll()
|
||||
{
|
||||
_subscribed.Clear();
|
||||
_values.Clear();
|
||||
_valueSignature.Clear();
|
||||
_observedChangeAt.Clear();
|
||||
_readError = null;
|
||||
}
|
||||
|
||||
// The MXAccess source timestamp when the worker supplies one, otherwise the
|
||||
// time the dashboard first observed the current value/quality.
|
||||
private string TimestampText(string tag, DashboardTagValue? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return "…";
|
||||
}
|
||||
|
||||
if (value.SourceTimestamp is { } source)
|
||||
{
|
||||
return DashboardDisplay.DateTime(source);
|
||||
}
|
||||
|
||||
return _observedChangeAt.TryGetValue(tag, out DateTimeOffset observed)
|
||||
? DashboardDisplay.DateTime(observed)
|
||||
: "-";
|
||||
}
|
||||
|
||||
private static string TimestampTooltip(DashboardTagValue? value)
|
||||
{
|
||||
return value?.SourceTimestamp is not null
|
||||
? "MXAccess source timestamp."
|
||||
: "When the dashboard first observed this value — MXAccess did not supply a source timestamp for this tag.";
|
||||
}
|
||||
|
||||
private async Task PollLoopAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
using PeriodicTimer timer = new(TimeSpan.FromSeconds(2));
|
||||
while (await timer.WaitForNextTickAsync(_cts.Token).ConfigureAwait(false))
|
||||
{
|
||||
await InvokeAsync(RefreshValuesAsync).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RefreshValuesAsync()
|
||||
{
|
||||
if (_subscribed.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string[] tags = [.. _subscribed];
|
||||
DashboardLiveReadResult result = await LiveData.ReadAsync(tags, _cts.Token);
|
||||
_readError = result.Error;
|
||||
_workerPid = result.WorkerProcessId;
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
foreach (DashboardTagValue value in result.Values)
|
||||
{
|
||||
// Stamp the observed-change time only when the value/quality
|
||||
// signature actually changes, so the Updated column does not
|
||||
// tick on every poll for a static tag.
|
||||
string signature = $"{value.ValueText}{value.Quality}{value.Ok}";
|
||||
if (!_valueSignature.TryGetValue(value.TagAddress, out string? previous)
|
||||
|| previous != signature)
|
||||
{
|
||||
_valueSignature[value.TagAddress] = signature;
|
||||
_observedChangeAt[value.TagAddress] = now;
|
||||
}
|
||||
|
||||
_values[value.TagAddress] = value;
|
||||
}
|
||||
|
||||
StateHasChanged();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
await _cts.CancelAsync();
|
||||
if (_pollTask is not null)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _pollTask;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
_cts.Dispose();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
@page "/"
|
||||
@page "/dashboard/"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>MXAccess Gateway Dashboard</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading dashboard snapshot.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Overview</h1>
|
||||
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
|
||||
</div>
|
||||
<StatusBadge Text="@Snapshot.GatewayStatus" />
|
||||
</div>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricCard Label="Uptime" Value="@DashboardDisplay.Duration(Snapshot.GatewayUptime)" Detail="@Snapshot.GatewayVersion" />
|
||||
<MetricCard Label="Open Sessions" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.sessions.open"))" />
|
||||
<MetricCard Label="Workers Running" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.workers.running"))" />
|
||||
<MetricCard Label="Event Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.worker_queue.depth"))" />
|
||||
<MetricCard Label="Commands Failed" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.commands.failed"))" />
|
||||
<MetricCard Label="Events Received" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received"))" />
|
||||
<MetricCard Label="Faults" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.faults"))" />
|
||||
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading d-flex align-items-center gap-2">
|
||||
<h2>Galaxy Repository</h2>
|
||||
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
|
||||
<NavLink class="ms-auto small" href="galaxy">View browse details →</NavLink>
|
||||
</div>
|
||||
<div class="metric-grid compact">
|
||||
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" />
|
||||
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
|
||||
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" />
|
||||
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
|
||||
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
|
||||
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@GalaxyRefreshDetail()" />
|
||||
</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
|
||||
{
|
||||
<div class="empty-state mt-2">@Snapshot.Galaxy.LastError</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Recent Faults</h2>
|
||||
</div>
|
||||
<FaultList Faults="@Snapshot.Faults" />
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private string? GalaxyRefreshDetail()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
if (galaxy.LastQueriedAt is null)
|
||||
{
|
||||
return "never queried";
|
||||
}
|
||||
|
||||
if (galaxy.LastSuccessAt is null)
|
||||
{
|
||||
return "no successful refresh yet";
|
||||
}
|
||||
|
||||
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
|
||||
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
@page "/events"
|
||||
@page "/dashboard/events"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Events</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading event diagnostics.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Events</h1>
|
||||
<div class="text-secondary">Generated @DashboardDisplay.DateTime(Snapshot.GeneratedAt)</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="metric-grid compact">
|
||||
<MetricCard Label="Events Received" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.received"))" />
|
||||
<MetricCard Label="Worker Event Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.worker_queue.depth"))" />
|
||||
<MetricCard Label="Stream Queue Depth" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.events.grpc_stream_queue.depth"))" />
|
||||
<MetricCard Label="Queue Overflows" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.queues.overflows"))" />
|
||||
<MetricCard Label="Stream Disconnects" Value="@DashboardDisplay.Count(DashboardDisplay.MetricValue(Snapshot, "mxgateway.grpc.streams.disconnected"))" />
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Event Families</h2>
|
||||
</div>
|
||||
@if (EventFamilyMetrics.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No event family counters recorded.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Family</th>
|
||||
<th scope="col">Count</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardMetricSummary metric in EventFamilyMetrics)
|
||||
{
|
||||
<tr>
|
||||
<td>@metric.Dimension</td>
|
||||
<td>@DashboardDisplay.Count(metric.Value)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
private IReadOnlyList<DashboardMetricSummary> EventFamilyMetrics => Snapshot?.Metrics
|
||||
.Where(metric => metric.Name == "mxgateway.events.received" && metric.Dimension is not null)
|
||||
.OrderBy(metric => metric.Dimension, StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray() ?? [];
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
@page "/galaxy"
|
||||
@page "/dashboard/galaxy"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Galaxy</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading Galaxy summary.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Galaxy Repository</h1>
|
||||
<div class="text-secondary">@RefreshHeading()</div>
|
||||
</div>
|
||||
<StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" />
|
||||
</div>
|
||||
|
||||
<section class="metric-grid">
|
||||
<MetricCard Label="Last Deploy" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)" Detail="@DeployAge()" Wide="true" />
|
||||
<MetricCard Label="Last Refresh" Value="@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)" Detail="@LastAttemptDetail()" Wide="true" />
|
||||
<MetricCard Label="Objects" Value="@DashboardDisplay.Count(Snapshot.Galaxy.ObjectCount)" Detail="@($"{Snapshot.Galaxy.AreaCount:N0} areas")" />
|
||||
<MetricCard Label="Attributes" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AttributeCount)" Detail="dynamic, deployed" />
|
||||
<MetricCard Label="Historized" Value="@DashboardDisplay.Count(Snapshot.Galaxy.HistorizedAttributeCount)" />
|
||||
<MetricCard Label="Alarms" Value="@DashboardDisplay.Count(Snapshot.Galaxy.AlarmAttributeCount)" />
|
||||
</section>
|
||||
|
||||
@if (Snapshot.Galaxy.Status == DashboardGalaxyStatus.Unknown)
|
||||
{
|
||||
<section class="dashboard-section">
|
||||
<div class="empty-state">
|
||||
Galaxy summary has not been collected yet. The dashboard refreshes the
|
||||
summary every @RefreshIntervalSeconds() seconds via the
|
||||
<code>GalaxyRepository</code> service.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@if (!string.IsNullOrWhiteSpace(Snapshot.Galaxy.LastError))
|
||||
{
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Last Error</h2>
|
||||
</div>
|
||||
<div class="empty-state">@Snapshot.Galaxy.LastError</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Object Categories</h2>
|
||||
</div>
|
||||
@if (Snapshot.Galaxy.ObjectCategories.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No deployed objects observed.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Category</th>
|
||||
<th scope="col">Category ID</th>
|
||||
<th scope="col">Objects</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardGalaxyCategoryCount row in Snapshot.Galaxy.ObjectCategories)
|
||||
{
|
||||
<tr>
|
||||
<td>@row.CategoryName</td>
|
||||
<td><code>@row.CategoryId</code></td>
|
||||
<td>@DashboardDisplay.Count(row.ObjectCount)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Top Templates</h2>
|
||||
</div>
|
||||
@if (Snapshot.Galaxy.TopTemplates.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No template usage observed.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Template</th>
|
||||
<th scope="col">Instances</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardGalaxyTemplateUsage row in Snapshot.Galaxy.TopTemplates)
|
||||
{
|
||||
<tr>
|
||||
<td><code>@row.TemplateName</code></td>
|
||||
<td>@DashboardDisplay.Count(row.InstanceCount)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Sync Info</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Status</th><td><StatusBadge Text="@Snapshot.Galaxy.Status.ToString()" /></td></tr>
|
||||
<tr><th scope="row">Last successful refresh</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastSuccessAt)</td></tr>
|
||||
<tr><th scope="row">Last attempt</th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastQueriedAt)</td></tr>
|
||||
<tr><th scope="row">Galaxy <code>time_of_last_deploy</code></th><td>@DashboardDisplay.DateTime(Snapshot.Galaxy.LastDeployTime)</td></tr>
|
||||
<tr><th scope="row">Refresh interval</th><td>@RefreshIntervalSeconds() seconds</td></tr>
|
||||
<tr><th scope="row">Connection string</th><td><code>@DashboardDisplay.Text(GalaxyConnectionStringDisplay())</code></td></tr>
|
||||
<tr><th scope="row">Command timeout</th><td>@CommandTimeoutSeconds() seconds</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="text-secondary small mt-2">
|
||||
Browse data is served by the <code>galaxy_repository.v1.GalaxyRepository</code> gRPC
|
||||
service. Clients call <code>DiscoverHierarchy</code> for the full tree and
|
||||
<code>GetLastDeployTime</code> to detect redeployments.
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Inject]
|
||||
private IOptions<ZB.MOM.WW.MxGateway.Server.Galaxy.GalaxyRepositoryOptions> GalaxyOptions { get; set; } = null!;
|
||||
|
||||
private string RefreshHeading()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
return galaxy.LastSuccessAt is null
|
||||
? "Awaiting first successful refresh"
|
||||
: $"Refreshed {DashboardDisplay.DateTime(galaxy.LastSuccessAt)}";
|
||||
}
|
||||
|
||||
private string? DeployAge()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
if (galaxy.LastDeployTime is null || galaxy.LastSuccessAt is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
TimeSpan age = galaxy.LastSuccessAt.Value - galaxy.LastDeployTime.Value;
|
||||
if (age < TimeSpan.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return $"{DashboardDisplay.Duration(age)} ago";
|
||||
}
|
||||
|
||||
private string? LastAttemptDetail()
|
||||
{
|
||||
DashboardGalaxySummary galaxy = Snapshot!.Galaxy;
|
||||
if (galaxy.LastQueriedAt is null)
|
||||
{
|
||||
return "never queried";
|
||||
}
|
||||
|
||||
if (galaxy.LastSuccessAt is null)
|
||||
{
|
||||
return "no successful refresh yet";
|
||||
}
|
||||
|
||||
return galaxy.LastQueriedAt > galaxy.LastSuccessAt
|
||||
? $"last attempt {DashboardDisplay.DateTime(galaxy.LastQueriedAt)}"
|
||||
: null;
|
||||
}
|
||||
|
||||
private int RefreshIntervalSeconds() => Math.Max(1, GalaxyOptions.Value.DashboardRefreshIntervalSeconds);
|
||||
|
||||
private int CommandTimeoutSeconds() => GalaxyOptions.Value.CommandTimeoutSeconds;
|
||||
|
||||
private string GalaxyConnectionStringDisplay()
|
||||
{
|
||||
return DashboardConnectionStringDisplay.GalaxyRepositoryConnectionString(GalaxyOptions.Value.ConnectionString);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
@page "/sessions/{SessionId}"
|
||||
@page "/dashboard/sessions/{SessionId}"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Session</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading session.</div>
|
||||
}
|
||||
else if (CurrentSession is null)
|
||||
{
|
||||
<section class="dashboard-section">
|
||||
<h1 class="h4 mb-3">Session Not Found</h1>
|
||||
<p class="mb-0">The session is not present in the current snapshot.</p>
|
||||
</section>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Session Details</h1>
|
||||
<div class="text-secondary"><code>@CurrentSession.SessionId</code></div>
|
||||
</div>
|
||||
<StatusBadge Text="@CurrentSession.State.ToString()" />
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Session</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Backend</th><td>@CurrentSession.BackendName</td></tr>
|
||||
<tr><th scope="row">Client identity</th><td>@DashboardDisplay.Text(CurrentSession.ClientIdentity)</td></tr>
|
||||
<tr><th scope="row">Client session</th><td>@DashboardDisplay.Text(CurrentSession.ClientSessionName)</td></tr>
|
||||
<tr><th scope="row">Client correlation</th><td>@DashboardDisplay.Text(CurrentSession.ClientCorrelationId)</td></tr>
|
||||
<tr><th scope="row">Opened</th><td>@DashboardDisplay.DateTime(CurrentSession.OpenedAt)</td></tr>
|
||||
<tr><th scope="row">Last activity</th><td>@DashboardDisplay.DateTime(CurrentSession.LastClientActivityAt)</td></tr>
|
||||
<tr><th scope="row">Lease expires</th><td>@DashboardDisplay.DateTime(CurrentSession.LeaseExpiresAt)</td></tr>
|
||||
<tr><th scope="row">Events received</th><td>@DashboardDisplay.Count(CurrentSession.EventsReceived)</td></tr>
|
||||
<tr><th scope="row">Last fault</th><td>@DashboardDisplay.Text(CurrentSession.LastFault)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="section-heading">
|
||||
<h2>Worker</h2>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Process id</th><td>@(CurrentSession.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td></tr>
|
||||
<tr><th scope="row">State</th><td><StatusBadge Text="@(CurrentSession.WorkerState?.ToString() ?? "-")" /></td></tr>
|
||||
<tr><th scope="row">Last heartbeat</th><td>@DashboardDisplay.DateTime(CurrentSession.LastWorkerHeartbeatAt)</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string SessionId { get; set; } = string.Empty;
|
||||
|
||||
private DashboardSessionSummary? CurrentSession => Snapshot?.Sessions.FirstOrDefault(session =>
|
||||
string.Equals(session.SessionId, SessionId, StringComparison.Ordinal));
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
@page "/sessions"
|
||||
@page "/dashboard/sessions"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Sessions</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading sessions.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Sessions</h1>
|
||||
<div class="text-secondary">@Snapshot.Sessions.Count session rows</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.Sessions.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No sessions are active or retained.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Session</th>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Client</th>
|
||||
<th scope="col">Backend</th>
|
||||
<th scope="col">Worker</th>
|
||||
<th scope="col">Events</th>
|
||||
<th scope="col">Opened</th>
|
||||
<th scope="col">Activity</th>
|
||||
<th scope="col">Heartbeat</th>
|
||||
<th scope="col">Fault</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardSessionSummary session in Snapshot.Sessions)
|
||||
{
|
||||
<tr>
|
||||
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(session.SessionId)}")"><code>@session.SessionId</code></NavLink></td>
|
||||
<td><StatusBadge Text="@session.State.ToString()" /></td>
|
||||
<td>@DashboardDisplay.Text(session.ClientIdentity)</td>
|
||||
<td>@session.BackendName</td>
|
||||
<td>
|
||||
@(session.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")
|
||||
@if (session.WorkerState is not null)
|
||||
{
|
||||
<span class="ms-1"><StatusBadge Text="@session.WorkerState.ToString()" /></span>
|
||||
}
|
||||
</td>
|
||||
<td>@DashboardDisplay.Count(session.EventsReceived)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.OpenedAt)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.LastClientActivityAt)</td>
|
||||
<td>@DashboardDisplay.DateTime(session.LastWorkerHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(session.LastFault)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
@page "/settings"
|
||||
@page "/dashboard/settings"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Settings</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading settings.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Settings</h1>
|
||||
<div class="text-secondary">Effective gateway configuration</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm dashboard-table details-table">
|
||||
<tbody>
|
||||
<tr><th scope="row">Authentication mode</th><td>@Snapshot.Configuration.Authentication.Mode</td></tr>
|
||||
<tr><th scope="row">Auth database</th><td><code>@Snapshot.Configuration.Authentication.SqlitePath</code></td></tr>
|
||||
<tr><th scope="row">Pepper secret</th><td>@Snapshot.Configuration.Authentication.PepperSecretName</td></tr>
|
||||
<tr><th scope="row">Run migrations</th><td>@Snapshot.Configuration.Authentication.RunMigrationsOnStartup</td></tr>
|
||||
<tr><th scope="row">LDAP enabled</th><td>@Snapshot.Configuration.Ldap.Enabled</td></tr>
|
||||
<tr><th scope="row">LDAP server</th><td>@Snapshot.Configuration.Ldap.Server:@Snapshot.Configuration.Ldap.Port</td></tr>
|
||||
<tr><th scope="row">LDAP TLS</th><td>@Snapshot.Configuration.Ldap.UseTls</td></tr>
|
||||
<tr><th scope="row">LDAP search base</th><td><code>@Snapshot.Configuration.Ldap.SearchBase</code></td></tr>
|
||||
<tr><th scope="row">LDAP service account</th><td><code>@Snapshot.Configuration.Ldap.ServiceAccountDn</code></td></tr>
|
||||
<tr><th scope="row">LDAP service password</th><td>@Snapshot.Configuration.Ldap.ServiceAccountPassword</td></tr>
|
||||
<tr><th scope="row">LDAP username attribute</th><td>@Snapshot.Configuration.Ldap.UserNameAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP group attribute</th><td>@Snapshot.Configuration.Ldap.GroupAttribute</td></tr>
|
||||
<tr><th scope="row">LDAP required group</th><td>@Snapshot.Configuration.Ldap.RequiredGroup</td></tr>
|
||||
<tr><th scope="row">Worker executable</th><td><code>@Snapshot.Configuration.Worker.ExecutablePath</code></td></tr>
|
||||
<tr><th scope="row">Worker architecture</th><td>@Snapshot.Configuration.Worker.RequiredArchitecture</td></tr>
|
||||
<tr><th scope="row">Startup timeout</th><td>@Snapshot.Configuration.Worker.StartupTimeoutSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Shutdown timeout</th><td>@Snapshot.Configuration.Worker.ShutdownTimeoutSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Heartbeat grace</th><td>@Snapshot.Configuration.Worker.HeartbeatGraceSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Default command timeout</th><td>@Snapshot.Configuration.Sessions.DefaultCommandTimeoutSeconds seconds</td></tr>
|
||||
<tr><th scope="row">Max sessions</th><td>@Snapshot.Configuration.Sessions.MaxSessions</td></tr>
|
||||
<tr><th scope="row">Event queue capacity</th><td>@Snapshot.Configuration.Events.QueueCapacity</td></tr>
|
||||
<tr><th scope="row">Backpressure policy</th><td>@Snapshot.Configuration.Events.BackpressurePolicy</td></tr>
|
||||
<tr><th scope="row">Dashboard enabled</th><td>@Snapshot.Configuration.Dashboard.Enabled</td></tr>
|
||||
<tr><th scope="row">Dashboard path</th><td>@Snapshot.Configuration.Dashboard.PathBase</td></tr>
|
||||
<tr><th scope="row">Require admin scope</th><td>@Snapshot.Configuration.Dashboard.RequireAdminScope</td></tr>
|
||||
<tr><th scope="row">Anonymous localhost</th><td>@Snapshot.Configuration.Dashboard.AllowAnonymousLocalhost</td></tr>
|
||||
<tr><th scope="row">Snapshot interval</th><td>@Snapshot.Configuration.Dashboard.SnapshotIntervalMilliseconds ms</td></tr>
|
||||
<tr><th scope="row">Show tag values</th><td>@Snapshot.Configuration.Dashboard.ShowTagValues</td></tr>
|
||||
<tr><th scope="row">Worker protocol</th><td>@Snapshot.Configuration.Protocol.WorkerProtocolVersion</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
@page "/workers"
|
||||
@page "/dashboard/workers"
|
||||
@inherits DashboardPageBase
|
||||
|
||||
<PageTitle>Dashboard Workers</PageTitle>
|
||||
|
||||
@if (Snapshot is null)
|
||||
{
|
||||
<div class="empty-state">Loading workers.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="dashboard-page-header">
|
||||
<div>
|
||||
<h1>Workers</h1>
|
||||
<div class="text-secondary">@Snapshot.Workers.Count worker rows</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-section">
|
||||
@if (Snapshot.Workers.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No worker processes are attached.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Process</th>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Session</th>
|
||||
<th scope="col">Heartbeat</th>
|
||||
<th scope="col">Fault</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardWorkerSummary worker in Snapshot.Workers)
|
||||
{
|
||||
<tr>
|
||||
<td>@(worker.ProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
|
||||
<td><StatusBadge Text="@worker.State.ToString()" /></td>
|
||||
<td><NavLink href="@($"sessions/{Uri.EscapeDataString(worker.SessionId)}")"><code>@worker.SessionId</code></NavLink></td>
|
||||
<td>@DashboardDisplay.DateTime(worker.LastHeartbeatAt)</td>
|
||||
<td>@DashboardDisplay.Text(worker.LastFault)</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<Router AppAssembly="@typeof(Routes).Assembly">
|
||||
<Found Context="routeData">
|
||||
<RouteView RouteData="@routeData" DefaultLayout="@typeof(DashboardLayout)" />
|
||||
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
|
||||
</Found>
|
||||
<NotFound>
|
||||
<LayoutView Layout="@typeof(DashboardLayout)">
|
||||
<PageTitle>Dashboard - Not Found</PageTitle>
|
||||
<section class="dashboard-section">
|
||||
<h1 class="h4 mb-3">Not Found</h1>
|
||||
<p class="mb-0">The requested dashboard page does not exist.</p>
|
||||
</section>
|
||||
</LayoutView>
|
||||
</NotFound>
|
||||
</Router>
|
||||
@@ -0,0 +1,102 @@
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy
|
||||
|
||||
@*
|
||||
Recursive Browse hierarchy node. Renders one Galaxy object, its child
|
||||
objects (recursively), and its attributes as right-clickable tag rows.
|
||||
Expansion state is local; children render only while expanded.
|
||||
*@
|
||||
|
||||
<div class="tree-node">
|
||||
<div class="tree-row @(Node.IsArea ? "tree-row-area" : "tree-row-object")">
|
||||
@if (Node.HasChildren)
|
||||
{
|
||||
<button type="button" class="tree-toggle" @onclick="Toggle" aria-label="Toggle">
|
||||
@(_expanded ? "▾" : "▸")
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
}
|
||||
<span class="tree-label" @onclick="Toggle">
|
||||
<span class="tree-icon">@(Node.IsArea ? "▣" : "◇")</span>
|
||||
<span class="tree-name">@Node.DisplayName</span>
|
||||
@if (!string.IsNullOrWhiteSpace(Node.Object.TagName)
|
||||
&& !string.Equals(Node.Object.TagName, Node.DisplayName, StringComparison.Ordinal))
|
||||
{
|
||||
<code class="tree-tag">@Node.Object.TagName</code>
|
||||
}
|
||||
</span>
|
||||
</div>
|
||||
@if (_expanded)
|
||||
{
|
||||
<div class="tree-children">
|
||||
@foreach (DashboardBrowseNode child in Node.Children)
|
||||
{
|
||||
<BrowseTreeNodeView Node="child" OnAddTag="OnAddTag" OnTagContextMenu="OnTagContextMenu" />
|
||||
}
|
||||
@foreach (GalaxyAttribute attr in Node.Attributes)
|
||||
{
|
||||
GalaxyAttribute row = attr;
|
||||
<div class="tree-attr"
|
||||
title="@row.FullTagReference"
|
||||
@oncontextmenu:preventDefault="true"
|
||||
@oncontextmenu="@(args => OnTagContextMenu.InvokeAsync((args, row)))"
|
||||
@ondblclick="@(() => OnAddTag.InvokeAsync(row.FullTagReference))">
|
||||
<span class="tree-toggle tree-toggle-empty"></span>
|
||||
<span class="attr-icon">·</span>
|
||||
<span class="attr-name">@row.AttributeName</span>
|
||||
<span class="attr-type">@DisplayType(row)</span>
|
||||
@if (row.IsAlarm)
|
||||
{
|
||||
<span class="attr-flag attr-flag-alarm">alarm</span>
|
||||
}
|
||||
@if (row.IsHistorized)
|
||||
{
|
||||
<span class="attr-flag attr-flag-hist">hist</span>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@code {
|
||||
/// <summary>The hierarchy node this view renders.</summary>
|
||||
[Parameter]
|
||||
[EditorRequired]
|
||||
public DashboardBrowseNode Node { get; set; } = null!;
|
||||
|
||||
/// <summary>Raised with a tag's full reference when the operator double-clicks it.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<string> OnAddTag { get; set; }
|
||||
|
||||
/// <summary>Raised when an attribute row is right-clicked, for the context menu.</summary>
|
||||
[Parameter]
|
||||
public EventCallback<(MouseEventArgs Event, GalaxyAttribute Attribute)> OnTagContextMenu { get; set; }
|
||||
|
||||
private bool _expanded;
|
||||
|
||||
private void Toggle()
|
||||
{
|
||||
if (Node.HasChildren)
|
||||
{
|
||||
_expanded = !_expanded;
|
||||
}
|
||||
}
|
||||
|
||||
private static string DisplayType(GalaxyAttribute attribute)
|
||||
{
|
||||
string baseType = string.IsNullOrWhiteSpace(attribute.DataTypeName)
|
||||
? "type?"
|
||||
: attribute.DataTypeName;
|
||||
if (attribute.IsArray)
|
||||
{
|
||||
return attribute.ArrayDimensionPresent
|
||||
? $"{baseType}[{attribute.ArrayDimension}]"
|
||||
: $"{baseType}[]";
|
||||
}
|
||||
|
||||
return baseType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
@if (Faults.Count == 0)
|
||||
{
|
||||
<div class="empty-state">No faults recorded.</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle dashboard-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">Observed</th>
|
||||
<th scope="col">Source</th>
|
||||
<th scope="col">Session</th>
|
||||
<th scope="col">Worker</th>
|
||||
<th scope="col">State</th>
|
||||
<th scope="col">Message</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@foreach (DashboardFaultSummary fault in Faults)
|
||||
{
|
||||
<tr>
|
||||
<td>@DashboardDisplay.DateTime(fault.ObservedAt)</td>
|
||||
<td>@fault.Source</td>
|
||||
<td><code>@DashboardDisplay.Text(fault.SessionId)</code></td>
|
||||
<td>@(fault.WorkerProcessId?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "-")</td>
|
||||
<td><StatusBadge Text="@fault.State" /></td>
|
||||
<td>@fault.Message</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public IReadOnlyList<DashboardFaultSummary> Faults { get; set; } = [];
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
<div class="card metric-card h-100@(Wide ? " metric-card-wide" : string.Empty)">
|
||||
<div class="card-body">
|
||||
<div class="metric-label">@Label</div>
|
||||
<div class="metric-value">@Value</div>
|
||||
@if (!string.IsNullOrWhiteSpace(Detail))
|
||||
{
|
||||
<div class="metric-detail">@Detail</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string Label { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string Value { get; set; } = string.Empty;
|
||||
|
||||
[Parameter]
|
||||
public string? Detail { get; set; }
|
||||
|
||||
/// <summary>Spans the card across two grid columns for long values such as timestamps.</summary>
|
||||
[Parameter]
|
||||
public bool Wide { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<span class="chip @CssClass">@Text</span>
|
||||
|
||||
@code {
|
||||
[Parameter]
|
||||
public string? Text { get; set; }
|
||||
|
||||
private string CssClass => Text switch
|
||||
{
|
||||
"Ready" or "Healthy" or "Active" => "chip-ok",
|
||||
"Creating" or "StartingWorker" or "WaitingForPipe" or "InitializingWorker" or "Closing" => "chip-warn",
|
||||
"Stale" or "Degraded" => "chip-warn",
|
||||
"Faulted" or "Unavailable" => "chip-bad",
|
||||
"Closed" or "Revoked" or "Unknown" => "chip-idle",
|
||||
_ => "chip-idle"
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
@using Microsoft.AspNetCore.Components.Authorization
|
||||
@using Microsoft.AspNetCore.Components.Forms
|
||||
@using Microsoft.AspNetCore.Components.Routing
|
||||
@using Microsoft.AspNetCore.Components.Web
|
||||
@using Microsoft.Extensions.Options
|
||||
@using ZB.MOM.WW.MxGateway.Contracts.Proto
|
||||
@using ZB.MOM.WW.MxGateway.Server.Configuration
|
||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard
|
||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Layout
|
||||
@using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Shared
|
||||
@using ZB.MOM.WW.MxGateway.Server.Security.Authorization
|
||||
@using ZB.MOM.WW.MxGateway.Server.Workers
|
||||
@using static Microsoft.AspNetCore.Components.Web.RenderMode
|
||||
@@ -0,0 +1,63 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// One active-alarm row as shown on the dashboard Alarms tab. Projected
|
||||
/// from an <see cref="ActiveAlarmSnapshot"/> so the Razor component never
|
||||
/// touches protobuf types directly.
|
||||
/// </summary>
|
||||
public sealed record DashboardActiveAlarm(
|
||||
string Reference,
|
||||
string Provider,
|
||||
string Area,
|
||||
string Source,
|
||||
string AlarmType,
|
||||
int Severity,
|
||||
AlarmConditionState State,
|
||||
DateTimeOffset? LastTransition,
|
||||
string OperatorUser,
|
||||
string OperatorComment,
|
||||
string Description)
|
||||
{
|
||||
/// <summary>Projects a worker active-alarm snapshot into a dashboard alarm row.</summary>
|
||||
/// <param name="snapshot">The snapshot returned by <c>QueryActiveAlarms</c>.</param>
|
||||
/// <returns>The projected dashboard alarm.</returns>
|
||||
public static DashboardActiveAlarm FromSnapshot(ActiveAlarmSnapshot snapshot)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
|
||||
string provider = string.Empty;
|
||||
string reference = snapshot.AlarmFullReference ?? string.Empty;
|
||||
int bang = reference.IndexOf('!', StringComparison.Ordinal);
|
||||
if (bang > 0)
|
||||
{
|
||||
provider = reference[..bang];
|
||||
}
|
||||
|
||||
return new DashboardActiveAlarm(
|
||||
Reference: reference,
|
||||
Provider: provider,
|
||||
Area: snapshot.Category ?? string.Empty,
|
||||
Source: snapshot.SourceObjectReference ?? string.Empty,
|
||||
AlarmType: snapshot.AlarmTypeName ?? string.Empty,
|
||||
Severity: snapshot.Severity,
|
||||
State: snapshot.CurrentState,
|
||||
LastTransition: snapshot.LastTransitionTimestamp?.ToDateTimeOffset(),
|
||||
OperatorUser: snapshot.OperatorUser ?? string.Empty,
|
||||
OperatorComment: snapshot.OperatorComment ?? string.Empty,
|
||||
Description: snapshot.Description ?? string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>True when this alarm is active and not yet acknowledged.</summary>
|
||||
public bool IsUnacknowledged => State == AlarmConditionState.Active;
|
||||
}
|
||||
|
||||
/// <summary>Result of a dashboard active-alarm query.</summary>
|
||||
/// <param name="Alarms">The active alarms, or an empty list on error.</param>
|
||||
/// <param name="Error">A diagnostic message when the query failed; otherwise null.</param>
|
||||
/// <param name="WorkerProcessId">The worker process id backing the dashboard session, when available.</param>
|
||||
public sealed record DashboardAlarmQueryResult(
|
||||
IReadOnlyList<DashboardActiveAlarm> Alarms,
|
||||
string? Error,
|
||||
int? WorkerProcessId);
|
||||
@@ -0,0 +1,22 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyAuthorization(IOptions<GatewayOptions> options)
|
||||
{
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
if (user.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string requiredGroup = options.Value.Ldap.RequiredGroup;
|
||||
IEnumerable<string> groups = user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType)
|
||||
.Select(claim => claim.Value);
|
||||
|
||||
return DashboardAuthenticator.IsMemberOfRequiredGroup(groups, requiredGroup);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardApiKeyManagementRequest(
|
||||
string KeyId,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints);
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardApiKeyManagementResult(
|
||||
bool Succeeded,
|
||||
string Message,
|
||||
string? ApiKey)
|
||||
{
|
||||
public static DashboardApiKeyManagementResult Success(string message, string? apiKey = null)
|
||||
{
|
||||
return new DashboardApiKeyManagementResult(true, message, apiKey);
|
||||
}
|
||||
|
||||
public static DashboardApiKeyManagementResult Fail(string message)
|
||||
{
|
||||
return new DashboardApiKeyManagementResult(false, message, null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardApiKeyManagementService(
|
||||
DashboardApiKeyAuthorization authorization,
|
||||
IApiKeyAdminStore adminStore,
|
||||
IApiKeyAuditStore auditStore,
|
||||
IApiKeySecretHasher hasher,
|
||||
IHttpContextAccessor httpContextAccessor) : IDashboardApiKeyManagementService
|
||||
{
|
||||
private const string UnauthorizedMessage = "Sign in with an authorized LDAP account to manage API keys.";
|
||||
|
||||
public bool CanManage(ClaimsPrincipal user)
|
||||
{
|
||||
return authorization.CanManage(user);
|
||||
}
|
||||
|
||||
public async Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardApiKeyManagementRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
string? validation = ValidateCreateRequest(request);
|
||||
if (validation is not null)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(validation);
|
||||
}
|
||||
|
||||
string keyId = request.KeyId.Trim();
|
||||
string secret = ApiKeySecretGenerator.Generate();
|
||||
string apiKey = FormatApiKey(keyId, secret);
|
||||
|
||||
try
|
||||
{
|
||||
await adminStore.CreateAsync(
|
||||
new ApiKeyCreateRequest(
|
||||
KeyId: keyId,
|
||||
KeyPrefix: $"mxgw_{keyId}",
|
||||
SecretHash: hasher.HashSecret(secret),
|
||||
DisplayName: request.DisplayName.Trim(),
|
||||
Scopes: request.Scopes,
|
||||
Constraints: request.Constraints,
|
||||
CreatedUtc: DateTimeOffset.UtcNow),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await AppendAuditAsync(keyId, "dashboard-create-key", null, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
return DashboardApiKeyManagementResult.Success("API key created. Copy the key now; it will not be shown again.", apiKey);
|
||||
}
|
||||
catch (ApiKeyPepperUnavailableException)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||
}
|
||||
catch (SqliteException exception) when (exception.SqliteErrorCode == 19)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail("An API key with that id already exists.");
|
||||
}
|
||||
}
|
||||
|
||||
public async Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
string? validation = ValidateKeyId(keyId);
|
||||
if (validation is not null)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(validation);
|
||||
}
|
||||
|
||||
string normalizedKeyId = keyId.Trim();
|
||||
bool revoked = await adminStore
|
||||
.RevokeAsync(normalizedKeyId, DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await AppendAuditAsync(
|
||||
normalizedKeyId,
|
||||
"dashboard-revoke-key",
|
||||
revoked ? "revoked" : "not-found-or-already-revoked",
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return revoked
|
||||
? DashboardApiKeyManagementResult.Success("API key revoked.")
|
||||
: DashboardApiKeyManagementResult.Fail("API key was not found or is already revoked.");
|
||||
}
|
||||
|
||||
public async Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (!CanManage(user))
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(UnauthorizedMessage);
|
||||
}
|
||||
|
||||
string? validation = ValidateKeyId(keyId);
|
||||
if (validation is not null)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail(validation);
|
||||
}
|
||||
|
||||
string normalizedKeyId = keyId.Trim();
|
||||
string secret = ApiKeySecretGenerator.Generate();
|
||||
string apiKey = FormatApiKey(normalizedKeyId, secret);
|
||||
|
||||
try
|
||||
{
|
||||
bool rotated = await adminStore
|
||||
.RotateAsync(normalizedKeyId, hasher.HashSecret(secret), DateTimeOffset.UtcNow, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await AppendAuditAsync(
|
||||
normalizedKeyId,
|
||||
"dashboard-rotate-key",
|
||||
rotated ? "rotated" : "not-found",
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return rotated
|
||||
? DashboardApiKeyManagementResult.Success("API key rotated. Copy the key now; it will not be shown again.", apiKey)
|
||||
: DashboardApiKeyManagementResult.Fail("API key was not found.");
|
||||
}
|
||||
catch (ApiKeyPepperUnavailableException)
|
||||
{
|
||||
return DashboardApiKeyManagementResult.Fail("API key pepper is not configured.");
|
||||
}
|
||||
}
|
||||
|
||||
private async Task AppendAuditAsync(
|
||||
string? keyId,
|
||||
string eventType,
|
||||
string? details,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await auditStore.AppendAsync(
|
||||
new ApiKeyAuditEntry(
|
||||
KeyId: keyId,
|
||||
EventType: eventType,
|
||||
RemoteAddress: httpContextAccessor.HttpContext?.Connection.RemoteIpAddress?.ToString(),
|
||||
Details: details),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static string? ValidateCreateRequest(DashboardApiKeyManagementRequest request)
|
||||
{
|
||||
string? keyIdValidation = ValidateKeyId(request.KeyId);
|
||||
if (keyIdValidation is not null)
|
||||
{
|
||||
return keyIdValidation;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(request.DisplayName))
|
||||
{
|
||||
return "Display name is required.";
|
||||
}
|
||||
|
||||
string[] unknownScopes = request.Scopes
|
||||
.Where(scope => !GatewayScopes.IsKnown(scope))
|
||||
.ToArray();
|
||||
if (unknownScopes.Length > 0)
|
||||
{
|
||||
return $"Unknown scope(s): {string.Join(", ", unknownScopes)}. "
|
||||
+ $"Valid scopes are: {string.Join(", ", GatewayScopes.All)}.";
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? ValidateKeyId(string keyId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(keyId))
|
||||
{
|
||||
return "API key id is required.";
|
||||
}
|
||||
|
||||
return keyId.Trim().All(character =>
|
||||
char.IsAsciiLetterOrDigit(character)
|
||||
|| character is '.' or '-')
|
||||
? null
|
||||
: "API key id may contain only letters, numbers, periods, and hyphens.";
|
||||
}
|
||||
|
||||
private static string FormatApiKey(string keyId, string secret)
|
||||
{
|
||||
return $"mxgw_{keyId}_{secret}";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardApiKeySummary(
|
||||
string KeyId,
|
||||
string DisplayName,
|
||||
IReadOnlySet<string> Scopes,
|
||||
ApiKeyConstraints Constraints,
|
||||
DateTimeOffset CreatedUtc,
|
||||
DateTimeOffset? LastUsedUtc,
|
||||
DateTimeOffset? RevokedUtc);
|
||||
@@ -0,0 +1,11 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public static class DashboardAuthenticationDefaults
|
||||
{
|
||||
public const string AuthenticationScheme = "MxGateway.Dashboard";
|
||||
public const string AuthorizationPolicy = "MxGateway.Dashboard";
|
||||
public const string ScopeClaimType = "scope";
|
||||
public const string LdapGroupClaimType = "mxgateway:ldap_group";
|
||||
public const string KeyPrefixClaimType = "mxgateway:key_prefix";
|
||||
public const string CookieName = "__Host-MxGatewayDashboard";
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Result of a dashboard authentication attempt.
|
||||
/// </summary>
|
||||
public sealed record DashboardAuthenticationResult(
|
||||
/// <summary>
|
||||
/// Whether authentication succeeded.
|
||||
/// </summary>
|
||||
bool Succeeded,
|
||||
/// <summary>
|
||||
/// The authenticated principal if successful; otherwise null.
|
||||
/// </summary>
|
||||
ClaimsPrincipal? Principal,
|
||||
/// <summary>
|
||||
/// The failure message if authentication failed; otherwise null.
|
||||
/// </summary>
|
||||
string? FailureMessage)
|
||||
{
|
||||
/// <summary>
|
||||
/// Creates a successful authentication result.
|
||||
/// </summary>
|
||||
/// <param name="principal">Authenticated principal.</param>
|
||||
public static DashboardAuthenticationResult Success(ClaimsPrincipal principal)
|
||||
{
|
||||
return new DashboardAuthenticationResult(true, principal, null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a failed authentication result.
|
||||
/// </summary>
|
||||
/// <param name="failureMessage">Diagnostic message describing the failure.</param>
|
||||
public static DashboardAuthenticationResult Fail(string failureMessage)
|
||||
{
|
||||
return new DashboardAuthenticationResult(false, null, failureMessage);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
using System.Security.Claims;
|
||||
using System.Text;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
using Novell.Directory.Ldap;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthenticator(
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||
{
|
||||
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? username,
|
||||
string? password,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
LdapOptions ldapOptions = options.Value.Ldap;
|
||||
if (!ldapOptions.Enabled
|
||||
|| string.IsNullOrWhiteSpace(username)
|
||||
|| string.IsNullOrWhiteSpace(password))
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
if (!ldapOptions.UseTls && !ldapOptions.AllowInsecureLdap)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string normalizedUsername = username.Trim();
|
||||
|
||||
try
|
||||
{
|
||||
using LdapConnection connection = new();
|
||||
connection.SecureSocketLayer = ldapOptions.UseTls;
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Connect(ldapOptions.Server, ldapOptions.Port),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? candidate = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (candidate is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
await Task.Run(
|
||||
() => connection.Bind(candidate.Dn, password),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
await BindServiceAccountAsync(connection, ldapOptions, cancellationToken).ConfigureAwait(false);
|
||||
LdapEntry? authenticatedEntry = await SearchUserAsync(
|
||||
connection,
|
||||
ldapOptions,
|
||||
normalizedUsername,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (authenticatedEntry is null)
|
||||
{
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
string displayName = ReadAttribute(authenticatedEntry, ldapOptions.DisplayNameAttribute)
|
||||
?? normalizedUsername;
|
||||
IReadOnlyList<string> groups = ReadAttributeValues(authenticatedEntry, ldapOptions.GroupAttribute);
|
||||
|
||||
if (!IsMemberOfRequiredGroup(groups, ldapOptions.RequiredGroup))
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login denied for user {User}: missing required group {RequiredGroup}.",
|
||||
normalizedUsername,
|
||||
ldapOptions.RequiredGroup);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
|
||||
return DashboardAuthenticationResult.Success(CreatePrincipal(
|
||||
normalizedUsername,
|
||||
displayName,
|
||||
groups));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (LdapException ex)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"LDAP dashboard login rejected for user {User}: result code {ResultCode}.",
|
||||
normalizedUsername,
|
||||
ex.ResultCode);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
logger.LogError(ex, "Unexpected LDAP dashboard login error for user {User}.", normalizedUsername);
|
||||
|
||||
return DashboardAuthenticationResult.Fail(GenericFailureMessage);
|
||||
}
|
||||
}
|
||||
|
||||
internal static string EscapeLdapFilter(string value)
|
||||
{
|
||||
StringBuilder builder = new(value.Length);
|
||||
foreach (char character in value)
|
||||
{
|
||||
builder.Append(character switch
|
||||
{
|
||||
'\\' => @"\5c",
|
||||
'*' => @"\2a",
|
||||
'(' => @"\28",
|
||||
')' => @"\29",
|
||||
'\0' => @"\00",
|
||||
_ => character.ToString()
|
||||
});
|
||||
}
|
||||
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
internal static bool IsMemberOfRequiredGroup(IEnumerable<string> groups, string requiredGroup)
|
||||
{
|
||||
string normalizedRequiredGroup = requiredGroup.Trim();
|
||||
if (string.IsNullOrWhiteSpace(normalizedRequiredGroup))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string group in groups)
|
||||
{
|
||||
string normalizedGroup = group.Trim();
|
||||
if (string.Equals(normalizedGroup, normalizedRequiredGroup, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(
|
||||
ExtractFirstRdnValue(normalizedGroup),
|
||||
normalizedRequiredGroup,
|
||||
StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
internal static string ExtractFirstRdnValue(string distinguishedName)
|
||||
{
|
||||
int equalsIndex = distinguishedName.IndexOf('=');
|
||||
if (equalsIndex < 0)
|
||||
{
|
||||
return distinguishedName;
|
||||
}
|
||||
|
||||
int valueStart = equalsIndex + 1;
|
||||
int commaIndex = distinguishedName.IndexOf(',', valueStart);
|
||||
|
||||
return commaIndex > valueStart
|
||||
? distinguishedName[valueStart..commaIndex]
|
||||
: distinguishedName[valueStart..];
|
||||
}
|
||||
|
||||
private static Task BindServiceAccountAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.Run(
|
||||
() => connection.Bind(ldapOptions.ServiceAccountDn, ldapOptions.ServiceAccountPassword),
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<LdapEntry?> SearchUserAsync(
|
||||
LdapConnection connection,
|
||||
LdapOptions ldapOptions,
|
||||
string username,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string filter = $"({ldapOptions.UserNameAttribute}={EscapeLdapFilter(username)})";
|
||||
ILdapSearchResults results = await Task.Run(
|
||||
() => connection.Search(
|
||||
ldapOptions.SearchBase,
|
||||
LdapConnection.ScopeSub,
|
||||
filter,
|
||||
attrs: null,
|
||||
typesOnly: false),
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
LdapEntry? entry = null;
|
||||
while (results.HasMore())
|
||||
{
|
||||
LdapEntry next = results.Next();
|
||||
if (entry is not null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
entry = next;
|
||||
}
|
||||
|
||||
return entry;
|
||||
}
|
||||
|
||||
private static string? ReadAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
return ReadLdapAttribute(entry, attributeName)?.StringValue;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string> ReadAttributeValues(LdapEntry entry, string attributeName)
|
||||
{
|
||||
LdapAttribute? attribute = ReadLdapAttribute(entry, attributeName);
|
||||
return attribute?.StringValueArray ?? [];
|
||||
}
|
||||
|
||||
private static LdapAttribute? ReadLdapAttribute(LdapEntry entry, string attributeName)
|
||||
{
|
||||
return entry.GetAttribute(attributeName)
|
||||
?? entry.GetAttribute(attributeName.ToLowerInvariant())
|
||||
?? entry.GetAttribute(attributeName.ToUpperInvariant());
|
||||
}
|
||||
|
||||
private static ClaimsPrincipal CreatePrincipal(
|
||||
string username,
|
||||
string displayName,
|
||||
IEnumerable<string> groups)
|
||||
{
|
||||
// CreatePrincipal is reached only after IsMemberOfRequiredGroup passed,
|
||||
// so the authenticated user is authorized for the dashboard. Emit the
|
||||
// admin scope claim that DashboardAuthorizationHandler checks when
|
||||
// Dashboard:RequireAdminScope is enabled — without it, every LDAP login
|
||||
// would be denied once route-level authorization is enforced.
|
||||
List<Claim> claims =
|
||||
[
|
||||
new Claim(ClaimTypes.NameIdentifier, username),
|
||||
new Claim(ClaimTypes.Name, displayName),
|
||||
new Claim(DashboardAuthenticationDefaults.ScopeClaimType, GatewayScopes.Admin)
|
||||
];
|
||||
|
||||
claims.AddRange(groups.Select(group => new Claim(
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType,
|
||||
group)));
|
||||
|
||||
ClaimsIdentity claimsIdentity = new(
|
||||
claims,
|
||||
DashboardAuthenticationDefaults.AuthenticationScheme,
|
||||
ClaimTypes.Name,
|
||||
DashboardAuthenticationDefaults.LdapGroupClaimType);
|
||||
|
||||
return new ClaimsPrincipal(claimsIdentity);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
using System.Net;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthorizationHandler(
|
||||
IHttpContextAccessor httpContextAccessor,
|
||||
IOptions<GatewayOptions> options) : AuthorizationHandler<DashboardAuthorizationRequirement>
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override Task HandleRequirementAsync(
|
||||
AuthorizationHandlerContext context,
|
||||
DashboardAuthorizationRequirement requirement)
|
||||
{
|
||||
GatewayOptions gatewayOptions = options.Value;
|
||||
|
||||
if (gatewayOptions.Authentication.Mode == AuthenticationMode.Disabled)
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (gatewayOptions.Dashboard.AllowAnonymousLocalhost && IsLoopbackRequest())
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (context.User.Identity?.IsAuthenticated != true)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (!gatewayOptions.Dashboard.RequireAdminScope || HasAdminScope(context))
|
||||
{
|
||||
context.Succeed(requirement);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
private bool IsLoopbackRequest()
|
||||
{
|
||||
IPAddress? remoteAddress = httpContextAccessor.HttpContext?.Connection.RemoteIpAddress;
|
||||
|
||||
return remoteAddress is not null && IPAddress.IsLoopback(remoteAddress);
|
||||
}
|
||||
|
||||
private static bool HasAdminScope(AuthorizationHandlerContext context)
|
||||
{
|
||||
return context.User.HasClaim(
|
||||
DashboardAuthenticationDefaults.ScopeClaimType,
|
||||
GatewayScopes.Admin);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardAuthorizationRequirement : IAuthorizationRequirement;
|
||||
@@ -0,0 +1,95 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// One node in the dashboard Browse hierarchy tree. Wraps a Galaxy object
|
||||
/// and its child objects; the object's attributes are the leaf "tags" the
|
||||
/// operator can right-click and add to the subscription panel.
|
||||
/// </summary>
|
||||
public sealed class DashboardBrowseNode
|
||||
{
|
||||
/// <summary>The underlying Galaxy object for this node.</summary>
|
||||
public required GalaxyObject Object { get; init; }
|
||||
|
||||
/// <summary>Child objects contained by this object, sorted areas-first then by name.</summary>
|
||||
public List<DashboardBrowseNode> Children { get; } = [];
|
||||
|
||||
/// <summary>The label shown for this node in the tree.</summary>
|
||||
public string DisplayName =>
|
||||
!string.IsNullOrWhiteSpace(Object.BrowseName) ? Object.BrowseName
|
||||
: !string.IsNullOrWhiteSpace(Object.ContainedName) ? Object.ContainedName
|
||||
: Object.TagName;
|
||||
|
||||
/// <summary>True when this node is a Galaxy area rather than an instance object.</summary>
|
||||
public bool IsArea => Object.IsArea;
|
||||
|
||||
/// <summary>The object's attributes — the browsable tags.</summary>
|
||||
public IReadOnlyList<GalaxyAttribute> Attributes => Object.Attributes;
|
||||
|
||||
/// <summary>True when the node has child objects or attributes to expand.</summary>
|
||||
public bool HasChildren => Children.Count > 0 || Object.Attributes.Count > 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the dashboard Browse tree from the flat Galaxy object list held
|
||||
/// by <c>IGalaxyHierarchyCache</c>. Pure and side-effect free so the
|
||||
/// parent/child linkage and ordering rules are unit-testable.
|
||||
/// </summary>
|
||||
public static class DashboardBrowseTreeBuilder
|
||||
{
|
||||
/// <summary>Builds the root nodes of the Browse tree.</summary>
|
||||
/// <param name="objects">The flat Galaxy object list.</param>
|
||||
/// <returns>The root nodes, sorted areas-first then alphabetically.</returns>
|
||||
public static IReadOnlyList<DashboardBrowseNode> Build(IReadOnlyList<GalaxyObject> objects)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(objects);
|
||||
|
||||
Dictionary<int, DashboardBrowseNode> nodes = new(objects.Count);
|
||||
foreach (GalaxyObject galaxyObject in objects)
|
||||
{
|
||||
// Last write wins on a duplicate gobject id — Galaxy ids are unique
|
||||
// in practice, but guard so the dictionary build never throws.
|
||||
nodes[galaxyObject.GobjectId] = new DashboardBrowseNode { Object = galaxyObject };
|
||||
}
|
||||
|
||||
List<DashboardBrowseNode> roots = [];
|
||||
foreach (DashboardBrowseNode node in nodes.Values)
|
||||
{
|
||||
int parentId = node.Object.ParentGobjectId;
|
||||
if (parentId != 0
|
||||
&& parentId != node.Object.GobjectId
|
||||
&& nodes.TryGetValue(parentId, out DashboardBrowseNode? parent))
|
||||
{
|
||||
parent.Children.Add(node);
|
||||
}
|
||||
else
|
||||
{
|
||||
roots.Add(node);
|
||||
}
|
||||
}
|
||||
|
||||
SortRecursive(roots);
|
||||
return roots;
|
||||
}
|
||||
|
||||
private static void SortRecursive(List<DashboardBrowseNode> nodes)
|
||||
{
|
||||
nodes.Sort(CompareNodes);
|
||||
foreach (DashboardBrowseNode node in nodes)
|
||||
{
|
||||
SortRecursive(node.Children);
|
||||
}
|
||||
}
|
||||
|
||||
// Areas sort before instance objects; within a group, by display name.
|
||||
private static int CompareNodes(DashboardBrowseNode left, DashboardBrowseNode right)
|
||||
{
|
||||
if (left.IsArea != right.IsArea)
|
||||
{
|
||||
return left.IsArea ? -1 : 1;
|
||||
}
|
||||
|
||||
return string.Compare(left.DisplayName, right.DisplayName, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public static class DashboardConnectionStringDisplay
|
||||
{
|
||||
public static string GalaxyRepositoryConnectionString(string connectionString)
|
||||
{
|
||||
try
|
||||
{
|
||||
SqlConnectionStringBuilder builder = new(connectionString);
|
||||
SqlConnectionStringBuilder display = new()
|
||||
{
|
||||
DataSource = builder.DataSource,
|
||||
InitialCatalog = builder.InitialCatalog,
|
||||
IntegratedSecurity = builder.IntegratedSecurity,
|
||||
Encrypt = builder.Encrypt,
|
||||
TrustServerCertificate = builder.TrustServerCertificate,
|
||||
};
|
||||
|
||||
return display.ConnectionString;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return "[invalid connection string]";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
using System.Text.Encodings.Web;
|
||||
using Microsoft.AspNetCore.Antiforgery;
|
||||
using Microsoft.AspNetCore.Authentication;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>Endpoint extensions for registering the gateway dashboard routes.</summary>
|
||||
public static class DashboardEndpointRouteBuilderExtensions
|
||||
{
|
||||
/// <summary>Maps all gateway dashboard routes including login, logout, and Razor components.</summary>
|
||||
/// <param name="endpoints">The endpoint route builder.</param>
|
||||
/// <returns>The route builder for chaining.</returns>
|
||||
public static IEndpointRouteBuilder MapGatewayDashboard(this IEndpointRouteBuilder endpoints)
|
||||
{
|
||||
IConfiguration configuration = endpoints.ServiceProvider.GetRequiredService<IConfiguration>();
|
||||
IConfigurationSection dashboardSection = configuration
|
||||
.GetSection($"{GatewayOptions.SectionName}:Dashboard");
|
||||
|
||||
if (bool.TryParse(dashboardSection["Enabled"], out bool enabled) && !enabled)
|
||||
{
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
string pathBase = NormalizePathBase(dashboardSection["PathBase"] ?? new DashboardOptions().PathBase);
|
||||
RouteGroupBuilder dashboard = endpoints.MapGroup(pathBase);
|
||||
|
||||
dashboard.MapGet(
|
||||
"/login",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => GetLoginAsync(httpContext, antiforgery, pathBase))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLogin");
|
||||
|
||||
dashboard.MapPost(
|
||||
"/login",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery, IDashboardAuthenticator authenticator) =>
|
||||
PostLoginAsync(httpContext, antiforgery, authenticator, pathBase))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLoginPost");
|
||||
|
||||
dashboard.MapPost(
|
||||
"/logout",
|
||||
(HttpContext httpContext, IAntiforgery antiforgery) => PostLogoutAsync(httpContext, antiforgery, pathBase))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardLogout");
|
||||
|
||||
dashboard.MapGet("/denied", () => Results.Content(
|
||||
RenderPage("Access denied", "<p>The signed-in user is not authorized for dashboard access.</p>"),
|
||||
"text/html"))
|
||||
.AllowAnonymous()
|
||||
.WithName("DashboardAccessDenied");
|
||||
|
||||
// Every dashboard Razor component requires an authorized session. The
|
||||
// login/logout/denied endpoints above opt out via AllowAnonymous(); an
|
||||
// unauthenticated request to a component route is challenged by the
|
||||
// cookie scheme and redirected to the login page.
|
||||
dashboard.MapRazorComponents<App>()
|
||||
.AddInteractiveServerRenderMode()
|
||||
.RequireAuthorization(DashboardAuthenticationDefaults.AuthorizationPolicy);
|
||||
|
||||
return endpoints;
|
||||
}
|
||||
|
||||
private static Task<ContentHttpResult> GetLoginAsync(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
string pathBase)
|
||||
{
|
||||
string returnUrl = SanitizeReturnUrl(
|
||||
httpContext.Request.Query["returnUrl"].ToString(),
|
||||
pathBase);
|
||||
|
||||
return Task.FromResult(TypedResults.Content(
|
||||
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, failureMessage: null),
|
||||
"text/html"));
|
||||
}
|
||||
|
||||
private static async Task<IResult> PostLoginAsync(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
IDashboardAuthenticator authenticator,
|
||||
string pathBase)
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||
|
||||
IFormCollection form = await httpContext.Request
|
||||
.ReadFormAsync(httpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
string returnUrl = SanitizeReturnUrl(
|
||||
form["returnUrl"].ToString(),
|
||||
pathBase);
|
||||
|
||||
DashboardAuthenticationResult result = await authenticator
|
||||
.AuthenticateAsync(
|
||||
form["username"].ToString(),
|
||||
form["password"].ToString(),
|
||||
httpContext.RequestAborted)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
if (!result.Succeeded || result.Principal is null)
|
||||
{
|
||||
return TypedResults.Content(
|
||||
RenderLoginPage(httpContext, antiforgery, returnUrl, pathBase, result.FailureMessage),
|
||||
"text/html",
|
||||
statusCode: StatusCodes.Status401Unauthorized);
|
||||
}
|
||||
|
||||
await httpContext
|
||||
.SignInAsync(DashboardAuthenticationDefaults.AuthenticationScheme, result.Principal)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.LocalRedirect(returnUrl);
|
||||
}
|
||||
|
||||
private static async Task<IResult> PostLogoutAsync(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
string pathBase)
|
||||
{
|
||||
await antiforgery.ValidateRequestAsync(httpContext).ConfigureAwait(false);
|
||||
await httpContext
|
||||
.SignOutAsync(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return Results.LocalRedirect($"{pathBase}/login");
|
||||
}
|
||||
|
||||
private static string RenderLoginPage(
|
||||
HttpContext httpContext,
|
||||
IAntiforgery antiforgery,
|
||||
string returnUrl,
|
||||
string pathBase,
|
||||
string? failureMessage)
|
||||
{
|
||||
AntiforgeryTokenSet tokens = antiforgery.GetAndStoreTokens(httpContext);
|
||||
string requestToken = tokens.RequestToken ?? string.Empty;
|
||||
string alert = string.IsNullOrWhiteSpace(failureMessage)
|
||||
? string.Empty
|
||||
: $"<p class=\"alert alert-danger\" role=\"alert\">{HtmlEncoder.Default.Encode(failureMessage)}</p>";
|
||||
|
||||
string body = $"""
|
||||
<section class="dashboard-login">
|
||||
{alert}
|
||||
<form method="post" action="{HtmlEncoder.Default.Encode(pathBase + "/login")}" class="card login-card">
|
||||
<div class="card-body">
|
||||
<input name="{tokens.FormFieldName}" type="hidden" value="{HtmlEncoder.Default.Encode(requestToken)}" />
|
||||
<input name="returnUrl" type="hidden" value="{HtmlEncoder.Default.Encode(returnUrl)}" />
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" name="username" type="text" autocomplete="username" class="form-control" />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">Password</label>
|
||||
<input id="password" name="password" type="password" autocomplete="current-password" class="form-control" />
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Sign in</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
""";
|
||||
|
||||
return RenderPage("Dashboard Sign In", body);
|
||||
}
|
||||
|
||||
private static string RenderPage(string title, string body)
|
||||
{
|
||||
return $"""
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{HtmlEncoder.Default.Encode(title)}</title>
|
||||
<link rel="stylesheet" href="/lib/bootstrap/css/bootstrap.min.css" />
|
||||
<link rel="stylesheet" href="/css/theme.css" />
|
||||
<link rel="stylesheet" href="/css/dashboard.css" />
|
||||
</head>
|
||||
<body class="dashboard-body">
|
||||
<header class="app-bar">
|
||||
<span class="brand"><span class="mark">▮</span> MXAccess Gateway</span>
|
||||
</header>
|
||||
<main class="page">
|
||||
<div class="dashboard-page-header">
|
||||
<h1>{HtmlEncoder.Default.Encode(title)}</h1>
|
||||
</div>
|
||||
{body}
|
||||
</main>
|
||||
<script src="/lib/bootstrap/js/bootstrap.bundle.min.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
""";
|
||||
}
|
||||
|
||||
private static string NormalizePathBase(string pathBase)
|
||||
{
|
||||
string normalized = pathBase.TrimEnd('/');
|
||||
|
||||
return string.IsNullOrWhiteSpace(normalized) || !normalized.StartsWith("/", StringComparison.Ordinal)
|
||||
? "/dashboard"
|
||||
: normalized;
|
||||
}
|
||||
|
||||
private static string SanitizeReturnUrl(string? returnUrl, string pathBase)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(returnUrl)
|
||||
|| !returnUrl.StartsWith("/", StringComparison.Ordinal)
|
||||
|| returnUrl.StartsWith("//", StringComparison.Ordinal)
|
||||
|| !returnUrl.StartsWith(pathBase, StringComparison.OrdinalIgnoreCase)
|
||||
|| Uri.TryCreate(returnUrl, UriKind.Absolute, out _))
|
||||
{
|
||||
return pathBase;
|
||||
}
|
||||
|
||||
return returnUrl;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardFaultSummary(
|
||||
string Source,
|
||||
string? SessionId,
|
||||
int? WorkerProcessId,
|
||||
string State,
|
||||
string Message,
|
||||
DateTimeOffset ObservedAt);
|
||||
@@ -0,0 +1,12 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>Projects the precomputed Galaxy cache dashboard summary.</summary>
|
||||
internal static class DashboardGalaxyProjector
|
||||
{
|
||||
public static DashboardGalaxySummary Project(GalaxyHierarchyCacheEntry entry)
|
||||
{
|
||||
return entry.DashboardSummary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of the Galaxy Repository (ZB) browse state surfaced on the dashboard.
|
||||
/// Populated by <see cref="GalaxySummaryCache"/> on a background refresh cadence so
|
||||
/// the dashboard never blocks on SQL.
|
||||
/// </summary>
|
||||
public sealed record DashboardGalaxySummary(
|
||||
DashboardGalaxyStatus Status,
|
||||
DateTimeOffset? LastQueriedAt,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
DateTimeOffset? LastDeployTime,
|
||||
string? LastError,
|
||||
int ObjectCount,
|
||||
int AreaCount,
|
||||
int AttributeCount,
|
||||
int HistorizedAttributeCount,
|
||||
int AlarmAttributeCount,
|
||||
IReadOnlyList<DashboardGalaxyTemplateUsage> TopTemplates,
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> ObjectCategories)
|
||||
{
|
||||
/// <summary>Gets the unknown Galaxy status placeholder.</summary>
|
||||
public static DashboardGalaxySummary Unknown { get; } = new(
|
||||
DashboardGalaxyStatus.Unknown,
|
||||
LastQueriedAt: null,
|
||||
LastSuccessAt: null,
|
||||
LastDeployTime: null,
|
||||
LastError: null,
|
||||
ObjectCount: 0,
|
||||
AreaCount: 0,
|
||||
AttributeCount: 0,
|
||||
HistorizedAttributeCount: 0,
|
||||
AlarmAttributeCount: 0,
|
||||
TopTemplates: Array.Empty<DashboardGalaxyTemplateUsage>(),
|
||||
ObjectCategories: Array.Empty<DashboardGalaxyCategoryCount>());
|
||||
}
|
||||
|
||||
public enum DashboardGalaxyStatus
|
||||
{
|
||||
Unknown = 0,
|
||||
Healthy = 1,
|
||||
Stale = 2,
|
||||
Unavailable = 3,
|
||||
}
|
||||
|
||||
public sealed record DashboardGalaxyTemplateUsage(string TemplateName, int InstanceCount);
|
||||
|
||||
public sealed record DashboardGalaxyCategoryCount(int CategoryId, string CategoryName, int ObjectCount);
|
||||
@@ -0,0 +1,213 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Default <see cref="IDashboardLiveDataService"/>. Owns one shared gateway
|
||||
/// session for the whole dashboard: it is opened lazily on first use and
|
||||
/// re-opened transparently whenever it faults, is closed, or its lease
|
||||
/// expires. All access is serialised through <see cref="_gate"/> so the
|
||||
/// single backing worker only ever sees one in-flight command.
|
||||
/// </summary>
|
||||
public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable
|
||||
{
|
||||
private const string BackendName = "Galaxy";
|
||||
private const string ClientName = "mxgateway-dashboard";
|
||||
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
private readonly ISessionManager _sessionManager;
|
||||
private readonly IGatewayAlarmService _alarmService;
|
||||
private readonly ILogger<DashboardLiveDataService> _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
private readonly HashSet<string> _subscribed = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
private GatewaySession? _session;
|
||||
private int _serverHandle;
|
||||
private bool _disposed;
|
||||
|
||||
/// <summary>Initializes the live-data service.</summary>
|
||||
/// <param name="sessionManager">Gateway session manager.</param>
|
||||
/// <param name="alarmService">Gateway central alarm service.</param>
|
||||
/// <param name="logger">Diagnostic logger.</param>
|
||||
public DashboardLiveDataService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayAlarmService alarmService,
|
||||
ILogger<DashboardLiveDataService> logger)
|
||||
{
|
||||
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||
_alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<DashboardLiveReadResult> ReadAsync(
|
||||
IReadOnlyCollection<string> tagAddresses,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(tagAddresses);
|
||||
if (tagAddresses.Count == 0)
|
||||
{
|
||||
return DashboardLiveReadResult.Empty;
|
||||
}
|
||||
|
||||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
(GatewaySession session, int serverHandle) = await EnsureReadyAsync(cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
string[] toSubscribe = tagAddresses.Where(tag => !_subscribed.Contains(tag)).ToArray();
|
||||
if (toSubscribe.Length > 0)
|
||||
{
|
||||
await session.SubscribeBulkAsync(serverHandle, toSubscribe, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
foreach (string tag in toSubscribe)
|
||||
{
|
||||
_subscribed.Add(tag);
|
||||
}
|
||||
}
|
||||
|
||||
IReadOnlyList<BulkReadResult> results = await session
|
||||
.ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
DashboardTagValue[] values = results
|
||||
.Select(DashboardTagValue.FromBulkReadResult)
|
||||
.ToArray();
|
||||
return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId);
|
||||
}
|
||||
catch (Exception exception) when (exception is not OperationCanceledException)
|
||||
{
|
||||
InvalidateSession();
|
||||
_logger.LogWarning(exception, "Dashboard live read failed; the dashboard session will be re-opened.");
|
||||
return new DashboardLiveReadResult([], exception.Message, null, null);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_gate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// Alarms come from the gateway's always-on central monitor; the
|
||||
// dashboard reads its in-process cache directly — no session needed.
|
||||
DashboardActiveAlarm[] alarms = _alarmService.CurrentAlarms
|
||||
.Select(DashboardActiveAlarm.FromSnapshot)
|
||||
.ToArray();
|
||||
|
||||
string? error = _alarmService.State is GatewayAlarmMonitorState.Monitoring
|
||||
or GatewayAlarmMonitorState.Disabled
|
||||
? null
|
||||
: _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}.";
|
||||
|
||||
return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId));
|
||||
}
|
||||
|
||||
// Returns a Ready session + its Register server handle, opening a fresh
|
||||
// session when none exists or the current one is no longer usable. Callers
|
||||
// must hold _gate.
|
||||
private async Task<(GatewaySession Session, int ServerHandle)> EnsureReadyAsync(
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||||
|
||||
GatewaySession? existing = _session;
|
||||
if (existing is not null
|
||||
&& existing.State == SessionState.Ready
|
||||
&& _sessionManager.TryGetSession(existing.SessionId, out _))
|
||||
{
|
||||
return (existing, _serverHandle);
|
||||
}
|
||||
|
||||
if (existing is not null)
|
||||
{
|
||||
_logger.LogInformation(
|
||||
"Dashboard session {SessionId} is no longer usable (state {State}); re-opening.",
|
||||
existing.SessionId,
|
||||
existing.State);
|
||||
await CloseQuietlyAsync(existing.SessionId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_subscribed.Clear();
|
||||
_session = null;
|
||||
|
||||
GatewaySession session = await _sessionManager.OpenSessionAsync(
|
||||
new SessionOpenRequest(BackendName, ClientName, Guid.NewGuid().ToString("N"), CommandTimeout: null),
|
||||
ClientName,
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
WorkerCommandReply reply = await session.InvokeAsync(
|
||||
new WorkerCommand
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Register,
|
||||
Register = new RegisterCommand { ClientName = ClientName },
|
||||
},
|
||||
},
|
||||
cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
int? serverHandle = reply.Reply?.Register?.ServerHandle;
|
||||
if (serverHandle is null)
|
||||
{
|
||||
string diagnostic = reply.Reply?.ProtocolStatus?.Message
|
||||
?? reply.Reply?.DiagnosticMessage
|
||||
?? "Worker did not return a server handle for Register.";
|
||||
await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
|
||||
throw new InvalidOperationException($"Dashboard session registration failed: {diagnostic}");
|
||||
}
|
||||
|
||||
_session = session;
|
||||
_serverHandle = serverHandle.Value;
|
||||
_logger.LogInformation(
|
||||
"Dashboard session {SessionId} opened (worker pid {WorkerPid}).",
|
||||
session.SessionId,
|
||||
session.WorkerProcessId);
|
||||
return (session, _serverHandle);
|
||||
}
|
||||
|
||||
// Drops the cached session so the next call re-opens. Callers must hold _gate.
|
||||
private void InvalidateSession()
|
||||
{
|
||||
_session = null;
|
||||
_serverHandle = 0;
|
||||
_subscribed.Clear();
|
||||
}
|
||||
|
||||
private async Task CloseQuietlyAsync(string sessionId)
|
||||
{
|
||||
try
|
||||
{
|
||||
await _sessionManager.CloseSessionAsync(sessionId, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger.LogDebug(exception, "Closing stale dashboard session {SessionId} failed.", sessionId);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_disposed = true;
|
||||
GatewaySession? session = _session;
|
||||
_session = null;
|
||||
if (session is not null)
|
||||
{
|
||||
await CloseQuietlyAsync(session.SessionId).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
_gate.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardMetricSummary(
|
||||
string Name,
|
||||
long Value,
|
||||
string? Dimension = null);
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Globalization;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Formats an <see cref="MxValue"/> into the short, human-readable text the
|
||||
/// dashboard's Browse subscription panel shows. Kept separate from the
|
||||
/// view layer so the formatting rules are unit-testable without a worker.
|
||||
/// </summary>
|
||||
public static class DashboardMxValueFormatter
|
||||
{
|
||||
/// <summary>Maximum array elements rendered inline before the value is truncated.</summary>
|
||||
private const int MaxArrayElements = 24;
|
||||
|
||||
/// <summary>Formats the value payload of an <see cref="MxValue"/>.</summary>
|
||||
/// <param name="value">The value to format; may be null.</param>
|
||||
/// <returns>A display string — never null.</returns>
|
||||
public static string FormatValue(MxValue? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
if (value.IsNull)
|
||||
{
|
||||
return "(null)";
|
||||
}
|
||||
|
||||
return value.KindCase switch
|
||||
{
|
||||
MxValue.KindOneofCase.BoolValue => value.BoolValue ? "true" : "false",
|
||||
MxValue.KindOneofCase.Int32Value => value.Int32Value.ToString(CultureInfo.InvariantCulture),
|
||||
MxValue.KindOneofCase.Int64Value => value.Int64Value.ToString(CultureInfo.InvariantCulture),
|
||||
MxValue.KindOneofCase.FloatValue => value.FloatValue.ToString("G7", CultureInfo.InvariantCulture),
|
||||
MxValue.KindOneofCase.DoubleValue => value.DoubleValue.ToString("G15", CultureInfo.InvariantCulture),
|
||||
MxValue.KindOneofCase.StringValue => value.StringValue,
|
||||
MxValue.KindOneofCase.TimestampValue => value.TimestampValue
|
||||
.ToDateTimeOffset()
|
||||
.UtcDateTime
|
||||
.ToString("yyyy-MM-dd HH:mm:ss.fff 'UTC'", CultureInfo.InvariantCulture),
|
||||
MxValue.KindOneofCase.ArrayValue => FormatArray(value.ArrayValue),
|
||||
MxValue.KindOneofCase.RawValue => $"({value.RawValue.Length} bytes)",
|
||||
_ => "-",
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Formats the MXAccess data type of an <see cref="MxValue"/>.</summary>
|
||||
/// <param name="value">The value whose data type to describe; may be null.</param>
|
||||
/// <returns>The data-type name — never null. Arrays render as <c>Element[dims]</c>.</returns>
|
||||
public static string FormatDataType(MxValue? value)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return "-";
|
||||
}
|
||||
|
||||
// A scalar carries its type in MxValue.DataType, but an array leaves
|
||||
// that Unspecified and carries the element type on the MxArray itself.
|
||||
return value.KindCase == MxValue.KindOneofCase.ArrayValue
|
||||
? FormatArrayDataType(value.ArrayValue)
|
||||
: value.DataType.ToString();
|
||||
}
|
||||
|
||||
private static string FormatArrayDataType(MxArray array)
|
||||
{
|
||||
string dimensions = array.Dimensions.Count > 0
|
||||
? string.Join(",", array.Dimensions)
|
||||
: string.Empty;
|
||||
return $"{array.ElementDataType}[{dimensions}]";
|
||||
}
|
||||
|
||||
private static string FormatArray(MxArray array)
|
||||
{
|
||||
IReadOnlyList<string> elements = array.ValuesCase switch
|
||||
{
|
||||
MxArray.ValuesOneofCase.BoolValues =>
|
||||
array.BoolValues.Values.Select(item => item ? "true" : "false").ToArray(),
|
||||
MxArray.ValuesOneofCase.Int32Values =>
|
||||
array.Int32Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(),
|
||||
MxArray.ValuesOneofCase.Int64Values =>
|
||||
array.Int64Values.Values.Select(item => item.ToString(CultureInfo.InvariantCulture)).ToArray(),
|
||||
MxArray.ValuesOneofCase.FloatValues =>
|
||||
array.FloatValues.Values.Select(item => item.ToString("G7", CultureInfo.InvariantCulture)).ToArray(),
|
||||
MxArray.ValuesOneofCase.DoubleValues =>
|
||||
array.DoubleValues.Values.Select(item => item.ToString("G15", CultureInfo.InvariantCulture)).ToArray(),
|
||||
MxArray.ValuesOneofCase.StringValues =>
|
||||
array.StringValues.Values.Select(item => $"\"{item}\"").ToArray(),
|
||||
MxArray.ValuesOneofCase.TimestampValues =>
|
||||
array.TimestampValues.Values
|
||||
.Select(item => item.ToDateTimeOffset().UtcDateTime
|
||||
.ToString("yyyy-MM-dd HH:mm:ss 'UTC'", CultureInfo.InvariantCulture))
|
||||
.ToArray(),
|
||||
MxArray.ValuesOneofCase.RawValues =>
|
||||
array.RawValues.Values.Select(item => $"({item.Length} bytes)").ToArray(),
|
||||
_ => [],
|
||||
};
|
||||
|
||||
if (elements.Count == 0)
|
||||
{
|
||||
return "[]";
|
||||
}
|
||||
|
||||
string body = string.Join(", ", elements.Take(MaxArrayElements));
|
||||
return elements.Count > MaxArrayElements
|
||||
? $"[{body}, … {elements.Count} total]"
|
||||
: $"[{body}]";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
internal static class DashboardRedactor
|
||||
{
|
||||
private static readonly string[] SensitiveTextMarkers =
|
||||
[
|
||||
"apikey",
|
||||
"api_key",
|
||||
"authorization",
|
||||
"credential",
|
||||
"password",
|
||||
"secret",
|
||||
"token",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// Redacts sensitive content from a value for dashboard display.
|
||||
/// </summary>
|
||||
/// <param name="value">Value to redact.</param>
|
||||
/// <returns>Redacted value or original value if not sensitive.</returns>
|
||||
public static string? Redact(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
if (value.Contains("mxgw_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return GatewayLogRedactor.RedactClientIdentity(value);
|
||||
}
|
||||
|
||||
return SensitiveTextMarkers.Any(marker => value.Contains(marker, StringComparison.OrdinalIgnoreCase))
|
||||
? GatewayLogRedactor.RedactedValue
|
||||
: value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
using Microsoft.AspNetCore.Authentication.Cookies;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Extension methods for configuring the gateway dashboard services.
|
||||
/// </summary>
|
||||
public static class DashboardServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers all dashboard services, authentication, and Razor components.
|
||||
/// </summary>
|
||||
/// <param name="services">Service collection to register services.</param>
|
||||
public static IServiceCollection AddGatewayDashboard(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton<IDashboardSnapshotService, DashboardSnapshotService>();
|
||||
services.AddSingleton<IDashboardLiveDataService, DashboardLiveDataService>();
|
||||
services.AddSingleton<IDashboardAuthenticator, DashboardAuthenticator>();
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
services.AddHttpContextAccessor();
|
||||
services.AddAntiforgery();
|
||||
services.AddCascadingAuthenticationState();
|
||||
services.AddRazorComponents()
|
||||
.AddInteractiveServerComponents();
|
||||
services
|
||||
.AddAuthentication(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.AddCookie(DashboardAuthenticationDefaults.AuthenticationScheme);
|
||||
services.AddOptions<CookieAuthenticationOptions>(DashboardAuthenticationDefaults.AuthenticationScheme)
|
||||
.Configure<IOptions<GatewayOptions>>(ConfigureCookieOptions);
|
||||
services.AddAuthorization(options =>
|
||||
{
|
||||
options.AddPolicy(
|
||||
DashboardAuthenticationDefaults.AuthorizationPolicy,
|
||||
policy => policy.AddRequirements(new DashboardAuthorizationRequirement()));
|
||||
});
|
||||
services.AddSingleton<IAuthorizationHandler, DashboardAuthorizationHandler>();
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
private static void ConfigureCookieOptions(
|
||||
CookieAuthenticationOptions cookieOptions,
|
||||
IOptions<GatewayOptions> gatewayOptions)
|
||||
{
|
||||
string pathBase = gatewayOptions.Value.Dashboard.PathBase.TrimEnd('/');
|
||||
if (string.IsNullOrWhiteSpace(pathBase))
|
||||
{
|
||||
pathBase = "/dashboard";
|
||||
}
|
||||
|
||||
cookieOptions.Cookie.Name = DashboardAuthenticationDefaults.CookieName;
|
||||
cookieOptions.Cookie.HttpOnly = true;
|
||||
cookieOptions.Cookie.SecurePolicy = CookieSecurePolicy.Always;
|
||||
cookieOptions.Cookie.SameSite = SameSiteMode.Strict;
|
||||
cookieOptions.Cookie.Path = "/";
|
||||
cookieOptions.LoginPath = $"{pathBase}/login";
|
||||
cookieOptions.LogoutPath = $"{pathBase}/logout";
|
||||
cookieOptions.AccessDeniedPath = $"{pathBase}/denied";
|
||||
cookieOptions.ExpireTimeSpan = TimeSpan.FromHours(8);
|
||||
cookieOptions.SlidingExpiration = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardSessionSummary(
|
||||
string SessionId,
|
||||
string BackendName,
|
||||
SessionState State,
|
||||
string? ClientIdentity,
|
||||
string? ClientSessionName,
|
||||
string? ClientCorrelationId,
|
||||
DateTimeOffset OpenedAt,
|
||||
DateTimeOffset LastClientActivityAt,
|
||||
DateTimeOffset? LeaseExpiresAt,
|
||||
int? WorkerProcessId,
|
||||
WorkerClientState? WorkerState,
|
||||
DateTimeOffset? LastWorkerHeartbeatAt,
|
||||
long EventsReceived,
|
||||
string? LastFault);
|
||||
@@ -0,0 +1,17 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardSnapshot(
|
||||
DateTimeOffset GeneratedAt,
|
||||
DateTimeOffset GatewayStartedAt,
|
||||
TimeSpan GatewayUptime,
|
||||
string GatewayStatus,
|
||||
string GatewayVersion,
|
||||
IReadOnlyList<DashboardSessionSummary> Sessions,
|
||||
IReadOnlyList<DashboardWorkerSummary> Workers,
|
||||
IReadOnlyList<DashboardMetricSummary> Metrics,
|
||||
IReadOnlyList<DashboardFaultSummary> Faults,
|
||||
IReadOnlyList<DashboardApiKeySummary> ApiKeys,
|
||||
EffectiveGatewayConfiguration Configuration,
|
||||
DashboardGalaxySummary Galaxy);
|
||||
@@ -0,0 +1,282 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||||
using ZB.MOM.WW.MxGateway.Server.Security.Authentication;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed class DashboardSnapshotService : IDashboardSnapshotService
|
||||
{
|
||||
private const string HealthyStatus = "Healthy";
|
||||
|
||||
private readonly ISessionRegistry _sessionRegistry;
|
||||
private readonly GatewayMetrics _metrics;
|
||||
private readonly IGatewayConfigurationProvider _configurationProvider;
|
||||
private readonly IGalaxyHierarchyCache _galaxyHierarchyCache;
|
||||
private readonly IApiKeyAdminStore _apiKeyAdminStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly DateTimeOffset _gatewayStartedAt;
|
||||
private readonly TimeSpan _snapshotInterval;
|
||||
private readonly TimeSpan _apiKeySummaryRefreshTimeout = TimeSpan.FromSeconds(2);
|
||||
private readonly int _recentFaultLimit;
|
||||
private readonly int _recentSessionLimit;
|
||||
private readonly ILogger<DashboardSnapshotService> _logger;
|
||||
private readonly SemaphoreSlim _apiKeySummaryRefreshGate = new(1, 1);
|
||||
private IReadOnlyList<DashboardApiKeySummary> _apiKeySummaries = Array.Empty<DashboardApiKeySummary>();
|
||||
|
||||
/// <summary>Initializes a new instance of the DashboardSnapshotService class.</summary>
|
||||
/// <param name="sessionRegistry">Registry of active gateway sessions.</param>
|
||||
/// <param name="metrics">Gateway metrics collector.</param>
|
||||
/// <param name="configurationProvider">Gateway configuration provider.</param>
|
||||
/// <param name="galaxyHierarchyCache">Galaxy hierarchy cache.</param>
|
||||
/// <param name="options">Gateway configuration options.</param>
|
||||
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
||||
public DashboardSnapshotService(
|
||||
ISessionRegistry sessionRegistry,
|
||||
GatewayMetrics metrics,
|
||||
IGatewayConfigurationProvider configurationProvider,
|
||||
IGalaxyHierarchyCache galaxyHierarchyCache,
|
||||
IApiKeyAdminStore apiKeyAdminStore,
|
||||
IOptions<GatewayOptions> options,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<DashboardSnapshotService>? logger = null)
|
||||
{
|
||||
_sessionRegistry = sessionRegistry ?? throw new ArgumentNullException(nameof(sessionRegistry));
|
||||
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
||||
_configurationProvider = configurationProvider ?? throw new ArgumentNullException(nameof(configurationProvider));
|
||||
_galaxyHierarchyCache = galaxyHierarchyCache ?? throw new ArgumentNullException(nameof(galaxyHierarchyCache));
|
||||
_apiKeyAdminStore = apiKeyAdminStore ?? throw new ArgumentNullException(nameof(apiKeyAdminStore));
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_gatewayStartedAt = _timeProvider.GetUtcNow();
|
||||
_snapshotInterval = TimeSpan.FromMilliseconds(options.Value.Dashboard.SnapshotIntervalMilliseconds);
|
||||
_recentFaultLimit = options.Value.Dashboard.RecentFaultLimit;
|
||||
_recentSessionLimit = options.Value.Dashboard.RecentSessionLimit;
|
||||
_logger = logger ?? NullLogger<DashboardSnapshotService>.Instance;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets a current dashboard snapshot of gateway state.
|
||||
/// </summary>
|
||||
/// <returns>Dashboard snapshot.</returns>
|
||||
public DashboardSnapshot GetSnapshot()
|
||||
{
|
||||
DateTimeOffset generatedAt = _timeProvider.GetUtcNow();
|
||||
IReadOnlyList<GatewaySession> sessions = _sessionRegistry.Snapshot()
|
||||
.OrderByDescending(session => session.OpenedAt)
|
||||
.ToArray();
|
||||
GatewayMetricsSnapshot metricsSnapshot = _metrics.GetSnapshot();
|
||||
IReadOnlyList<DashboardSessionSummary> sessionSummaries = sessions
|
||||
.Take(ResolveLimit(_recentSessionLimit))
|
||||
.Select(session => CreateSessionSummary(session, metricsSnapshot))
|
||||
.ToArray();
|
||||
IReadOnlyList<DashboardWorkerSummary> workerSummaries = sessions
|
||||
.Where(session => session.WorkerClient is { State: not WorkerClientState.Closed })
|
||||
.Select(CreateWorkerSummary)
|
||||
.ToArray();
|
||||
|
||||
return new DashboardSnapshot(
|
||||
GeneratedAt: generatedAt,
|
||||
GatewayStartedAt: _gatewayStartedAt,
|
||||
GatewayUptime: generatedAt - _gatewayStartedAt,
|
||||
GatewayStatus: HealthyStatus,
|
||||
GatewayVersion: typeof(DashboardSnapshotService).Assembly.GetName().Version?.ToString() ?? "unknown",
|
||||
Sessions: sessionSummaries,
|
||||
Workers: workerSummaries,
|
||||
Metrics: CreateMetricSummaries(metricsSnapshot),
|
||||
Faults: CreateFaultSummaries(sessions, generatedAt),
|
||||
ApiKeys: Volatile.Read(ref _apiKeySummaries),
|
||||
Configuration: _configurationProvider.GetEffectiveConfiguration(),
|
||||
Galaxy: DashboardGalaxyProjector.Project(_galaxyHierarchyCache.Current));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 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(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
if (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||
yield return GetSnapshot();
|
||||
|
||||
using PeriodicTimer timer = new(_snapshotInterval, _timeProvider);
|
||||
while (true)
|
||||
{
|
||||
bool hasNext;
|
||||
try
|
||||
{
|
||||
hasNext = await timer.WaitForNextTickAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
if (!hasNext)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
await RefreshApiKeySummariesAsync(cancellationToken).ConfigureAwait(false);
|
||||
yield return GetSnapshot();
|
||||
}
|
||||
}
|
||||
|
||||
private static DashboardSessionSummary CreateSessionSummary(
|
||||
GatewaySession session,
|
||||
GatewayMetricsSnapshot metricsSnapshot)
|
||||
{
|
||||
IWorkerClient? workerClient = session.WorkerClient;
|
||||
metricsSnapshot.EventsBySession.TryGetValue(session.SessionId, out long eventsReceived);
|
||||
|
||||
return new DashboardSessionSummary(
|
||||
SessionId: session.SessionId,
|
||||
BackendName: session.BackendName,
|
||||
State: session.State,
|
||||
ClientIdentity: DashboardRedactor.Redact(session.ClientIdentity),
|
||||
ClientSessionName: DashboardRedactor.Redact(session.ClientSessionName),
|
||||
ClientCorrelationId: DashboardRedactor.Redact(session.ClientCorrelationId),
|
||||
OpenedAt: session.OpenedAt,
|
||||
LastClientActivityAt: session.LastClientActivityAt,
|
||||
LeaseExpiresAt: session.LeaseExpiresAt,
|
||||
WorkerProcessId: workerClient?.ProcessId,
|
||||
WorkerState: workerClient?.State,
|
||||
LastWorkerHeartbeatAt: workerClient?.LastHeartbeatAt,
|
||||
EventsReceived: eventsReceived,
|
||||
LastFault: DashboardRedactor.Redact(session.FinalFault));
|
||||
}
|
||||
|
||||
private static DashboardWorkerSummary CreateWorkerSummary(GatewaySession session)
|
||||
{
|
||||
IWorkerClient workerClient = session.WorkerClient!;
|
||||
|
||||
return new DashboardWorkerSummary(
|
||||
SessionId: session.SessionId,
|
||||
ProcessId: workerClient.ProcessId,
|
||||
State: workerClient.State,
|
||||
LastHeartbeatAt: workerClient.LastHeartbeatAt,
|
||||
LastFault: DashboardRedactor.Redact(session.FinalFault));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<DashboardMetricSummary> CreateMetricSummaries(GatewayMetricsSnapshot snapshot)
|
||||
{
|
||||
List<DashboardMetricSummary> metrics =
|
||||
[
|
||||
new("mxgateway.sessions.open", snapshot.OpenSessions),
|
||||
new("mxgateway.workers.running", snapshot.WorkersRunning),
|
||||
new("mxgateway.events.worker_queue.depth", snapshot.WorkerEventQueueDepth),
|
||||
new("mxgateway.events.grpc_stream_queue.depth", snapshot.GrpcEventStreamQueueDepth),
|
||||
new("mxgateway.sessions.opened", snapshot.SessionsOpened),
|
||||
new("mxgateway.sessions.closed", snapshot.SessionsClosed),
|
||||
new("mxgateway.commands.started", snapshot.CommandsStarted),
|
||||
new("mxgateway.commands.succeeded", snapshot.CommandsSucceeded),
|
||||
new("mxgateway.commands.failed", snapshot.CommandsFailed),
|
||||
new("mxgateway.events.received", snapshot.EventsReceived),
|
||||
new("mxgateway.queues.overflows", snapshot.QueueOverflows),
|
||||
new("mxgateway.faults", snapshot.Faults),
|
||||
new("mxgateway.workers.killed", snapshot.WorkerKills),
|
||||
new("mxgateway.workers.exited", snapshot.WorkerExits),
|
||||
new("mxgateway.heartbeats.failed", snapshot.HeartbeatFailures),
|
||||
new("mxgateway.grpc.streams.disconnected", snapshot.StreamDisconnects),
|
||||
];
|
||||
|
||||
metrics.AddRange(snapshot.CommandFailuresByMethod
|
||||
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => new DashboardMetricSummary("mxgateway.commands.failed", entry.Value, entry.Key)));
|
||||
metrics.AddRange(snapshot.EventsByFamily
|
||||
.OrderBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Select(entry => new DashboardMetricSummary("mxgateway.events.received", entry.Value, entry.Key)));
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
private IReadOnlyList<DashboardFaultSummary> CreateFaultSummaries(
|
||||
IReadOnlyList<GatewaySession> sessions,
|
||||
DateTimeOffset generatedAt)
|
||||
{
|
||||
return sessions
|
||||
.Where(HasFault)
|
||||
.Take(ResolveLimit(_recentFaultLimit))
|
||||
.Select(session => new DashboardFaultSummary(
|
||||
Source: session.WorkerClient?.State == WorkerClientState.Faulted ? "Worker" : "Session",
|
||||
SessionId: session.SessionId,
|
||||
WorkerProcessId: session.WorkerProcessId,
|
||||
State: session.WorkerClient?.State == WorkerClientState.Faulted
|
||||
? WorkerClientState.Faulted.ToString()
|
||||
: session.State.ToString(),
|
||||
Message: DashboardRedactor.Redact(session.FinalFault) ?? "Faulted",
|
||||
ObservedAt: generatedAt))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
private async Task RefreshApiKeySummariesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (!await _apiKeySummaryRefreshGate.WaitAsync(0, cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
using CancellationTokenSource timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeout.CancelAfter(_apiKeySummaryRefreshTimeout);
|
||||
IReadOnlyList<DashboardApiKeySummary> summaries = (await _apiKeyAdminStore.ListAsync(timeout.Token)
|
||||
.ConfigureAwait(false))
|
||||
.Select(key => new DashboardApiKeySummary(
|
||||
KeyId: key.KeyId,
|
||||
DisplayName: key.DisplayName,
|
||||
Scopes: key.Scopes,
|
||||
Constraints: key.Constraints,
|
||||
CreatedUtc: key.CreatedUtc,
|
||||
LastUsedUtc: key.LastUsedUtc,
|
||||
RevokedUtc: key.RevokedUtc))
|
||||
.ToArray();
|
||||
|
||||
Volatile.Write(ref _apiKeySummaries, summaries);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"Timed out refreshing dashboard API key summaries after {Timeout}.",
|
||||
_apiKeySummaryRefreshTimeout);
|
||||
}
|
||||
catch (Exception)
|
||||
{
|
||||
_logger.LogWarning("Failed to refresh dashboard API key summaries.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
_apiKeySummaryRefreshGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool HasFault(GatewaySession session)
|
||||
{
|
||||
return session.State == ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState.Faulted
|
||||
|| session.WorkerClient?.State == WorkerClientState.Faulted
|
||||
|| !string.IsNullOrWhiteSpace(session.FinalFault);
|
||||
}
|
||||
|
||||
private static int ResolveLimit(int configuredLimit)
|
||||
{
|
||||
return configuredLimit < 0 ? 0 : configuredLimit;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// One live tag value as shown in the Browse subscription panel. Projected
|
||||
/// from a worker <see cref="BulkReadResult"/> so the Razor component never
|
||||
/// touches protobuf types directly.
|
||||
/// </summary>
|
||||
public sealed record DashboardTagValue(
|
||||
string TagAddress,
|
||||
bool Ok,
|
||||
string ValueText,
|
||||
string DataType,
|
||||
int Quality,
|
||||
bool QualityGood,
|
||||
DateTimeOffset? SourceTimestamp,
|
||||
string? Error)
|
||||
{
|
||||
/// <summary>
|
||||
/// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy
|
||||
/// advised value; anything lower is uncertain or bad.
|
||||
/// </summary>
|
||||
private const int GoodQualityThreshold = 192;
|
||||
|
||||
/// <summary>Projects a worker bulk-read result into a dashboard tag value.</summary>
|
||||
/// <param name="result">The per-tag result from a <c>ReadBulk</c> reply.</param>
|
||||
/// <returns>The projected dashboard value.</returns>
|
||||
public static DashboardTagValue FromBulkReadResult(BulkReadResult result)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(result);
|
||||
|
||||
string? error = null;
|
||||
if (!result.WasSuccessful)
|
||||
{
|
||||
error = !string.IsNullOrWhiteSpace(result.ErrorMessage)
|
||||
? result.ErrorMessage
|
||||
: FirstStatusDiagnostic(result);
|
||||
}
|
||||
|
||||
return new DashboardTagValue(
|
||||
TagAddress: result.TagAddress,
|
||||
Ok: result.WasSuccessful,
|
||||
ValueText: DashboardMxValueFormatter.FormatValue(result.Value),
|
||||
DataType: DashboardMxValueFormatter.FormatDataType(result.Value),
|
||||
Quality: result.Quality,
|
||||
QualityGood: result.WasSuccessful && result.Quality >= GoodQualityThreshold,
|
||||
SourceTimestamp: result.SourceTimestamp?.ToDateTimeOffset(),
|
||||
Error: error);
|
||||
}
|
||||
|
||||
private static string? FirstStatusDiagnostic(BulkReadResult result)
|
||||
{
|
||||
foreach (MxStatusProxy status in result.Statuses)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(status.DiagnosticText))
|
||||
{
|
||||
return status.DiagnosticText;
|
||||
}
|
||||
|
||||
if (status.Category != MxStatusCategory.Ok)
|
||||
{
|
||||
return status.Category.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public sealed record DashboardWorkerSummary(
|
||||
string SessionId,
|
||||
int? ProcessId,
|
||||
WorkerClientState State,
|
||||
DateTimeOffset LastHeartbeatAt,
|
||||
string? LastFault);
|
||||
@@ -0,0 +1,23 @@
|
||||
using System.Security.Claims;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
public interface IDashboardApiKeyManagementService
|
||||
{
|
||||
bool CanManage(ClaimsPrincipal user);
|
||||
|
||||
Task<DashboardApiKeyManagementResult> CreateAsync(
|
||||
ClaimsPrincipal user,
|
||||
DashboardApiKeyManagementRequest request,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardApiKeyManagementResult> RevokeAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<DashboardApiKeyManagementResult> RotateAsync(
|
||||
ClaimsPrincipal user,
|
||||
string keyId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Authenticates dashboard access with API keys.
|
||||
/// </summary>
|
||||
public interface IDashboardAuthenticator
|
||||
{
|
||||
/// <summary>
|
||||
/// Authenticates the dashboard session with an API key.
|
||||
/// </summary>
|
||||
/// <param name="apiKey">The API key to authenticate.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
Task<DashboardAuthenticationResult> AuthenticateAsync(
|
||||
string? username,
|
||||
string? password,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Supplies the Browse and Alarms dashboard tabs with live MXAccess data.
|
||||
/// Owns one shared, lazily-opened gateway session (and therefore one
|
||||
/// worker process) used by every dashboard circuit; the session is
|
||||
/// transparently re-opened if it faults or its lease expires.
|
||||
/// </summary>
|
||||
public interface IDashboardLiveDataService
|
||||
{
|
||||
/// <summary>
|
||||
/// Subscribes (once) and reads the current value, quality and timestamp
|
||||
/// of the supplied tags. Never throws — transport and session failures
|
||||
/// are surfaced in <see cref="DashboardLiveReadResult.Error"/>.
|
||||
/// </summary>
|
||||
/// <param name="tagAddresses">Fully-qualified tag references to read.</param>
|
||||
/// <param name="cancellationToken">Token to cancel the read.</param>
|
||||
/// <returns>The read result, or an error-bearing result on failure.</returns>
|
||||
Task<DashboardLiveReadResult> ReadAsync(
|
||||
IReadOnlyCollection<string> tagAddresses,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Queries the currently-active alarm set for the dashboard session.
|
||||
/// Never throws — failures are surfaced in
|
||||
/// <see cref="DashboardAlarmQueryResult.Error"/>.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the query.</param>
|
||||
/// <returns>The active alarms, or an error-bearing result on failure.</returns>
|
||||
Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>Result of a dashboard live tag read.</summary>
|
||||
/// <param name="Values">The per-tag values, or an empty list on error.</param>
|
||||
/// <param name="Error">A diagnostic message when the read failed; otherwise null.</param>
|
||||
/// <param name="SessionId">The dashboard session id used, when available.</param>
|
||||
/// <param name="WorkerProcessId">The worker process id backing the session, when available.</param>
|
||||
public sealed record DashboardLiveReadResult(
|
||||
IReadOnlyList<DashboardTagValue> Values,
|
||||
string? Error,
|
||||
string? SessionId,
|
||||
int? WorkerProcessId)
|
||||
{
|
||||
/// <summary>An empty, successful result — used when no tags are subscribed.</summary>
|
||||
public static DashboardLiveReadResult Empty { get; } =
|
||||
new([], null, null, null);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Provides snapshots of the dashboard state for UI updates.
|
||||
/// </summary>
|
||||
public interface IDashboardSnapshotService
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the current dashboard snapshot.
|
||||
/// </summary>
|
||||
DashboardSnapshot GetSnapshot();
|
||||
|
||||
/// <summary>
|
||||
/// Watches for changes to the dashboard state as an async enumerable.
|
||||
/// </summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// Redacts sensitive information from log entries.
|
||||
/// </summary>
|
||||
public static class GatewayLogRedactor
|
||||
{
|
||||
/// <summary>Placeholder for redacted values.</summary>
|
||||
public const string RedactedValue = "[redacted]";
|
||||
|
||||
private static readonly HashSet<string> SensitiveCommandMethods = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"AuthenticateUser",
|
||||
"WriteSecured",
|
||||
"WriteSecured2"
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Determines whether a command method bears credentials.
|
||||
/// </summary>
|
||||
/// <param name="commandMethod">The command method name to check.</param>
|
||||
public static bool IsCredentialBearingCommand(string? commandMethod)
|
||||
{
|
||||
return commandMethod is not null
|
||||
&& SensitiveCommandMethods.Contains(commandMethod);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redacts the API key secret portion of a Bearer authorization header.
|
||||
/// </summary>
|
||||
/// <param name="authorizationHeader">The authorization header value to redact.</param>
|
||||
public static string? RedactApiKey(string? authorizationHeader)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(authorizationHeader))
|
||||
{
|
||||
return authorizationHeader;
|
||||
}
|
||||
|
||||
const string bearerPrefix = "Bearer ";
|
||||
if (!authorizationHeader.StartsWith(bearerPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return RedactedValue;
|
||||
}
|
||||
|
||||
string token = authorizationHeader[bearerPrefix.Length..].Trim();
|
||||
|
||||
if (!token.StartsWith("mxgw_", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return $"{bearerPrefix}{RedactedValue}";
|
||||
}
|
||||
|
||||
string[] tokenParts = token.Split('_', 3, StringSplitOptions.RemoveEmptyEntries);
|
||||
if (tokenParts.Length < 2)
|
||||
{
|
||||
return $"{bearerPrefix}mxgw_{RedactedValue}";
|
||||
}
|
||||
|
||||
return $"{bearerPrefix}mxgw_{tokenParts[1]}_{RedactedValue}";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redacts the client identity if it contains an API key.
|
||||
/// </summary>
|
||||
/// <param name="clientIdentity">The client identity string to redact.</param>
|
||||
public static string? RedactClientIdentity(string? clientIdentity)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(clientIdentity))
|
||||
{
|
||||
return clientIdentity;
|
||||
}
|
||||
|
||||
return clientIdentity.Contains("mxgw_", StringComparison.OrdinalIgnoreCase)
|
||||
? RedactApiKey(clientIdentity)
|
||||
: clientIdentity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redacts a command value if it contains credentials or value logging is disabled.
|
||||
/// </summary>
|
||||
/// <param name="commandMethod">The command method name to check for credentials.</param>
|
||||
/// <param name="value">The command value to redact.</param>
|
||||
/// <param name="valueLoggingEnabled">Whether value logging is enabled.</param>
|
||||
public static object? RedactCommandValue(
|
||||
string? commandMethod,
|
||||
object? value,
|
||||
bool valueLoggingEnabled = false)
|
||||
{
|
||||
if (value is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!valueLoggingEnabled || IsCredentialBearingCommand(commandMethod))
|
||||
{
|
||||
return RedactedValue;
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
public sealed record GatewayLogScope(
|
||||
string? SessionId = null,
|
||||
int? WorkerProcessId = null,
|
||||
ulong? CorrelationId = null,
|
||||
string? CommandMethod = null,
|
||||
string? ClientIdentity = null)
|
||||
{
|
||||
/// <summary>Converts the log scope to a dictionary with redacted sensitive fields.</summary>
|
||||
public IReadOnlyDictionary<string, object?> ToDictionary()
|
||||
{
|
||||
Dictionary<string, object?> values = [];
|
||||
|
||||
AddIfPresent(values, "SessionId", SessionId);
|
||||
AddIfPresent(values, "WorkerProcessId", WorkerProcessId);
|
||||
AddIfPresent(values, "CorrelationId", CorrelationId);
|
||||
AddIfPresent(values, "CommandMethod", CommandMethod);
|
||||
AddIfPresent(values, "ClientIdentity", GatewayLogRedactor.RedactClientIdentity(ClientIdentity));
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
private static void AddIfPresent(
|
||||
Dictionary<string, object?> values,
|
||||
string key,
|
||||
object? value)
|
||||
{
|
||||
if (value is not null)
|
||||
{
|
||||
values[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
using Microsoft.Extensions.Logging;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
public static class GatewayLoggerExtensions
|
||||
{
|
||||
/// <summary>Begins a gateway log scope with the specified scope properties.</summary>
|
||||
/// <param name="logger">Logger used for diagnostic output.</param>
|
||||
/// <param name="scope">Scope properties to apply.</param>
|
||||
/// <returns>A disposable that ends the scope when disposed.</returns>
|
||||
public static IDisposable? BeginGatewayScope(
|
||||
this ILogger logger,
|
||||
GatewayLogScope scope)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
ArgumentNullException.ThrowIfNull(scope);
|
||||
|
||||
return logger.BeginScope(scope.ToDictionary());
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
using Microsoft.Extensions.Primitives;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Diagnostics;
|
||||
|
||||
/// <summary>Middleware extensions for structured gateway request logging with correlation context.</summary>
|
||||
public static class GatewayRequestLoggingMiddlewareExtensions
|
||||
{
|
||||
/// <summary>Header name for the session ID.</summary>
|
||||
public const string SessionIdHeaderName = "x-session-id";
|
||||
|
||||
/// <summary>Header name for the worker process ID.</summary>
|
||||
public const string WorkerProcessIdHeaderName = "x-worker-process-id";
|
||||
|
||||
/// <summary>Header name for the correlation ID.</summary>
|
||||
public const string CorrelationIdHeaderName = "x-correlation-id";
|
||||
|
||||
/// <summary>Header name for the command method name.</summary>
|
||||
public const string CommandMethodHeaderName = "x-command-method";
|
||||
|
||||
/// <summary>Adds gateway request logging scope middleware that reads correlation headers and redacts sensitive data.</summary>
|
||||
/// <param name="app">Application builder.</param>
|
||||
public static IApplicationBuilder UseGatewayRequestLoggingScope(this IApplicationBuilder app)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(app);
|
||||
|
||||
return app.Use(async (context, next) =>
|
||||
{
|
||||
ILogger logger = context.RequestServices
|
||||
.GetRequiredService<ILoggerFactory>()
|
||||
.CreateLogger("MxGateway.Request");
|
||||
|
||||
using IDisposable? scope = logger.BeginGatewayScope(new GatewayLogScope(
|
||||
SessionId: ReadHeader(context, SessionIdHeaderName),
|
||||
WorkerProcessId: ReadInt32Header(context, WorkerProcessIdHeaderName),
|
||||
CorrelationId: ReadUInt64Header(context, CorrelationIdHeaderName),
|
||||
CommandMethod: ReadHeader(context, CommandMethodHeaderName),
|
||||
ClientIdentity: ReadHeader(context, "authorization")));
|
||||
|
||||
await next(context);
|
||||
});
|
||||
}
|
||||
|
||||
private static string? ReadHeader(HttpContext context, string headerName)
|
||||
{
|
||||
return context.Request.Headers.TryGetValue(headerName, out StringValues values)
|
||||
? values.ToString()
|
||||
: null;
|
||||
}
|
||||
|
||||
private static int? ReadInt32Header(HttpContext context, string headerName)
|
||||
{
|
||||
string? value = ReadHeader(context, headerName);
|
||||
|
||||
return int.TryParse(value, out int parsedValue)
|
||||
? parsedValue
|
||||
: null;
|
||||
}
|
||||
|
||||
private static ulong? ReadUInt64Header(HttpContext context, string headerName)
|
||||
{
|
||||
string? value = ReadHeader(context, headerName);
|
||||
|
||||
return ulong.TryParse(value, out ulong parsedValue)
|
||||
? parsedValue
|
||||
: null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public enum GalaxyCacheStatus
|
||||
{
|
||||
/// <summary>Cache has never completed a refresh.</summary>
|
||||
Unknown = 0,
|
||||
|
||||
/// <summary>Cache holds data from a recent successful refresh.</summary>
|
||||
Healthy = 1,
|
||||
|
||||
/// <summary>Cache holds data, but the most recent refresh attempt failed
|
||||
/// or no successful refresh has happened within the staleness threshold.</summary>
|
||||
Stale = 2,
|
||||
|
||||
/// <summary>Latest refresh failed and no prior data is available.</summary>
|
||||
Unavailable = 3,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// A single Galaxy deploy notification. Published by <see cref="GalaxyHierarchyCache"/>
|
||||
/// whenever a refresh detects that <c>galaxy.time_of_last_deploy</c> has changed (or on
|
||||
/// the first successful refresh). Consumed by <see cref="IGalaxyDeployNotifier"/>
|
||||
/// subscribers (the streaming gRPC RPC).
|
||||
/// </summary>
|
||||
public sealed record GalaxyDeployEventInfo(
|
||||
long Sequence,
|
||||
DateTimeOffset ObservedAt,
|
||||
DateTimeOffset? TimeOfLastDeploy,
|
||||
int ObjectCount,
|
||||
int AttributeCount);
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading.Channels;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Channel-based fan-out of Galaxy deploy events to streaming gRPC subscribers. Each
|
||||
/// subscriber gets a private bounded channel so a slow client cannot back-pressure
|
||||
/// other subscribers or the publisher. When a subscriber's channel is full the oldest
|
||||
/// event is dropped — clients use the sequence field to detect gaps.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// Publishes Galaxy deploy events to streaming gRPC subscribers via private bounded channels.
|
||||
/// </summary>
|
||||
public sealed class GalaxyDeployNotifier : IGalaxyDeployNotifier
|
||||
{
|
||||
private const int SubscriberQueueCapacity = 16;
|
||||
|
||||
private readonly ConcurrentDictionary<Guid, Channel<GalaxyDeployEventInfo>> _subscribers = new();
|
||||
private GalaxyDeployEventInfo? _latest;
|
||||
|
||||
/// <summary>
|
||||
/// The most recent deploy event, or null if none has been published.
|
||||
/// </summary>
|
||||
public GalaxyDeployEventInfo? Latest => Volatile.Read(ref _latest);
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Publish(GalaxyDeployEventInfo info)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(info);
|
||||
|
||||
Volatile.Write(ref _latest, info);
|
||||
|
||||
foreach (Channel<GalaxyDeployEventInfo> channel in _subscribers.Values)
|
||||
{
|
||||
// BoundedChannelFullMode.DropOldest -> writes never wait; we only fail if the
|
||||
// channel was completed by the subscriber side, which we ignore.
|
||||
channel.Writer.TryWrite(info);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<GalaxyDeployEventInfo> SubscribeAsync(
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
Guid subscriberId = Guid.NewGuid();
|
||||
Channel<GalaxyDeployEventInfo> channel = Channel.CreateBounded<GalaxyDeployEventInfo>(
|
||||
new BoundedChannelOptions(SubscriberQueueCapacity)
|
||||
{
|
||||
FullMode = BoundedChannelFullMode.DropOldest,
|
||||
SingleReader = true,
|
||||
SingleWriter = false,
|
||||
});
|
||||
|
||||
_subscribers[subscriberId] = channel;
|
||||
|
||||
// Bootstrap: emit the latest known event so subscribers don't need to wait for
|
||||
// the next deploy to know current state.
|
||||
GalaxyDeployEventInfo? bootstrap = Volatile.Read(ref _latest);
|
||||
if (bootstrap is not null)
|
||||
{
|
||||
channel.Writer.TryWrite(bootstrap);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
while (await channel.Reader.WaitToReadAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
while (channel.Reader.TryRead(out GalaxyDeployEventInfo? next))
|
||||
{
|
||||
yield return next;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
_subscribers.TryRemove(subscriberId, out _);
|
||||
channel.Writer.TryComplete();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public static class GalaxyGlobMatcher
|
||||
{
|
||||
/// <summary>
|
||||
/// Maximum number of compiled-regex entries retained in <see cref="RegexCache"/>.
|
||||
/// The cache is keyed by glob pattern and patterns flow in from two sources:
|
||||
/// admin-controlled API-key constraints (naturally bounded) and the
|
||||
/// client-supplied <c>DiscoverHierarchyRequest.TagNameGlob</c> (unbounded — a
|
||||
/// client can iterate through generated names and create millions of distinct
|
||||
/// globs over the process lifetime). Capping the cache bounds memory while
|
||||
/// keeping the hot working set hit-cached.
|
||||
/// </summary>
|
||||
internal const int RegexCacheCapacity = 256;
|
||||
|
||||
/// <summary>
|
||||
/// Bounded compiled-regex cache keyed by glob pattern. <c>IsMatch</c> is called
|
||||
/// once per object per <c>DiscoverHierarchy</c>/<c>WatchDeployEvents</c>
|
||||
/// evaluation, so the same handful of glob patterns are translated
|
||||
/// repeatedly; caching avoids rebuilding and recompiling the regex on every
|
||||
/// call. Beyond <see cref="RegexCacheCapacity"/> entries the oldest insertion
|
||||
/// is evicted so a client cannot grow the cache without bound by submitting
|
||||
/// unique patterns. Eviction is approximate (FIFO over insertion order, not
|
||||
/// true LRU) because we only need the bound, not exact recency tracking.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Insertion-order queue used to evict the oldest cache entry when the cache
|
||||
/// exceeds <see cref="RegexCacheCapacity"/>. A separate queue keeps the
|
||||
/// <see cref="RegexCache"/> reads lock-free; the lock below only guards the
|
||||
/// eviction path.
|
||||
/// </summary>
|
||||
private static readonly ConcurrentQueue<string> InsertionOrder = new();
|
||||
private static readonly object EvictionLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Current cache size, exposed for tests asserting the cap is honoured.
|
||||
/// </summary>
|
||||
internal static int CurrentCacheSize => RegexCache.Count;
|
||||
|
||||
public static bool IsMatch(string value, string glob)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(glob))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return GetOrCreateRegex(glob).IsMatch(value ?? string.Empty);
|
||||
}
|
||||
|
||||
private static Regex GetOrCreateRegex(string glob)
|
||||
{
|
||||
if (RegexCache.TryGetValue(glob, out Regex? existing))
|
||||
{
|
||||
return existing;
|
||||
}
|
||||
|
||||
Regex compiled = new(
|
||||
BuildRegex(glob),
|
||||
RegexOptions.CultureInvariant | RegexOptions.IgnoreCase | RegexOptions.Compiled,
|
||||
TimeSpan.FromMilliseconds(100));
|
||||
|
||||
// GetOrAdd atomically returns whichever instance is in the cache after the
|
||||
// call — either the locally-compiled regex (we won the race) or the regex
|
||||
// another thread inserted (we lost). It also avoids the TryAdd-then-indexer
|
||||
// pattern where the key could be evicted between the failed TryAdd and the
|
||||
// indexer read, producing a KeyNotFoundException under contention near the
|
||||
// cap (Server-024).
|
||||
Regex result = RegexCache.GetOrAdd(glob, compiled);
|
||||
if (ReferenceEquals(result, compiled))
|
||||
{
|
||||
// We were the inserter — track for FIFO eviction and bound the cache.
|
||||
InsertionOrder.Enqueue(glob);
|
||||
EvictIfOverCapacity();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static void EvictIfOverCapacity()
|
||||
{
|
||||
if (RegexCache.Count <= RegexCacheCapacity)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// Serialize eviction so two threads do not race past the cap together.
|
||||
lock (EvictionLock)
|
||||
{
|
||||
while (RegexCache.Count > RegexCacheCapacity && InsertionOrder.TryDequeue(out string? oldest))
|
||||
{
|
||||
RegexCache.TryRemove(oldest, out _);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildRegex(string glob)
|
||||
{
|
||||
StringBuilder builder = new("^", glob.Length + 2);
|
||||
foreach (char character in glob)
|
||||
{
|
||||
switch (character)
|
||||
{
|
||||
case '*':
|
||||
builder.Append(".*");
|
||||
break;
|
||||
case '?':
|
||||
builder.Append('.');
|
||||
break;
|
||||
default:
|
||||
builder.Append(Regex.Escape(character.ToString()));
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
builder.Append('$');
|
||||
return builder.ToString();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,489 @@
|
||||
using Google.Protobuf.WellKnownTypes;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
using ZB.MOM.WW.MxGateway.Server.Grpc;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Server-side cache of Galaxy Repository browse data. All gRPC clients share the same
|
||||
/// entry — the materialized <see cref="DiscoverHierarchyReply"/> is produced once per
|
||||
/// refresh and reused across requests. Refreshes are deploy-time gated: every tick
|
||||
/// queries <c>galaxy.time_of_last_deploy</c> (cheap), and the heavy hierarchy +
|
||||
/// attributes rowsets are pulled only when that timestamp has advanced.
|
||||
/// Each successful heavy refresh is persisted to disk through
|
||||
/// <see cref="IGalaxyHierarchySnapshotStore"/>; the first refresh restores that
|
||||
/// snapshot (as <see cref="GalaxyCacheStatus.Stale"/>) so clients can browse
|
||||
/// last-known data when the Galaxy database is unreachable on a cold start.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyCache : IGalaxyHierarchyCache
|
||||
{
|
||||
private static readonly TimeSpan StaleThreshold = TimeSpan.FromMinutes(5);
|
||||
|
||||
private readonly IGalaxyRepository _repository;
|
||||
private readonly IGalaxyDeployNotifier _notifier;
|
||||
private readonly IGalaxyHierarchySnapshotStore? _snapshotStore;
|
||||
private readonly TimeProvider _timeProvider;
|
||||
private readonly ILogger<GalaxyHierarchyCache>? _logger;
|
||||
private readonly TaskCompletionSource _firstLoad = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
private readonly SemaphoreSlim _refreshGate = new(1, 1);
|
||||
private GalaxyHierarchyCacheEntry _current = GalaxyHierarchyCacheEntry.Empty;
|
||||
private bool _restoreAttempted;
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchyCache"/> class.</summary>
|
||||
/// <param name="repository">Galaxy Repository client for SQL queries.</param>
|
||||
/// <param name="notifier">Galaxy deploy event notifier.</param>
|
||||
/// <param name="timeProvider">Provider for current time; defaults to system time.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
/// <param name="snapshotStore">
|
||||
/// Optional on-disk snapshot store. When supplied, the cache persists each
|
||||
/// successful refresh and restores the last snapshot on first load.
|
||||
/// </param>
|
||||
public GalaxyHierarchyCache(
|
||||
IGalaxyRepository repository,
|
||||
IGalaxyDeployNotifier notifier,
|
||||
TimeProvider? timeProvider = null,
|
||||
ILogger<GalaxyHierarchyCache>? logger = null,
|
||||
IGalaxyHierarchySnapshotStore? snapshotStore = null)
|
||||
{
|
||||
_repository = repository;
|
||||
_notifier = notifier;
|
||||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||||
_logger = logger;
|
||||
_snapshotStore = snapshotStore;
|
||||
}
|
||||
|
||||
/// <summary>Gets the current Galaxy hierarchy cache entry with projected status.</summary>
|
||||
public GalaxyHierarchyCacheEntry Current
|
||||
{
|
||||
get
|
||||
{
|
||||
GalaxyHierarchyCacheEntry snapshot = Volatile.Read(ref _current);
|
||||
GalaxyCacheStatus projected = ProjectStatus(snapshot);
|
||||
return projected == snapshot.Status
|
||||
? snapshot
|
||||
: snapshot with
|
||||
{
|
||||
Status = projected,
|
||||
DashboardSummary = snapshot.DashboardSummary with
|
||||
{
|
||||
Status = MapDashboardStatus(projected),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Refreshes the Galaxy hierarchy cache if the deploy time has advanced.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>Asynchronous task representing the refresh operation.</returns>
|
||||
public async Task RefreshAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await _refreshGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
await RefreshCoreAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_refreshGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Waits for the Galaxy hierarchy cache to complete its first load.</summary>
|
||||
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
|
||||
/// <returns>Asynchronous task representing the wait operation.</returns>
|
||||
public Task WaitForFirstLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
return _firstLoad.Task.WaitAsync(cancellationToken);
|
||||
}
|
||||
|
||||
private async Task RefreshCoreAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
// First refresh only: seed the cache from the on-disk snapshot before
|
||||
// querying SQL, so a cold start with an unreachable Galaxy database can
|
||||
// still serve last-known browse data. Runs under the refresh gate.
|
||||
if (!_restoreAttempted)
|
||||
{
|
||||
_restoreAttempted = true;
|
||||
await TryRestoreFromDiskAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
GalaxyHierarchyCacheEntry previous = Volatile.Read(ref _current);
|
||||
DateTimeOffset queriedAt = _timeProvider.GetUtcNow();
|
||||
|
||||
try
|
||||
{
|
||||
DateTime? deployRaw = await _repository.GetLastDeployTimeAsync(cancellationToken).ConfigureAwait(false);
|
||||
DateTimeOffset? deployTime = deployRaw.HasValue
|
||||
? new DateTimeOffset(DateTime.SpecifyKind(deployRaw.Value, DateTimeKind.Utc))
|
||||
: null;
|
||||
|
||||
bool hasPriorData = previous.HasData;
|
||||
bool deployChanged = !hasPriorData || deployTime != previous.LastDeployTime;
|
||||
|
||||
if (!deployChanged)
|
||||
{
|
||||
// No deploy change — skip heavy queries; just bump LastSuccessAt.
|
||||
GalaxyHierarchyCacheEntry refreshed = previous with
|
||||
{
|
||||
Status = GalaxyCacheStatus.Healthy,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastSuccessAt = queriedAt,
|
||||
LastError = null,
|
||||
DashboardSummary = previous.DashboardSummary with
|
||||
{
|
||||
Status = DashboardGalaxyStatus.Healthy,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastSuccessAt = queriedAt,
|
||||
LastDeployTime = deployTime,
|
||||
LastError = null,
|
||||
},
|
||||
};
|
||||
Volatile.Write(ref _current, refreshed);
|
||||
_firstLoad.TrySetResult();
|
||||
return;
|
||||
}
|
||||
|
||||
Task<List<GalaxyHierarchyRow>> hierarchyTask = _repository.GetHierarchyAsync(cancellationToken);
|
||||
Task<List<GalaxyAttributeRow>> attributesTask = _repository.GetAttributesAsync(cancellationToken);
|
||||
await Task.WhenAll(hierarchyTask, attributesTask).ConfigureAwait(false);
|
||||
|
||||
List<GalaxyHierarchyRow> hierarchy = hierarchyTask.Result;
|
||||
List<GalaxyAttributeRow> attributes = attributesTask.Result;
|
||||
|
||||
long nextSequence = previous.Sequence + 1;
|
||||
GalaxyHierarchyCacheEntry next = BuildEntry(
|
||||
status: GalaxyCacheStatus.Healthy,
|
||||
sequence: nextSequence,
|
||||
lastQueriedAt: queriedAt,
|
||||
lastSuccessAt: queriedAt,
|
||||
lastDeployTime: deployTime,
|
||||
lastError: null,
|
||||
hierarchy: hierarchy,
|
||||
attributes: attributes);
|
||||
|
||||
Volatile.Write(ref _current, next);
|
||||
_firstLoad.TrySetResult();
|
||||
|
||||
_notifier.Publish(new GalaxyDeployEventInfo(
|
||||
Sequence: nextSequence,
|
||||
ObservedAt: queriedAt,
|
||||
TimeOfLastDeploy: deployTime,
|
||||
ObjectCount: hierarchy.Count,
|
||||
AttributeCount: attributes.Count));
|
||||
|
||||
await PersistSnapshotAsync(deployTime, queriedAt, hierarchy, attributes, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// Catch every non-cancellation failure — not just SqlException /
|
||||
// InvalidOperationException. A TimeoutException or Win32Exception
|
||||
// from connection establishment, or another DbException subtype,
|
||||
// must still degrade gracefully to Stale/Unavailable and complete
|
||||
// _firstLoad rather than escape and fault the refresh BackgroundService.
|
||||
_logger?.LogWarning(exception, "Galaxy hierarchy cache refresh failed.");
|
||||
GalaxyHierarchyCacheEntry failed = previous with
|
||||
{
|
||||
Status = previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable,
|
||||
LastQueriedAt = queriedAt,
|
||||
LastError = exception.Message,
|
||||
DashboardSummary = previous.DashboardSummary with
|
||||
{
|
||||
Status = MapDashboardStatus(previous.HasData ? GalaxyCacheStatus.Stale : GalaxyCacheStatus.Unavailable),
|
||||
LastQueriedAt = queriedAt,
|
||||
LastError = exception.Message,
|
||||
},
|
||||
};
|
||||
Volatile.Write(ref _current, failed);
|
||||
_firstLoad.TrySetResult();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Materializes a complete <see cref="GalaxyHierarchyCacheEntry"/> from raw
|
||||
/// hierarchy and attribute rowsets. Shared by the live refresh path and the
|
||||
/// on-disk restore path so both produce an identical object list, index, and
|
||||
/// dashboard summary.
|
||||
/// </summary>
|
||||
private static GalaxyHierarchyCacheEntry BuildEntry(
|
||||
GalaxyCacheStatus status,
|
||||
long sequence,
|
||||
DateTimeOffset? lastQueriedAt,
|
||||
DateTimeOffset? lastSuccessAt,
|
||||
DateTimeOffset? lastDeployTime,
|
||||
string? lastError,
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
IReadOnlyList<GalaxyObject> objects = BuildObjects(hierarchy, attributes);
|
||||
GalaxyHierarchyIndex index = GalaxyHierarchyIndex.Build(objects);
|
||||
|
||||
int areaCount = hierarchy.Count(row => row.IsArea);
|
||||
int historized = attributes.Count(row => row.IsHistorized);
|
||||
int alarms = attributes.Count(row => row.IsAlarm);
|
||||
DashboardGalaxySummary dashboardSummary = BuildDashboardSummary(
|
||||
status: status,
|
||||
lastQueriedAt: lastQueriedAt,
|
||||
lastSuccessAt: lastSuccessAt,
|
||||
lastDeployTime: lastDeployTime,
|
||||
lastError: lastError,
|
||||
hierarchy: hierarchy,
|
||||
objectCount: hierarchy.Count,
|
||||
areaCount: areaCount,
|
||||
attributeCount: attributes.Count,
|
||||
historizedAttributeCount: historized,
|
||||
alarmAttributeCount: alarms);
|
||||
|
||||
return new GalaxyHierarchyCacheEntry(
|
||||
Status: status,
|
||||
Sequence: sequence,
|
||||
LastQueriedAt: lastQueriedAt,
|
||||
LastSuccessAt: lastSuccessAt,
|
||||
LastDeployTime: lastDeployTime,
|
||||
LastError: lastError,
|
||||
Objects: objects,
|
||||
Index: index,
|
||||
DashboardSummary: dashboardSummary,
|
||||
ObjectCount: hierarchy.Count,
|
||||
AreaCount: areaCount,
|
||||
AttributeCount: attributes.Count,
|
||||
HistorizedAttributeCount: historized,
|
||||
AlarmAttributeCount: alarms);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the cache from the on-disk snapshot when no live data has loaded yet.
|
||||
/// The restored entry is marked <see cref="GalaxyCacheStatus.Stale"/> — it is
|
||||
/// last-known data, not live. A later refresh that observes the same deploy
|
||||
/// time promotes it to healthy; one that observes a newer deploy replaces it.
|
||||
/// </summary>
|
||||
private async Task TryRestoreFromDiskAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_snapshotStore is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (Volatile.Read(ref _current).HasData)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
GalaxyHierarchySnapshot? snapshot;
|
||||
try
|
||||
{
|
||||
snapshot = await _snapshotStore.TryLoadAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger?.LogWarning(exception, "Failed to restore the Galaxy hierarchy from the on-disk snapshot.");
|
||||
return;
|
||||
}
|
||||
|
||||
if (snapshot is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
long sequence = Volatile.Read(ref _current).Sequence + 1;
|
||||
GalaxyHierarchyCacheEntry restored = BuildEntry(
|
||||
status: GalaxyCacheStatus.Stale,
|
||||
sequence: sequence,
|
||||
lastQueriedAt: snapshot.SavedAt,
|
||||
lastSuccessAt: snapshot.SavedAt,
|
||||
lastDeployTime: snapshot.LastDeployTime,
|
||||
lastError: null,
|
||||
hierarchy: snapshot.Hierarchy,
|
||||
attributes: snapshot.Attributes);
|
||||
Volatile.Write(ref _current, restored);
|
||||
|
||||
// Restored data is a valid completed first load: unblock callers waiting on
|
||||
// the bootstrap gate immediately, rather than making them wait out the full
|
||||
// wait budget for a live query that — when the database is unreachable, the
|
||||
// scenario this restore exists for — may not return for seconds.
|
||||
_firstLoad.TrySetResult();
|
||||
|
||||
_notifier.Publish(new GalaxyDeployEventInfo(
|
||||
Sequence: sequence,
|
||||
ObservedAt: _timeProvider.GetUtcNow(),
|
||||
TimeOfLastDeploy: snapshot.LastDeployTime,
|
||||
ObjectCount: snapshot.Hierarchy.Count,
|
||||
AttributeCount: snapshot.Attributes.Count));
|
||||
|
||||
_logger?.LogInformation(
|
||||
"Restored Galaxy hierarchy from on-disk snapshot saved {SavedAt:o}: {ObjectCount} objects, {AttributeCount} attributes (status Stale until the Galaxy database confirms).",
|
||||
snapshot.SavedAt,
|
||||
snapshot.Hierarchy.Count,
|
||||
snapshot.Attributes.Count);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Persists a successful refresh to disk. Persistence failures are logged and
|
||||
/// swallowed — a cache that cannot write its backup is still fully usable.
|
||||
/// </summary>
|
||||
private async Task PersistSnapshotAsync(
|
||||
DateTimeOffset? deployTime,
|
||||
DateTimeOffset savedAt,
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
if (_snapshotStore is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await _snapshotStore.SaveAsync(
|
||||
new GalaxyHierarchySnapshot(deployTime, savedAt, hierarchy, attributes),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
// The refresh was cancelled (gateway shutdown) before the write finished.
|
||||
// That is not a persistence failure — do not log it as a warning.
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
_logger?.LogWarning(exception, "Failed to persist the Galaxy hierarchy snapshot to disk.");
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObject> BuildObjects(
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> attributes)
|
||||
{
|
||||
Dictionary<int, List<GalaxyAttributeRow>> attributesByGobjectId = attributes
|
||||
.GroupBy(a => a.GobjectId)
|
||||
.ToDictionary(g => g.Key, g => g.ToList());
|
||||
|
||||
List<GalaxyObject> objects = new(hierarchy.Count);
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
objects.Add(GalaxyProtoMapper.MapObject(row, attributesByGobjectId));
|
||||
}
|
||||
return objects;
|
||||
}
|
||||
|
||||
private static DashboardGalaxySummary BuildDashboardSummary(
|
||||
GalaxyCacheStatus status,
|
||||
DateTimeOffset? lastQueriedAt,
|
||||
DateTimeOffset? lastSuccessAt,
|
||||
DateTimeOffset? lastDeployTime,
|
||||
string? lastError,
|
||||
IReadOnlyList<GalaxyHierarchyRow> hierarchy,
|
||||
int objectCount,
|
||||
int areaCount,
|
||||
int attributeCount,
|
||||
int historizedAttributeCount,
|
||||
int alarmAttributeCount)
|
||||
{
|
||||
IReadOnlyList<DashboardGalaxyTemplateUsage> topTemplates;
|
||||
IReadOnlyList<DashboardGalaxyCategoryCount> objectCategories;
|
||||
|
||||
if (hierarchy.Count == 0)
|
||||
{
|
||||
topTemplates = Array.Empty<DashboardGalaxyTemplateUsage>();
|
||||
objectCategories = Array.Empty<DashboardGalaxyCategoryCount>();
|
||||
}
|
||||
else
|
||||
{
|
||||
Dictionary<int, int> objectsByCategory = new();
|
||||
Dictionary<string, int> templateUsage = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyHierarchyRow row in hierarchy)
|
||||
{
|
||||
objectsByCategory.TryGetValue(row.CategoryId, out int categoryCount);
|
||||
objectsByCategory[row.CategoryId] = categoryCount + 1;
|
||||
|
||||
if (row.TemplateChain.Count > 0)
|
||||
{
|
||||
string immediate = row.TemplateChain[0];
|
||||
if (!string.IsNullOrWhiteSpace(immediate))
|
||||
{
|
||||
templateUsage.TryGetValue(immediate, out int templateCount);
|
||||
templateUsage[immediate] = templateCount + 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
topTemplates = templateUsage
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key, StringComparer.OrdinalIgnoreCase)
|
||||
.Take(10)
|
||||
.Select(entry => new DashboardGalaxyTemplateUsage(entry.Key, entry.Value))
|
||||
.ToArray();
|
||||
|
||||
objectCategories = objectsByCategory
|
||||
.OrderByDescending(entry => entry.Value)
|
||||
.ThenBy(entry => entry.Key)
|
||||
.Select(entry => new DashboardGalaxyCategoryCount(
|
||||
entry.Key,
|
||||
ResolveCategoryName(entry.Key),
|
||||
entry.Value))
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
return new DashboardGalaxySummary(
|
||||
Status: MapDashboardStatus(status),
|
||||
LastQueriedAt: lastQueriedAt,
|
||||
LastSuccessAt: lastSuccessAt,
|
||||
LastDeployTime: lastDeployTime,
|
||||
LastError: lastError,
|
||||
ObjectCount: objectCount,
|
||||
AreaCount: areaCount,
|
||||
AttributeCount: attributeCount,
|
||||
HistorizedAttributeCount: historizedAttributeCount,
|
||||
AlarmAttributeCount: alarmAttributeCount,
|
||||
TopTemplates: topTemplates,
|
||||
ObjectCategories: objectCategories);
|
||||
}
|
||||
|
||||
private static DashboardGalaxyStatus MapDashboardStatus(GalaxyCacheStatus status) => status switch
|
||||
{
|
||||
GalaxyCacheStatus.Healthy => DashboardGalaxyStatus.Healthy,
|
||||
GalaxyCacheStatus.Stale => DashboardGalaxyStatus.Stale,
|
||||
GalaxyCacheStatus.Unavailable => DashboardGalaxyStatus.Unavailable,
|
||||
_ => DashboardGalaxyStatus.Unknown,
|
||||
};
|
||||
|
||||
private static string ResolveCategoryName(int categoryId) => categoryId switch
|
||||
{
|
||||
1 => "WinPlatform",
|
||||
3 => "AppEngine",
|
||||
4 => "InTouchViewApp",
|
||||
10 => "UserDefined",
|
||||
11 => "FieldReference",
|
||||
13 => "Area",
|
||||
17 => "DIObject",
|
||||
24 => "DDESuiteLinkClient",
|
||||
26 => "OPCClient",
|
||||
_ => $"Category {categoryId}",
|
||||
};
|
||||
|
||||
private GalaxyCacheStatus ProjectStatus(GalaxyHierarchyCacheEntry snapshot)
|
||||
{
|
||||
if (snapshot.Status is GalaxyCacheStatus.Unknown or GalaxyCacheStatus.Unavailable)
|
||||
{
|
||||
return snapshot.Status;
|
||||
}
|
||||
|
||||
if (snapshot.LastSuccessAt is { } success
|
||||
&& _timeProvider.GetUtcNow() - success > StaleThreshold)
|
||||
{
|
||||
return GalaxyCacheStatus.Stale;
|
||||
}
|
||||
|
||||
return snapshot.Status;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Immutable snapshot of the Galaxy Repository browse data held by
|
||||
/// <see cref="GalaxyHierarchyCache"/>. Multiple gRPC clients share the same
|
||||
/// materialized object list and precomputed dashboard projection.
|
||||
/// </summary>
|
||||
public sealed record GalaxyHierarchyCacheEntry(
|
||||
GalaxyCacheStatus Status,
|
||||
long Sequence,
|
||||
DateTimeOffset? LastQueriedAt,
|
||||
DateTimeOffset? LastSuccessAt,
|
||||
DateTimeOffset? LastDeployTime,
|
||||
string? LastError,
|
||||
IReadOnlyList<GalaxyObject> Objects,
|
||||
GalaxyHierarchyIndex Index,
|
||||
DashboardGalaxySummary DashboardSummary,
|
||||
int ObjectCount,
|
||||
int AreaCount,
|
||||
int AttributeCount,
|
||||
int HistorizedAttributeCount,
|
||||
int AlarmAttributeCount)
|
||||
{
|
||||
/// <summary>Gets an empty Galaxy hierarchy cache entry.</summary>
|
||||
public static GalaxyHierarchyCacheEntry Empty { get; } = new(
|
||||
Status: GalaxyCacheStatus.Unknown,
|
||||
Sequence: 0,
|
||||
LastQueriedAt: null,
|
||||
LastSuccessAt: null,
|
||||
LastDeployTime: null,
|
||||
LastError: null,
|
||||
Objects: Array.Empty<GalaxyObject>(),
|
||||
Index: GalaxyHierarchyIndex.Empty,
|
||||
DashboardSummary: DashboardGalaxySummary.Unknown,
|
||||
ObjectCount: 0,
|
||||
AreaCount: 0,
|
||||
AttributeCount: 0,
|
||||
HistorizedAttributeCount: 0,
|
||||
AlarmAttributeCount: 0);
|
||||
|
||||
/// <summary>Gets a value indicating whether the cache entry contains usable data.</summary>
|
||||
public bool HasData => Status is GalaxyCacheStatus.Healthy or GalaxyCacheStatus.Stale;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public sealed class GalaxyHierarchyIndex
|
||||
{
|
||||
private GalaxyHierarchyIndex(
|
||||
IReadOnlyList<GalaxyObjectView> objectViews,
|
||||
IReadOnlyDictionary<int, GalaxyObjectView> objectViewsById,
|
||||
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress)
|
||||
{
|
||||
ObjectViews = objectViews;
|
||||
ObjectViewsById = objectViewsById;
|
||||
TagsByAddress = tagsByAddress;
|
||||
}
|
||||
|
||||
public static GalaxyHierarchyIndex Empty { get; } = new(
|
||||
Array.Empty<GalaxyObjectView>(),
|
||||
new Dictionary<int, GalaxyObjectView>(),
|
||||
new Dictionary<string, GalaxyTagLookup>(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
public IReadOnlyList<GalaxyObjectView> ObjectViews { get; }
|
||||
|
||||
public IReadOnlyDictionary<int, GalaxyObjectView> ObjectViewsById { get; }
|
||||
|
||||
public IReadOnlyDictionary<string, GalaxyTagLookup> TagsByAddress { get; }
|
||||
|
||||
public static GalaxyHierarchyIndex Build(IReadOnlyList<GalaxyObject> objects)
|
||||
{
|
||||
if (objects.Count == 0)
|
||||
{
|
||||
return Empty;
|
||||
}
|
||||
|
||||
Dictionary<int, GalaxyObject> objectsById = new();
|
||||
foreach (GalaxyObject obj in objects)
|
||||
{
|
||||
objectsById.TryAdd(obj.GobjectId, obj);
|
||||
}
|
||||
|
||||
List<GalaxyObjectView> views = new(objects.Count);
|
||||
Dictionary<int, GalaxyObjectView> viewsById = new();
|
||||
Dictionary<string, GalaxyTagLookup> tagsByAddress = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (GalaxyObject obj in objects)
|
||||
{
|
||||
string path = BuildContainedPath(obj, objectsById);
|
||||
int depth = string.IsNullOrWhiteSpace(path) ? 0 : path.Count(character => character == '/');
|
||||
GalaxyObjectView view = new(obj, path, depth);
|
||||
views.Add(view);
|
||||
viewsById.TryAdd(obj.GobjectId, view);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(obj.TagName))
|
||||
{
|
||||
tagsByAddress.TryAdd(obj.TagName, new GalaxyTagLookup(obj, Attribute: null, path));
|
||||
}
|
||||
|
||||
foreach (GalaxyAttribute attribute in obj.Attributes)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(attribute.FullTagReference))
|
||||
{
|
||||
tagsByAddress.TryAdd(attribute.FullTagReference, new GalaxyTagLookup(obj, attribute, path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new GalaxyHierarchyIndex(
|
||||
views,
|
||||
viewsById,
|
||||
tagsByAddress);
|
||||
}
|
||||
|
||||
private static string BuildContainedPath(
|
||||
GalaxyObject obj,
|
||||
IReadOnlyDictionary<int, GalaxyObject> objectsById)
|
||||
{
|
||||
Stack<string> names = new();
|
||||
HashSet<int> seen = [];
|
||||
GalaxyObject? current = obj;
|
||||
while (current is not null && seen.Add(current.GobjectId))
|
||||
{
|
||||
names.Push(ResolvePathSegment(current));
|
||||
current = current.ParentGobjectId != 0
|
||||
&& objectsById.TryGetValue(current.ParentGobjectId, out GalaxyObject? parent)
|
||||
? parent
|
||||
: null;
|
||||
}
|
||||
|
||||
return string.Join('/', names.Where(name => !string.IsNullOrWhiteSpace(name)));
|
||||
}
|
||||
|
||||
private static string ResolvePathSegment(GalaxyObject obj)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(obj.ContainedName))
|
||||
{
|
||||
return obj.ContainedName;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(obj.BrowseName))
|
||||
{
|
||||
return obj.BrowseName;
|
||||
}
|
||||
|
||||
return obj.TagName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using Grpc.Core;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public static class GalaxyHierarchyProjector
|
||||
{
|
||||
/// <summary>
|
||||
/// Per-cache-entry memo of filtered, ordered <see cref="GalaxyObjectView"/> lists
|
||||
/// keyed by filter signature. Without it, paging through a large hierarchy
|
||||
/// re-applies every filter and re-scans the full <see cref="GalaxyHierarchyIndex.ObjectViews"/>
|
||||
/// collection on every page — O(total) per page, O(total²/pageSize) end-to-end.
|
||||
/// With it, the first page builds the filtered list and each subsequent page is an
|
||||
/// O(pageSize) slice. The table is keyed on the immutable cache-entry instance, so
|
||||
/// when the cache publishes a new entry the stale memo becomes unreachable and is
|
||||
/// reclaimed with it — no explicit invalidation needed.
|
||||
/// </summary>
|
||||
private static readonly ConditionalWeakTable<GalaxyHierarchyCacheEntry, ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>> FilteredViewCache = new();
|
||||
|
||||
public static GalaxyHierarchyQueryResult Project(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs = null)
|
||||
{
|
||||
return Project(
|
||||
entry,
|
||||
request,
|
||||
browseSubtreeGlobs,
|
||||
offset: 0,
|
||||
pageSize: int.MaxValue);
|
||||
}
|
||||
|
||||
public static GalaxyHierarchyQueryResult Project(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int offset,
|
||||
int pageSize)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(entry);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
if (offset < 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(offset), offset, "Offset must be greater than or equal to zero.");
|
||||
}
|
||||
|
||||
if (pageSize <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(pageSize), pageSize, "Page size must be greater than zero.");
|
||||
}
|
||||
|
||||
int? maxDepth = request.MaxDepth;
|
||||
if (maxDepth < 0)
|
||||
{
|
||||
throw new RpcException(new Status(
|
||||
StatusCode.InvalidArgument,
|
||||
"DiscoverHierarchy max_depth must be greater than or equal to zero when provided."));
|
||||
}
|
||||
|
||||
string filterSignature = ComputeFilterSignature(request, browseSubtreeGlobs);
|
||||
IReadOnlyList<GalaxyObjectView> matchedViews = GetFilteredViews(
|
||||
entry,
|
||||
request,
|
||||
browseSubtreeGlobs,
|
||||
maxDepth,
|
||||
filterSignature);
|
||||
|
||||
bool includeAttributes = IncludeAttributes(request);
|
||||
List<GalaxyObject> page = new(Math.Min(pageSize, Math.Max(0, matchedViews.Count - offset)));
|
||||
int end = (int)Math.Min((long)offset + pageSize, matchedViews.Count);
|
||||
for (int index = offset; index < end; index++)
|
||||
{
|
||||
page.Add(CloneObject(matchedViews[index].Object, includeAttributes));
|
||||
}
|
||||
|
||||
return new GalaxyHierarchyQueryResult(
|
||||
page,
|
||||
matchedViews.Count,
|
||||
filterSignature);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<GalaxyObjectView> GetFilteredViews(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs,
|
||||
int? maxDepth,
|
||||
string filterSignature)
|
||||
{
|
||||
// ResolveRoot can throw RpcException(NotFound); run it before consulting the
|
||||
// memo so a bad root surfaces consistently regardless of cache state.
|
||||
IReadOnlyList<GalaxyObjectView> views = entry.Index.ObjectViews;
|
||||
GalaxyObjectView? root = ResolveRoot(request, views);
|
||||
|
||||
ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>> memo =
|
||||
FilteredViewCache.GetValue(entry, static _ => new ConcurrentDictionary<string, IReadOnlyList<GalaxyObjectView>>(StringComparer.Ordinal));
|
||||
|
||||
return memo.GetOrAdd(
|
||||
filterSignature,
|
||||
static (_, state) =>
|
||||
{
|
||||
List<GalaxyObjectView> matched = [];
|
||||
foreach (GalaxyObjectView view in state.Views)
|
||||
{
|
||||
if (MatchesRoot(view, state.Root, state.MaxDepth)
|
||||
&& MatchesBrowseSubtrees(view, state.BrowseSubtreeGlobs)
|
||||
&& MatchesFilters(view.Object, state.Request))
|
||||
{
|
||||
matched.Add(view);
|
||||
}
|
||||
}
|
||||
|
||||
return matched;
|
||||
},
|
||||
(Views: views, Root: root, MaxDepth: maxDepth, BrowseSubtreeGlobs: browseSubtreeGlobs, Request: request));
|
||||
}
|
||||
|
||||
public static GalaxyObject? FindObjectForTag(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
string tagAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||
? lookup.Object
|
||||
: null;
|
||||
}
|
||||
|
||||
public static GalaxyAttribute? FindAttributeForTag(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
string tagAddress)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
||||
? lookup.Attribute
|
||||
: null;
|
||||
}
|
||||
|
||||
public static string GetContainedPath(
|
||||
GalaxyHierarchyCacheEntry entry,
|
||||
int gobjectId)
|
||||
{
|
||||
return entry.Index.ObjectViewsById.TryGetValue(gobjectId, out GalaxyObjectView? view)
|
||||
? view.ContainedPath
|
||||
: string.Empty;
|
||||
}
|
||||
|
||||
private static GalaxyObjectView? ResolveRoot(
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<GalaxyObjectView> views)
|
||||
{
|
||||
GalaxyObjectView? root = request.RootCase switch
|
||||
{
|
||||
DiscoverHierarchyRequest.RootOneofCase.None => null,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => views.FirstOrDefault(
|
||||
view => view.Object.GobjectId == request.RootGobjectId),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName => views.FirstOrDefault(
|
||||
view => string.Equals(view.Object.TagName, request.RootTagName, StringComparison.OrdinalIgnoreCase)),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => views.FirstOrDefault(
|
||||
view => string.Equals(view.ContainedPath, request.RootContainedPath, StringComparison.OrdinalIgnoreCase)),
|
||||
_ => null,
|
||||
};
|
||||
|
||||
if (request.RootCase != DiscoverHierarchyRequest.RootOneofCase.None && root is null)
|
||||
{
|
||||
throw new RpcException(new Status(StatusCode.NotFound, "DiscoverHierarchy root was not found."));
|
||||
}
|
||||
|
||||
return root;
|
||||
}
|
||||
|
||||
private static bool MatchesRoot(
|
||||
GalaxyObjectView view,
|
||||
GalaxyObjectView? root,
|
||||
int? maxDepth)
|
||||
{
|
||||
if (root is null)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
bool isRoot = view.Object.GobjectId == root.Object.GobjectId;
|
||||
bool isDescendant = view.ContainedPath.StartsWith(root.ContainedPath + "/", StringComparison.OrdinalIgnoreCase);
|
||||
if (!isRoot && !isDescendant)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return maxDepth is null || view.Depth - root.Depth <= maxDepth.Value;
|
||||
}
|
||||
|
||||
private static bool MatchesBrowseSubtrees(
|
||||
GalaxyObjectView view,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
return browseSubtreeGlobs is null
|
||||
|| browseSubtreeGlobs.Count == 0
|
||||
|| browseSubtreeGlobs.Any(glob => GalaxyGlobMatcher.IsMatch(view.ContainedPath, glob));
|
||||
}
|
||||
|
||||
private static bool MatchesFilters(
|
||||
GalaxyObject obj,
|
||||
DiscoverHierarchyRequest request)
|
||||
{
|
||||
if (request.CategoryIds.Count > 0 && !request.CategoryIds.Contains(obj.CategoryId))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach (string templateFilter in request.TemplateChainContains)
|
||||
{
|
||||
if (!obj.TemplateChain.Any(template => template.Contains(templateFilter, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.TagNameGlob)
|
||||
&& !GalaxyGlobMatcher.IsMatch(obj.TagName, request.TagNameGlob))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.AlarmBearingOnly && !obj.Attributes.Any(attribute => attribute.IsAlarm))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (request.HistorizedOnly && !obj.Attributes.Any(attribute => attribute.IsHistorized))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static bool IncludeAttributes(DiscoverHierarchyRequest request)
|
||||
{
|
||||
return !request.HasIncludeAttributes || request.IncludeAttributes;
|
||||
}
|
||||
|
||||
private static GalaxyObject CloneObject(GalaxyObject source, bool includeAttributes)
|
||||
{
|
||||
GalaxyObject clone = source.Clone();
|
||||
if (!includeAttributes)
|
||||
{
|
||||
clone.Attributes.Clear();
|
||||
}
|
||||
|
||||
return clone;
|
||||
}
|
||||
|
||||
public static string ComputeFilterSignature(
|
||||
DiscoverHierarchyRequest request,
|
||||
IReadOnlyList<string>? browseSubtreeGlobs)
|
||||
{
|
||||
StringBuilder builder = new();
|
||||
builder.Append("root=").Append(request.RootCase).Append('|');
|
||||
builder.Append(request.RootCase switch
|
||||
{
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootGobjectId => request.RootGobjectId.ToString(
|
||||
System.Globalization.CultureInfo.InvariantCulture),
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootTagName => request.RootTagName,
|
||||
DiscoverHierarchyRequest.RootOneofCase.RootContainedPath => request.RootContainedPath,
|
||||
_ => string.Empty,
|
||||
});
|
||||
builder.Append("|max=").Append(request.MaxDepth?.ToString(System.Globalization.CultureInfo.InvariantCulture) ?? "");
|
||||
builder.Append("|cat=").AppendJoin(',', request.CategoryIds.Order());
|
||||
builder.Append("|tpl=").AppendJoin(',', request.TemplateChainContains.Order(StringComparer.OrdinalIgnoreCase));
|
||||
builder.Append("|glob=").Append(request.TagNameGlob);
|
||||
builder.Append("|attrs=").Append(request.HasIncludeAttributes ? request.IncludeAttributes.ToString() : "unset");
|
||||
builder.Append("|alarm=").Append(request.AlarmBearingOnly);
|
||||
builder.Append("|hist=").Append(request.HistorizedOnly);
|
||||
builder.Append("|browse=").AppendJoin(',', (browseSubtreeGlobs ?? Array.Empty<string>()).Order(StringComparer.OrdinalIgnoreCase));
|
||||
|
||||
byte[] hash = SHA256.HashData(Encoding.UTF8.GetBytes(builder.ToString()));
|
||||
return Convert.ToHexString(hash, 0, 12);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public sealed record GalaxyHierarchyQueryResult(
|
||||
IReadOnlyList<GalaxyObject> Objects,
|
||||
int TotalObjectCount,
|
||||
string FilterSignature);
|
||||
@@ -0,0 +1,62 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>Background service that periodically refreshes the Galaxy Repository hierarchy cache off the request path.</summary>
|
||||
public sealed class GalaxyHierarchyRefreshService(
|
||||
IGalaxyHierarchyCache cache,
|
||||
IOptions<GalaxyRepositoryOptions> options,
|
||||
ILogger<GalaxyHierarchyRefreshService> logger,
|
||||
TimeProvider? timeProvider = null) : BackgroundService
|
||||
{
|
||||
private readonly TimeProvider _timeProvider = timeProvider ?? TimeProvider.System;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
TimeSpan interval = TimeSpan.FromSeconds(Math.Max(1, options.Value.DashboardRefreshIntervalSeconds));
|
||||
|
||||
try
|
||||
{
|
||||
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
// A transient first-load failure (e.g. a TimeoutException or
|
||||
// Win32Exception from connection establishment, or a DbException
|
||||
// subtype the cache does not catch) must not fault this
|
||||
// BackgroundService and stop the whole gateway. The cache records
|
||||
// its own Unavailable/Stale status; the periodic tick below retries.
|
||||
logger.LogWarning(exception, "Initial Galaxy hierarchy cache load failed; will retry on the refresh interval.");
|
||||
}
|
||||
|
||||
using PeriodicTimer timer = new(interval, _timeProvider);
|
||||
try
|
||||
{
|
||||
while (await timer.WaitForNextTickAsync(stoppingToken).ConfigureAwait(false))
|
||||
{
|
||||
try
|
||||
{
|
||||
await cache.RefreshAsync(stoppingToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
return;
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
logger.LogWarning(exception, "Galaxy hierarchy cache refresh tick failed.");
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// One row from <see cref="GalaxyRepository.GetHierarchyAsync"/>: a deployed Galaxy
|
||||
/// <c>gobject</c> with its hierarchy parent and template-derivation chain.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchyRow
|
||||
{
|
||||
/// <summary>Gets the Galaxy object identifier.</summary>
|
||||
public int GobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets the tag name.</summary>
|
||||
public string TagName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the contained name.</summary>
|
||||
public string ContainedName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the browse name.</summary>
|
||||
public string BrowseName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the parent Galaxy object identifier.</summary>
|
||||
public int ParentGobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is an area.</summary>
|
||||
public bool IsArea { get; init; }
|
||||
|
||||
/// <summary>Gets the category identifier.</summary>
|
||||
public int CategoryId { get; init; }
|
||||
|
||||
/// <summary>Gets the Galaxy object identifier of the host.</summary>
|
||||
public int HostedByGobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets the template derivation chain.</summary>
|
||||
public IReadOnlyList<string> TemplateChain { get; init; } = Array.Empty<string>();
|
||||
}
|
||||
|
||||
/// <summary>One row from <see cref="GalaxyRepository.GetAttributesAsync"/>.</summary>
|
||||
public sealed class GalaxyAttributeRow
|
||||
{
|
||||
/// <summary>Gets the Galaxy object identifier.</summary>
|
||||
public int GobjectId { get; init; }
|
||||
|
||||
/// <summary>Gets the tag name.</summary>
|
||||
public string TagName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the attribute name.</summary>
|
||||
public string AttributeName { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the full tag reference.</summary>
|
||||
public string FullTagReference { get; init; } = string.Empty;
|
||||
|
||||
/// <summary>Gets the MXAccess data type code.</summary>
|
||||
public int MxDataType { get; init; }
|
||||
|
||||
/// <summary>Gets the data type name.</summary>
|
||||
public string? DataTypeName { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is an array.</summary>
|
||||
public bool IsArray { get; init; }
|
||||
|
||||
/// <summary>Gets the array dimension, if applicable.</summary>
|
||||
public int? ArrayDimension { get; init; }
|
||||
|
||||
/// <summary>Gets the MXAccess attribute category code.</summary>
|
||||
public int MxAttributeCategory { get; init; }
|
||||
|
||||
/// <summary>Gets the security classification code.</summary>
|
||||
public int SecurityClassification { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is historized.</summary>
|
||||
public bool IsHistorized { get; init; }
|
||||
|
||||
/// <summary>Gets a value indicating whether this is an alarm.</summary>
|
||||
public bool IsAlarm { get; init; }
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// A serializable point-in-time copy of the Galaxy Repository browse data.
|
||||
/// Holds the raw hierarchy and attribute rowsets — not the materialized
|
||||
/// protobuf objects — so the restore path runs the exact same
|
||||
/// materialization as a live refresh. Persisted by
|
||||
/// <see cref="IGalaxyHierarchySnapshotStore"/> after a successful refresh
|
||||
/// and reloaded at startup when the Galaxy database is unreachable.
|
||||
/// </summary>
|
||||
/// <param name="LastDeployTime">
|
||||
/// The <c>galaxy.time_of_last_deploy</c> the rowsets were pulled at, or
|
||||
/// <see langword="null"/> when the Galaxy table reported no deploy. A later
|
||||
/// live refresh that observes this same timestamp can promote the restored
|
||||
/// entry to healthy without re-running the heavy queries.
|
||||
/// </param>
|
||||
/// <param name="SavedAt">UTC wall-clock when the snapshot was written to disk.</param>
|
||||
/// <param name="Hierarchy">The persisted object-hierarchy rowset.</param>
|
||||
/// <param name="Attributes">The persisted attribute rowset.</param>
|
||||
public sealed record GalaxyHierarchySnapshot(
|
||||
DateTimeOffset? LastDeployTime,
|
||||
DateTimeOffset SavedAt,
|
||||
IReadOnlyList<GalaxyHierarchyRow> Hierarchy,
|
||||
IReadOnlyList<GalaxyAttributeRow> Attributes);
|
||||
@@ -0,0 +1,141 @@
|
||||
using System.Text.Json;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// JSON-file implementation of <see cref="IGalaxyHierarchySnapshotStore"/>.
|
||||
/// Writes the on-disk snapshot atomically (temp file + rename) so a crash
|
||||
/// mid-write can never leave a torn file, and ignores files whose schema
|
||||
/// version it does not recognize. When
|
||||
/// <see cref="GalaxyRepositoryOptions.PersistSnapshot"/> is <see langword="false"/>
|
||||
/// both operations are no-ops.
|
||||
/// </summary>
|
||||
public sealed class GalaxyHierarchySnapshotStore : IGalaxyHierarchySnapshotStore
|
||||
{
|
||||
/// <summary>
|
||||
/// On-disk format version. Bump this whenever the persisted shape changes
|
||||
/// in a way an older or newer gateway cannot read; a mismatched file is
|
||||
/// ignored rather than misparsed.
|
||||
/// </summary>
|
||||
private const int CurrentSchemaVersion = 1;
|
||||
|
||||
private static readonly JsonSerializerOptions SerializerOptions = new()
|
||||
{
|
||||
WriteIndented = false,
|
||||
};
|
||||
|
||||
private readonly string? _path;
|
||||
private readonly TimeSpan _writeTimeout;
|
||||
private readonly ILogger<GalaxyHierarchySnapshotStore>? _logger;
|
||||
private readonly SemaphoreSlim _ioGate = new(1, 1);
|
||||
|
||||
/// <summary>Initializes a new instance of the <see cref="GalaxyHierarchySnapshotStore"/> class.</summary>
|
||||
/// <param name="options">Galaxy repository options carrying the snapshot path and enable flag.</param>
|
||||
/// <param name="logger">Optional logger for diagnostic output.</param>
|
||||
public GalaxyHierarchySnapshotStore(
|
||||
IOptions<GalaxyRepositoryOptions> options,
|
||||
ILogger<GalaxyHierarchySnapshotStore>? logger = null)
|
||||
{
|
||||
GalaxyRepositoryOptions value = options.Value;
|
||||
_path = value.PersistSnapshot && !string.IsNullOrWhiteSpace(value.SnapshotCachePath)
|
||||
? value.SnapshotCachePath
|
||||
: null;
|
||||
_writeTimeout = TimeSpan.FromSeconds(Math.Max(1, value.CommandTimeoutSeconds));
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task SaveAsync(GalaxyHierarchySnapshot snapshot, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(snapshot);
|
||||
if (_path is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
PersistedFile file = new(CurrentSchemaVersion, snapshot);
|
||||
|
||||
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
// Bound the write so a stuck disk — e.g. a SnapshotCachePath on an
|
||||
// unresponsive network share — cannot stall the caller. On the cache
|
||||
// refresh path that would otherwise pin the whole refresh loop.
|
||||
using CancellationTokenSource writeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
writeCts.CancelAfter(_writeTimeout);
|
||||
|
||||
string? directory = Path.GetDirectoryName(_path);
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
{
|
||||
Directory.CreateDirectory(directory);
|
||||
}
|
||||
|
||||
string tempPath = _path + ".tmp";
|
||||
await using (FileStream stream = new(tempPath, FileMode.Create, FileAccess.Write, FileShare.None))
|
||||
{
|
||||
await JsonSerializer.SerializeAsync(stream, file, SerializerOptions, writeCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
File.Move(tempPath, _path, overwrite: true);
|
||||
_logger?.LogDebug(
|
||||
"Persisted Galaxy hierarchy snapshot to {Path} ({ObjectCount} objects, {AttributeCount} attributes).",
|
||||
_path,
|
||||
snapshot.Hierarchy.Count,
|
||||
snapshot.Attributes.Count);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ioGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<GalaxyHierarchySnapshot?> TryLoadAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_path is null || !File.Exists(_path))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
await _ioGate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||||
try
|
||||
{
|
||||
PersistedFile? file;
|
||||
await using (FileStream stream = new(_path, FileMode.Open, FileAccess.Read, FileShare.Read))
|
||||
{
|
||||
file = await JsonSerializer.DeserializeAsync<PersistedFile>(
|
||||
stream, SerializerOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
if (file is null || file.SchemaVersion != CurrentSchemaVersion || file.Snapshot is null)
|
||||
{
|
||||
_logger?.LogWarning(
|
||||
"Ignoring Galaxy hierarchy snapshot at {Path}: unrecognized or empty schema version.",
|
||||
_path);
|
||||
return null;
|
||||
}
|
||||
|
||||
return file.Snapshot;
|
||||
}
|
||||
catch (Exception exception) when (exception is JsonException or IOException or UnauthorizedAccessException)
|
||||
{
|
||||
// A corrupt, truncated, locked, or access-denied snapshot file is an
|
||||
// expected failure mode for a disk cache — honor the Try contract and
|
||||
// return null rather than throwing.
|
||||
_logger?.LogWarning(
|
||||
exception,
|
||||
"Ignoring Galaxy hierarchy snapshot at {Path}: the file is unreadable or not valid JSON.",
|
||||
_path);
|
||||
return null;
|
||||
}
|
||||
finally
|
||||
{
|
||||
_ioGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>On-disk envelope: a schema version plus the snapshot payload.</summary>
|
||||
private sealed record PersistedFile(int SchemaVersion, GalaxyHierarchySnapshot? Snapshot);
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public sealed record GalaxyObjectView(
|
||||
GalaxyObject Object,
|
||||
string ContainedPath,
|
||||
int Depth);
|
||||
@@ -0,0 +1,252 @@
|
||||
using Microsoft.Data.SqlClient;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// SQL access to the AVEVA System Platform Galaxy Repository (ZB) database.
|
||||
/// <para>
|
||||
/// <see cref="HierarchySql" /> is still the query originally ported from the OtOpcUa
|
||||
/// project. <see cref="AttributesSql" /> has diverged: it additionally enumerates the
|
||||
/// built-in attributes contributed by each object's primitives (from
|
||||
/// <c>attribute_definition</c> via <c>primitive_instance</c>), so engine/platform objects
|
||||
/// and extension sub-attributes (e.g. <c>TestAlarm001.Acked</c>) are surfaced. The
|
||||
/// OtOpcUa query is not kept in sync — see docs/GalaxyRepository.md.
|
||||
/// </para>
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepository(GalaxyRepositoryOptions options) : IGalaxyRepository
|
||||
{
|
||||
/// <summary>Tests the connection to the Galaxy Repository database.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<bool> TestConnectionAsync(CancellationToken ct = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
using SqlCommand cmd = new("SELECT 1", conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return result is int i && i == 1;
|
||||
}
|
||||
catch (SqlException) { return false; }
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>Retrieves the last deployment time from the Galaxy Repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<DateTime?> GetLastDeployTimeAsync(CancellationToken ct = default)
|
||||
{
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
using SqlCommand cmd = new("SELECT time_of_last_deploy FROM galaxy", conn)
|
||||
{ CommandTimeout = options.CommandTimeoutSeconds };
|
||||
object? result = await cmd.ExecuteScalarAsync(ct).ConfigureAwait(false);
|
||||
return result is DateTime dt ? dt : null;
|
||||
}
|
||||
|
||||
/// <summary>Retrieves the complete hierarchy of Galaxy objects from the repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<List<GalaxyHierarchyRow>> GetHierarchyAsync(CancellationToken ct = default)
|
||||
{
|
||||
List<GalaxyHierarchyRow> rows = new();
|
||||
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using SqlCommand cmd = new(HierarchySql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
string templateChainRaw = reader.IsDBNull(8) ? string.Empty : reader.GetString(8);
|
||||
string[] templateChain = templateChainRaw.Length == 0
|
||||
? Array.Empty<string>()
|
||||
: templateChainRaw.Split(['|'], StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(s => s.Trim())
|
||||
.Where(s => s.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
rows.Add(new GalaxyHierarchyRow
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
TagName = reader.GetString(1),
|
||||
ContainedName = reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
|
||||
BrowseName = reader.GetString(3),
|
||||
ParentGobjectId = Convert.ToInt32(reader.GetValue(4)),
|
||||
IsArea = Convert.ToInt32(reader.GetValue(5)) == 1,
|
||||
CategoryId = Convert.ToInt32(reader.GetValue(6)),
|
||||
HostedByGobjectId = Convert.ToInt32(reader.GetValue(7)),
|
||||
TemplateChain = templateChain,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>Retrieves all attributes for Galaxy objects from the repository.</summary>
|
||||
/// <param name="ct">Token to cancel the asynchronous operation.</param>
|
||||
public async Task<List<GalaxyAttributeRow>> GetAttributesAsync(CancellationToken ct = default)
|
||||
{
|
||||
List<GalaxyAttributeRow> rows = new();
|
||||
|
||||
using SqlConnection conn = new(options.ConnectionString);
|
||||
await conn.OpenAsync(ct).ConfigureAwait(false);
|
||||
|
||||
using SqlCommand cmd = new(AttributesSql, conn) { CommandTimeout = options.CommandTimeoutSeconds };
|
||||
using SqlDataReader reader = await cmd.ExecuteReaderAsync(ct).ConfigureAwait(false);
|
||||
|
||||
while (await reader.ReadAsync(ct).ConfigureAwait(false))
|
||||
{
|
||||
rows.Add(new GalaxyAttributeRow
|
||||
{
|
||||
GobjectId = Convert.ToInt32(reader.GetValue(0)),
|
||||
TagName = reader.GetString(1),
|
||||
AttributeName = reader.GetString(2),
|
||||
FullTagReference = reader.GetString(3),
|
||||
MxDataType = Convert.ToInt32(reader.GetValue(4)),
|
||||
DataTypeName = reader.IsDBNull(5) ? null : reader.GetString(5),
|
||||
IsArray = Convert.ToInt32(reader.GetValue(6)) == 1,
|
||||
ArrayDimension = reader.IsDBNull(7) ? null : Convert.ToInt32(reader.GetValue(7)),
|
||||
MxAttributeCategory = Convert.ToInt32(reader.GetValue(8)),
|
||||
SecurityClassification = Convert.ToInt32(reader.GetValue(9)),
|
||||
IsHistorized = Convert.ToInt32(reader.GetValue(10)) == 1,
|
||||
IsAlarm = Convert.ToInt32(reader.GetValue(11)) == 1,
|
||||
});
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
private const string HierarchySql = @"
|
||||
;WITH template_chain AS (
|
||||
SELECT g.gobject_id AS instance_gobject_id, t.gobject_id AS template_gobject_id,
|
||||
t.tag_name AS template_tag_name, t.derived_from_gobject_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN gobject t ON t.gobject_id = g.derived_from_gobject_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0 AND g.derived_from_gobject_id <> 0
|
||||
UNION ALL
|
||||
SELECT tc.instance_gobject_id, t.gobject_id, t.tag_name, t.derived_from_gobject_id, tc.depth + 1
|
||||
FROM template_chain tc
|
||||
INNER JOIN gobject t ON t.gobject_id = tc.derived_from_gobject_id
|
||||
WHERE tc.derived_from_gobject_id <> 0 AND tc.depth < 10
|
||||
)
|
||||
SELECT DISTINCT
|
||||
g.gobject_id,
|
||||
g.tag_name,
|
||||
g.contained_name,
|
||||
CASE WHEN g.contained_name IS NULL OR g.contained_name = ''
|
||||
THEN g.tag_name
|
||||
ELSE g.contained_name
|
||||
END AS browse_name,
|
||||
CASE WHEN g.contained_by_gobject_id = 0
|
||||
THEN g.area_gobject_id
|
||||
ELSE g.contained_by_gobject_id
|
||||
END AS parent_gobject_id,
|
||||
CASE WHEN td.category_id = 13
|
||||
THEN 1
|
||||
ELSE 0
|
||||
END AS is_area,
|
||||
td.category_id AS category_id,
|
||||
g.hosted_by_gobject_id AS hosted_by_gobject_id,
|
||||
ISNULL(
|
||||
STUFF((
|
||||
SELECT '|' + tc.template_tag_name
|
||||
FROM template_chain tc
|
||||
WHERE tc.instance_gobject_id = g.gobject_id
|
||||
ORDER BY tc.depth
|
||||
FOR XML PATH('')
|
||||
), 1, 1, ''),
|
||||
''
|
||||
) AS template_chain
|
||||
FROM gobject g
|
||||
INNER JOIN template_definition td
|
||||
ON g.template_definition_id = td.template_definition_id
|
||||
WHERE td.category_id IN (1, 3, 4, 10, 11, 13, 17, 24, 26)
|
||||
AND g.is_template = 0
|
||||
AND g.deployed_package_id <> 0
|
||||
ORDER BY parent_gobject_id, g.tag_name";
|
||||
|
||||
// Unlike HierarchySql, this query has diverged from the OtOpcUa original. It returns two
|
||||
// kinds of attribute: user-configured dynamic attributes (the original `dynamic_attribute`
|
||||
// body, src_pri 0) and the built-in attributes every object inherits from its primitives
|
||||
// (`attribute_definition` joined through `primitive_instance`, src_pri 1). Built-in
|
||||
// attributes are why engine/platform objects and extension sub-attributes such as
|
||||
// `TestAlarm001.Acked` show up at all. Built-in rows carry no category filter (the
|
||||
// `attribute_definition` category numbering differs from `dynamic_attribute`'s — only the
|
||||
// `_`-prefix and `.Description` name exclusions apply) and are never flagged
|
||||
// `is_historized`/`is_alarm`: those flags describe a user attribute that anchors an
|
||||
// extension, not the extension's machinery leaves. See docs/GalaxyRepository.md.
|
||||
private const string AttributesSql = @"
|
||||
;WITH deployed_package_chain AS (
|
||||
SELECT g.gobject_id, p.package_id, p.derived_from_package_id, 0 AS depth
|
||||
FROM gobject g
|
||||
INNER JOIN package p ON p.package_id = g.deployed_package_id
|
||||
WHERE g.is_template = 0 AND g.deployed_package_id <> 0
|
||||
UNION ALL
|
||||
SELECT dpc.gobject_id, p.package_id, p.derived_from_package_id, dpc.depth + 1
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN package p ON p.package_id = dpc.derived_from_package_id
|
||||
WHERE dpc.derived_from_package_id <> 0 AND dpc.depth < 10
|
||||
),
|
||||
candidate AS (
|
||||
SELECT
|
||||
dpc.gobject_id, g.tag_name, da.attribute_name, da.mx_data_type, da.is_array,
|
||||
CASE WHEN da.is_array = 1
|
||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||
SUBSTRING(da.mx_value, 15, 2) + SUBSTRING(da.mx_value, 13, 2), 2))
|
||||
ELSE NULL END AS array_dimension,
|
||||
da.mx_attribute_category, da.security_classification, dpc.depth, 0 AS src_pri
|
||||
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)
|
||||
UNION ALL
|
||||
SELECT
|
||||
dpc.gobject_id, g.tag_name,
|
||||
CASE WHEN pi.primitive_name IS NULL OR pi.primitive_name = ''
|
||||
THEN ad.attribute_name
|
||||
ELSE pi.primitive_name + '.' + ad.attribute_name END AS attribute_name,
|
||||
ad.mx_data_type, ad.is_array,
|
||||
CASE WHEN ad.is_array = 1
|
||||
THEN CONVERT(int, CONVERT(varbinary(2),
|
||||
SUBSTRING(ad.mx_value, 15, 2) + SUBSTRING(ad.mx_value, 13, 2), 2))
|
||||
ELSE NULL END AS array_dimension,
|
||||
ad.mx_attribute_category, ad.security_classification, dpc.depth, 1 AS src_pri
|
||||
FROM deployed_package_chain dpc
|
||||
INNER JOIN primitive_instance pi ON pi.package_id = dpc.package_id
|
||||
INNER JOIN attribute_definition ad ON ad.primitive_definition_id = pi.primitive_definition_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 ad.attribute_name NOT LIKE '[_]%'
|
||||
AND ad.attribute_name NOT LIKE '%.Description'
|
||||
),
|
||||
ranked AS (
|
||||
SELECT c.*, ROW_NUMBER() OVER (
|
||||
PARTITION BY c.gobject_id, c.attribute_name ORDER BY c.src_pri, c.depth) AS rn
|
||||
FROM candidate c
|
||||
)
|
||||
SELECT
|
||||
r.gobject_id, r.tag_name, r.attribute_name,
|
||||
r.tag_name + '.' + r.attribute_name
|
||||
+ CASE WHEN r.is_array = 1 THEN '[]' ELSE '' END AS full_tag_reference,
|
||||
r.mx_data_type, dt.description AS data_type_name, r.is_array, r.array_dimension,
|
||||
r.mx_attribute_category, r.security_classification,
|
||||
CASE WHEN r.src_pri = 0 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 = 'HistoryExtension'
|
||||
WHERE dpc2.gobject_id = r.gobject_id
|
||||
) THEN 1 ELSE 0 END AS is_historized,
|
||||
CASE WHEN r.src_pri = 0 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
|
||||
) THEN 1 ELSE 0 END AS is_alarm
|
||||
FROM ranked r
|
||||
LEFT JOIN data_type dt ON dt.mx_data_type = r.mx_data_type
|
||||
WHERE r.rn = 1
|
||||
ORDER BY r.tag_name, r.attribute_name";
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
/// <summary>
|
||||
/// Connection settings for the AVEVA System Platform Galaxy Repository (ZB) database.
|
||||
/// Bound to the <c>MxGateway:Galaxy</c> configuration section.
|
||||
/// </summary>
|
||||
public sealed class GalaxyRepositoryOptions
|
||||
{
|
||||
public const string SectionName = "MxGateway:Galaxy";
|
||||
|
||||
/// <summary>
|
||||
/// Default SQL Server connection string for the Galaxy Repository database.
|
||||
/// Single source of truth shared with the integration-test fallback so the
|
||||
/// production default and the live-test default cannot drift.
|
||||
/// </summary>
|
||||
public const string DefaultConnectionString =
|
||||
"Server=localhost;Database=ZB;Integrated Security=True;TrustServerCertificate=True;Encrypt=False;";
|
||||
|
||||
/// <summary>The SQL Server connection string for the Galaxy Repository database.</summary>
|
||||
public string ConnectionString { get; init; } = DefaultConnectionString;
|
||||
|
||||
/// <summary>The timeout in seconds for SQL commands executed against the Galaxy Repository.</summary>
|
||||
public int CommandTimeoutSeconds { get; init; } = 60;
|
||||
|
||||
/// <summary>
|
||||
/// Interval (seconds) between background refreshes of the dashboard Galaxy summary
|
||||
/// cache. SQL is hit at most once per interval regardless of dashboard render rate.
|
||||
/// </summary>
|
||||
public int DashboardRefreshIntervalSeconds { get; init; } = 30;
|
||||
|
||||
/// <summary>Default on-disk path for the persisted Galaxy browse snapshot.</summary>
|
||||
public const string DefaultSnapshotCachePath =
|
||||
@"C:\ProgramData\MxGateway\galaxy-snapshot.json";
|
||||
|
||||
/// <summary>
|
||||
/// Whether the gateway persists the latest successful Galaxy browse dataset to
|
||||
/// disk. When enabled, the cache reloads that snapshot at startup so clients can
|
||||
/// still browse last-known data while the Galaxy database is unreachable.
|
||||
/// </summary>
|
||||
public bool PersistSnapshot { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// File path for the persisted Galaxy browse snapshot. Ignored when
|
||||
/// <see cref="PersistSnapshot"/> is <see langword="false"/>.
|
||||
/// </summary>
|
||||
public string SnapshotCachePath { get; init; } = DefaultSnapshotCachePath;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public static class GalaxyRepositoryServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>Registers Galaxy Repository services in the dependency injection container.</summary>
|
||||
/// <param name="services">The service collection.</param>
|
||||
/// <returns>The service collection for chaining.</returns>
|
||||
public static IServiceCollection AddGalaxyRepository(this IServiceCollection services)
|
||||
{
|
||||
services
|
||||
.AddOptions<GalaxyRepositoryOptions>()
|
||||
.BindConfiguration(GalaxyRepositoryOptions.SectionName)
|
||||
.ValidateOnStart();
|
||||
|
||||
services.AddSingleton(sp =>
|
||||
new GalaxyRepository(sp.GetRequiredService<IOptions<GalaxyRepositoryOptions>>().Value));
|
||||
services.AddSingleton<IGalaxyRepository>(sp => sp.GetRequiredService<GalaxyRepository>());
|
||||
|
||||
services.AddSingleton<IGalaxyDeployNotifier, GalaxyDeployNotifier>();
|
||||
services.AddSingleton<IGalaxyHierarchySnapshotStore, GalaxyHierarchySnapshotStore>();
|
||||
services.AddSingleton<IGalaxyHierarchyCache, GalaxyHierarchyCache>();
|
||||
services.AddHostedService<GalaxyHierarchyRefreshService>();
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto.Galaxy;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Galaxy;
|
||||
|
||||
public sealed record GalaxyTagLookup(
|
||||
GalaxyObject Object,
|
||||
GalaxyAttribute? Attribute,
|
||||
string ContainedPath);
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user