fix(dashboard): ShowTagValues now gates the alarms hub and /browse live values
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Sessions;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
@@ -11,6 +13,16 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// expires. All access is serialised through <see cref="_gate"/> so the
|
||||
/// single backing worker only ever sees one in-flight command.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This service is also where <c>MxGateway:Dashboard:ShowTagValues</c> is
|
||||
/// applied to the Browse panel: with the flag false (the default) the
|
||||
/// formatted value never leaves this boundary — the projected
|
||||
/// <see cref="DashboardTagValue"/> carries
|
||||
/// <see cref="DashboardTagValue.RedactedValueText"/> instead. Putting the
|
||||
/// decision at the service rather than in the page keeps it to one place and
|
||||
/// keeps a value out of the render tree entirely, rather than relying on
|
||||
/// every current and future view to remember to suppress it.
|
||||
/// </remarks>
|
||||
public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsyncDisposable
|
||||
{
|
||||
private const string BackendName = "Galaxy";
|
||||
@@ -38,6 +50,15 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
||||
private readonly ILogger<DashboardLiveDataService> _logger;
|
||||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||||
|
||||
/// <summary>
|
||||
/// <c>MxGateway:Dashboard:ShowTagValues</c>. False (the default)
|
||||
/// substitutes <see cref="DashboardTagValue.RedactedValueText"/> for every
|
||||
/// value this service hands the Browse panel; quality, data type, source
|
||||
/// timestamp, and any error still describe the real read, so the panel
|
||||
/// remains a diagnostic surface without being a value-disclosure one.
|
||||
/// </summary>
|
||||
private readonly bool _showTagValues;
|
||||
|
||||
// Least-recently-read-last advise set: the list holds every currently advised
|
||||
// tag ordered most- to least-recently read, the dictionary indexes into it.
|
||||
// Both are only ever touched under _gate, which already serialises all viewers.
|
||||
@@ -53,15 +74,19 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
||||
/// <summary>Initializes the live-data service.</summary>
|
||||
/// <param name="sessionManager">Gateway session manager.</param>
|
||||
/// <param name="alarmService">Gateway central alarm service.</param>
|
||||
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
|
||||
/// <param name="logger">Diagnostic logger.</param>
|
||||
public DashboardLiveDataService(
|
||||
ISessionManager sessionManager,
|
||||
IGatewayAlarmService alarmService,
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<DashboardLiveDataService> logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(options);
|
||||
_sessionManager = sessionManager ?? throw new ArgumentNullException(nameof(sessionManager));
|
||||
_alarmService = alarmService ?? throw new ArgumentNullException(nameof(alarmService));
|
||||
_logger = logger ?? throw new ArgumentNullException(nameof(logger));
|
||||
_showTagValues = options.Value.Dashboard.ShowTagValues;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -96,8 +121,12 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
||||
.ReadBulkAsync(serverHandle, tagAddresses.ToArray(), ReadTimeout, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
// The only place the /browse live-value gate is evaluated: the page
|
||||
// renders whatever ValueText it is handed, so a second check in the
|
||||
// view could only ever disagree with this one.
|
||||
DashboardTagValue[] values = results
|
||||
.Select(DashboardTagValue.FromBulkReadResult)
|
||||
.Select(value => _showTagValues ? value : value with { ValueText = DashboardTagValue.RedactedValueText })
|
||||
.ToArray();
|
||||
return new DashboardLiveReadResult(values, null, session.SessionId, session.WorkerProcessId);
|
||||
}
|
||||
|
||||
@@ -17,6 +17,17 @@ public sealed record DashboardTagValue(
|
||||
DateTimeOffset? SourceTimestamp,
|
||||
string? Error)
|
||||
{
|
||||
/// <summary>
|
||||
/// Placeholder rendered in place of <see cref="ValueText"/> when
|
||||
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default). The
|
||||
/// substitution happens once, in <c>DashboardLiveDataService</c>, so the
|
||||
/// Browse page renders whatever it is handed and no view has to repeat
|
||||
/// the decision. Deliberately a visible marker rather than an empty
|
||||
/// string: an operator must be able to tell a suppressed value from a
|
||||
/// tag that read back blank.
|
||||
/// </summary>
|
||||
public const string RedactedValueText = "[redacted]";
|
||||
|
||||
/// <summary>
|
||||
/// Classic OPC-DA "Good" quality. MXAccess surfaces 192 for a healthy
|
||||
/// advised value; anything lower is uncertain or bad.
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
@@ -11,11 +13,28 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
/// client. The hub itself is session-less; clients filter / route messages
|
||||
/// in the browser.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// When <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), the
|
||||
/// alarm value fields are stripped from a redacted copy before the message
|
||||
/// reaches any browser client — the same rule
|
||||
/// <see cref="DashboardEventBroadcaster"/> applies to the events-hub mirror, so
|
||||
/// the two SignalR seams cannot disagree about whether values leave the gateway.
|
||||
/// The source message is never mutated: it fans out from one feed to the gRPC
|
||||
/// <c>StreamAlarms</c> subscribers and the alarms page as well, and none of
|
||||
/// those audiences is subject to this dashboard-display flag.
|
||||
/// </remarks>
|
||||
/// <param name="alarmService">The gateway's central alarm feed.</param>
|
||||
/// <param name="hubContext">Hub context used to broadcast to the alarms group.</param>
|
||||
/// <param name="options">Gateway options supplying <c>Dashboard:ShowTagValues</c>.</param>
|
||||
/// <param name="logger">Logger for best-effort broadcast failures.</param>
|
||||
public sealed class AlarmsHubPublisher(
|
||||
IGatewayAlarmService alarmService,
|
||||
IHubContext<AlarmsHub> hubContext,
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<AlarmsHubPublisher> logger) : BackgroundService
|
||||
{
|
||||
private readonly bool _showTagValues = options.Value.Dashboard.ShowTagValues;
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
@@ -37,9 +56,10 @@ public sealed class AlarmsHubPublisher(
|
||||
|
||||
try
|
||||
{
|
||||
AlarmFeedMessage outbound = _showTagValues ? message : RedactValues(message);
|
||||
await hubContext.Clients
|
||||
.Group(AlarmsHub.AllAlarmsGroup)
|
||||
.SendAsync(AlarmsHub.AlarmMessage, message, stoppingToken)
|
||||
.SendAsync(AlarmsHub.AlarmMessage, outbound, stoppingToken)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
@@ -66,4 +86,42 @@ public sealed class AlarmsHubPublisher(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Produces a copy of <paramref name="source"/> with the alarm value fields
|
||||
/// cleared, leaving every other field — reference, severity, state, category,
|
||||
/// operator, timestamps — intact so the alarms page still renders the row.
|
||||
/// </summary>
|
||||
/// <param name="source">The message as the alarm feed produced it.</param>
|
||||
/// <returns>
|
||||
/// A redacted deep clone for the two value-bearing payload arms; the source
|
||||
/// instance itself for every other arm, which carries no value to strip.
|
||||
/// New payload arms therefore pass through unchanged by default — the switch
|
||||
/// names only the arms that have something to redact, so adding a valueless
|
||||
/// arm to the contract needs no change here.
|
||||
/// </returns>
|
||||
private static AlarmFeedMessage RedactValues(AlarmFeedMessage source)
|
||||
{
|
||||
switch (source.PayloadCase)
|
||||
{
|
||||
case AlarmFeedMessage.PayloadOneofCase.Transition:
|
||||
{
|
||||
AlarmFeedMessage redacted = source.Clone();
|
||||
redacted.Transition.CurrentValue = null;
|
||||
redacted.Transition.LimitValue = null;
|
||||
return redacted;
|
||||
}
|
||||
|
||||
case AlarmFeedMessage.PayloadOneofCase.ActiveAlarm:
|
||||
{
|
||||
AlarmFeedMessage redacted = source.Clone();
|
||||
redacted.ActiveAlarm.CurrentValue = null;
|
||||
redacted.ActiveAlarm.LimitValue = null;
|
||||
return redacted;
|
||||
}
|
||||
|
||||
default:
|
||||
return source;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,343 @@
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.AspNetCore.SignalR;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||||
using ZB.MOM.WW.MxGateway.Server.Alarms;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// Verifies that <see cref="AlarmsHubPublisher"/> honours
|
||||
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): the alarm value fields of
|
||||
/// both value-bearing payload arms are stripped from the copy broadcast to
|
||||
/// browser clients when the flag is off, present when it is on, and the source
|
||||
/// <see cref="AlarmFeedMessage"/> — shared with the gRPC <c>StreamAlarms</c>
|
||||
/// subscribers and the alarms page — is never mutated.
|
||||
/// </summary>
|
||||
public sealed class AlarmsHubPublisherTests
|
||||
{
|
||||
private static readonly TimeSpan TestTimeout = TimeSpan.FromSeconds(5);
|
||||
|
||||
/// <summary>Both value-bearing arms lose their values when the flag is off; metadata survives.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenShowTagValuesFalse_RedactsBothArmsButKeepsMetadata()
|
||||
{
|
||||
AlarmFeedMessage transition = BuildTransition();
|
||||
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
|
||||
CapturingHubContext hubContext = await RunPublisherAsync(
|
||||
showTagValues: false,
|
||||
transition,
|
||||
activeAlarm);
|
||||
|
||||
Assert.Equal(2, hubContext.Sent.Count);
|
||||
|
||||
AlarmFeedMessage sentTransition = hubContext.Sent[0];
|
||||
Assert.Null(sentTransition.Transition.CurrentValue);
|
||||
Assert.Null(sentTransition.Transition.LimitValue);
|
||||
Assert.Equal("Tank01.Level.HiHi", sentTransition.Transition.AlarmFullReference);
|
||||
Assert.Equal("Tank01", sentTransition.Transition.SourceObjectReference);
|
||||
Assert.Equal(AlarmTransitionKind.Raise, sentTransition.Transition.TransitionKind);
|
||||
Assert.Equal(800, sentTransition.Transition.Severity);
|
||||
Assert.Equal("Process", sentTransition.Transition.Category);
|
||||
|
||||
AlarmFeedMessage sentActive = hubContext.Sent[1];
|
||||
Assert.Null(sentActive.ActiveAlarm.CurrentValue);
|
||||
Assert.Null(sentActive.ActiveAlarm.LimitValue);
|
||||
Assert.Equal("Tank02.Level.Lo", sentActive.ActiveAlarm.AlarmFullReference);
|
||||
Assert.Equal(AlarmConditionState.Active, sentActive.ActiveAlarm.CurrentState);
|
||||
Assert.Equal(500, sentActive.ActiveAlarm.Severity);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Redaction applies to a clone: the source message fans out to gRPC
|
||||
/// <c>StreamAlarms</c> subscribers and the alarms page, so it must keep its
|
||||
/// values.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenShowTagValuesFalse_DoesNotMutateSourceMessage()
|
||||
{
|
||||
AlarmFeedMessage transition = BuildTransition();
|
||||
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
|
||||
CapturingHubContext hubContext = await RunPublisherAsync(
|
||||
showTagValues: false,
|
||||
transition,
|
||||
activeAlarm);
|
||||
|
||||
Assert.NotNull(transition.Transition.CurrentValue);
|
||||
Assert.Equal(88.0, transition.Transition.CurrentValue.DoubleValue);
|
||||
Assert.NotNull(transition.Transition.LimitValue);
|
||||
Assert.NotNull(activeAlarm.ActiveAlarm.CurrentValue);
|
||||
Assert.Equal(12.5, activeAlarm.ActiveAlarm.CurrentValue.DoubleValue);
|
||||
Assert.NotNull(activeAlarm.ActiveAlarm.LimitValue);
|
||||
|
||||
Assert.NotSame(transition, hubContext.Sent[0]);
|
||||
Assert.NotSame(activeAlarm, hubContext.Sent[1]);
|
||||
}
|
||||
|
||||
/// <summary>Values pass through unredacted — and uncloned — when the flag is on.</summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WhenShowTagValuesTrue_KeepsValues()
|
||||
{
|
||||
AlarmFeedMessage transition = BuildTransition();
|
||||
AlarmFeedMessage activeAlarm = BuildActiveAlarm();
|
||||
CapturingHubContext hubContext = await RunPublisherAsync(
|
||||
showTagValues: true,
|
||||
transition,
|
||||
activeAlarm);
|
||||
|
||||
Assert.Same(transition, hubContext.Sent[0]);
|
||||
Assert.Same(activeAlarm, hubContext.Sent[1]);
|
||||
Assert.Equal(88.0, hubContext.Sent[0].Transition.CurrentValue.DoubleValue);
|
||||
Assert.Equal(90.0, hubContext.Sent[0].Transition.LimitValue.DoubleValue);
|
||||
Assert.Equal(12.5, hubContext.Sent[1].ActiveAlarm.CurrentValue.DoubleValue);
|
||||
Assert.Equal(10.0, hubContext.Sent[1].ActiveAlarm.LimitValue.DoubleValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A payload arm that carries no value is forwarded as-is — the same
|
||||
/// instance, no clone. This is also the contract for arms added later: the
|
||||
/// switch names only the value-bearing arms, so a new arm passes through.
|
||||
/// </summary>
|
||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||
[Fact]
|
||||
public async Task ExecuteAsync_WithValuelessPayloads_ForwardsThemUntouched()
|
||||
{
|
||||
AlarmFeedMessage snapshotComplete = new() { SnapshotComplete = true };
|
||||
AlarmFeedMessage providerStatus = new()
|
||||
{
|
||||
ProviderStatus = new AlarmProviderStatus
|
||||
{
|
||||
Mode = AlarmProviderMode.Subtag,
|
||||
Degraded = true,
|
||||
Reason = "alarmmgr unavailable",
|
||||
},
|
||||
};
|
||||
|
||||
CapturingHubContext hubContext = await RunPublisherAsync(
|
||||
showTagValues: false,
|
||||
snapshotComplete,
|
||||
providerStatus);
|
||||
|
||||
Assert.Same(snapshotComplete, hubContext.Sent[0]);
|
||||
Assert.Same(providerStatus, hubContext.Sent[1]);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts the publisher over a scripted feed, waits until every scripted
|
||||
/// message has been broadcast, and stops it.
|
||||
/// </summary>
|
||||
/// <param name="showTagValues">The <c>Dashboard:ShowTagValues</c> value under test.</param>
|
||||
/// <param name="messages">The messages the fake alarm feed yields, in order.</param>
|
||||
/// <returns>The hub context holding everything the publisher sent.</returns>
|
||||
private static async Task<CapturingHubContext> RunPublisherAsync(
|
||||
bool showTagValues,
|
||||
params AlarmFeedMessage[] messages)
|
||||
{
|
||||
ScriptedAlarmService alarmService = new(messages);
|
||||
CapturingHubContext hubContext = new();
|
||||
GatewayOptions gatewayOptions = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
|
||||
};
|
||||
|
||||
AlarmsHubPublisher publisher = new(
|
||||
alarmService,
|
||||
hubContext,
|
||||
Options.Create(gatewayOptions),
|
||||
NullLogger<AlarmsHubPublisher>.Instance);
|
||||
|
||||
using CancellationTokenSource cts = new();
|
||||
await publisher.StartAsync(cts.Token).WaitAsync(TestTimeout);
|
||||
await WaitUntilAsync(() => hubContext.Sent.Count >= messages.Length);
|
||||
await cts.CancelAsync();
|
||||
await publisher.StopAsync(CancellationToken.None);
|
||||
|
||||
return hubContext;
|
||||
}
|
||||
|
||||
private static async Task WaitUntilAsync(Func<bool> predicate)
|
||||
{
|
||||
using CancellationTokenSource cancellationTokenSource = new(TestTimeout);
|
||||
while (!predicate())
|
||||
{
|
||||
await Task.Delay(TimeSpan.FromMilliseconds(5), cancellationTokenSource.Token);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Builds a value-bearing <c>transition</c> feed message.</summary>
|
||||
/// <returns>The message.</returns>
|
||||
private static AlarmFeedMessage BuildTransition()
|
||||
{
|
||||
return new AlarmFeedMessage
|
||||
{
|
||||
Transition = new OnAlarmTransitionEvent
|
||||
{
|
||||
AlarmFullReference = "Tank01.Level.HiHi",
|
||||
SourceObjectReference = "Tank01",
|
||||
AlarmTypeName = "AnalogLimitAlarm.HiHi",
|
||||
TransitionKind = AlarmTransitionKind.Raise,
|
||||
Severity = 800,
|
||||
Category = "Process",
|
||||
Description = "Level high-high",
|
||||
CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 88.0 },
|
||||
LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 90.0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>Builds a value-bearing <c>active_alarm</c> feed message.</summary>
|
||||
/// <returns>The message.</returns>
|
||||
private static AlarmFeedMessage BuildActiveAlarm()
|
||||
{
|
||||
return new AlarmFeedMessage
|
||||
{
|
||||
ActiveAlarm = new ActiveAlarmSnapshot
|
||||
{
|
||||
AlarmFullReference = "Tank02.Level.Lo",
|
||||
SourceObjectReference = "Tank02",
|
||||
AlarmTypeName = "AnalogLimitAlarm.Lo",
|
||||
CurrentState = AlarmConditionState.Active,
|
||||
Severity = 500,
|
||||
Category = "Process",
|
||||
Description = "Level low",
|
||||
CurrentValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 12.5 },
|
||||
LimitValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 10.0 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Yields a scripted message list once and then stays open until cancelled,
|
||||
/// so the publisher's reconnect loop never re-subscribes mid-test.
|
||||
/// </summary>
|
||||
/// <param name="messages">The messages to yield, in order.</param>
|
||||
private sealed class ScriptedAlarmService(IReadOnlyList<AlarmFeedMessage> messages) : IGatewayAlarmService
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public GatewayAlarmMonitorState State => GatewayAlarmMonitorState.Monitoring;
|
||||
|
||||
/// <inheritdoc />
|
||||
public string? LastError => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public int? WorkerProcessId => null;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms => [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SnapshotTruncated => false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||||
{
|
||||
foreach (AlarmFeedMessage message in messages)
|
||||
{
|
||||
yield return message;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<AcknowledgeAlarmReply> AcknowledgeAsync(
|
||||
AcknowledgeAlarmRequest request,
|
||||
CancellationToken cancellationToken) => throw new NotSupportedException();
|
||||
}
|
||||
|
||||
private sealed class CapturingHubContext : IHubContext<AlarmsHub>
|
||||
{
|
||||
private readonly CapturingHubClients _clients = new();
|
||||
|
||||
/// <summary>Gets the hub clients.</summary>
|
||||
public IHubClients Clients => _clients;
|
||||
|
||||
/// <summary>Gets the group manager.</summary>
|
||||
public IGroupManager Groups { get; } = new NoopGroupManager();
|
||||
|
||||
/// <summary>Gets every message the publisher broadcast, in order.</summary>
|
||||
public IReadOnlyList<AlarmFeedMessage> Sent => _clients.GroupProxy.Sent;
|
||||
}
|
||||
|
||||
private sealed class CapturingHubClients : IHubClients
|
||||
{
|
||||
/// <summary>Gets the capturing client proxy shared by this fake.</summary>
|
||||
public CapturingClientProxy GroupProxy { get; } = new();
|
||||
|
||||
public IClientProxy All => GroupProxy;
|
||||
|
||||
public IClientProxy AllExcept(IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
||||
|
||||
public IClientProxy Client(string connectionId) => GroupProxy;
|
||||
|
||||
public IClientProxy Clients(IReadOnlyList<string> connectionIds) => GroupProxy;
|
||||
|
||||
public IClientProxy Group(string groupName) => GroupProxy;
|
||||
|
||||
public IClientProxy GroupExcept(string groupName, IReadOnlyList<string> excludedConnectionIds) => GroupProxy;
|
||||
|
||||
public IClientProxy Groups(IReadOnlyList<string> groupNames) => GroupProxy;
|
||||
|
||||
public IClientProxy User(string userId) => GroupProxy;
|
||||
|
||||
public IClientProxy Users(IReadOnlyList<string> userIds) => GroupProxy;
|
||||
}
|
||||
|
||||
private sealed class CapturingClientProxy : IClientProxy
|
||||
{
|
||||
private readonly List<AlarmFeedMessage> _sent = [];
|
||||
|
||||
/// <summary>Gets every alarm message sent through this proxy, in order.</summary>
|
||||
public IReadOnlyList<AlarmFeedMessage> Sent
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (_sent)
|
||||
{
|
||||
return [.. _sent];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Records the broadcast message and completes synchronously.</summary>
|
||||
/// <param name="method">The SignalR method name.</param>
|
||||
/// <param name="args">The method arguments.</param>
|
||||
/// <param name="cancellationToken">Token to observe for cancellation.</param>
|
||||
/// <returns>A completed task.</returns>
|
||||
public Task SendCoreAsync(string method, object?[] args, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (args.Length > 0 && args[0] is AlarmFeedMessage message)
|
||||
{
|
||||
lock (_sent)
|
||||
{
|
||||
_sent.Add(message);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class NoopGroupManager : IGroupManager
|
||||
{
|
||||
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
|
||||
public Task RemoveFromGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
|
||||
=> Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
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.Sessions;
|
||||
using ZB.MOM.WW.MxGateway.Server.Workers;
|
||||
@@ -186,11 +188,68 @@ public sealed class DashboardLiveDataServiceTests
|
||||
Assert.Equal(filler[0], worker.SubscribedTags[^1]);
|
||||
}
|
||||
|
||||
private static DashboardLiveDataService CreateService(ISessionManager sessionManager)
|
||||
/// <summary>
|
||||
/// Verifies the <c>/browse</c> live-value seam honours
|
||||
/// <c>MxGateway:Dashboard:ShowTagValues</c> (TST-16): with the flag off — the
|
||||
/// default — the value text the page renders is the redaction placeholder,
|
||||
/// never the formatted tag value.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WhenShowTagValuesFalse_RedactsValueTextButKeepsMetadata()
|
||||
{
|
||||
RecordingWorkerClient worker = new()
|
||||
{
|
||||
ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
|
||||
};
|
||||
await using FakeSessionManager sessionManager = new(worker);
|
||||
await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: false);
|
||||
|
||||
DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None);
|
||||
|
||||
DashboardTagValue value = Assert.Single(result.Values);
|
||||
Assert.Equal(DashboardTagValue.RedactedValueText, value.ValueText);
|
||||
Assert.DoesNotContain("42.5", value.ValueText, StringComparison.Ordinal);
|
||||
|
||||
// Everything that is not the value still renders: the panel stays useful.
|
||||
Assert.Equal("Tank_001.PV", value.TagAddress);
|
||||
Assert.True(value.Ok);
|
||||
Assert.Equal("Double", value.DataType);
|
||||
Assert.Equal(192, value.Quality);
|
||||
Assert.True(value.QualityGood);
|
||||
Assert.Null(value.Error);
|
||||
}
|
||||
|
||||
/// <summary>Verifies the formatted value is served when the flag is on.</summary>
|
||||
[Fact]
|
||||
public async Task ReadAsync_WhenShowTagValuesTrue_ServesFormattedValue()
|
||||
{
|
||||
RecordingWorkerClient worker = new()
|
||||
{
|
||||
ReadValue = new MxValue { DataType = MxDataType.Double, DoubleValue = 42.5 },
|
||||
};
|
||||
await using FakeSessionManager sessionManager = new(worker);
|
||||
await using DashboardLiveDataService service = CreateService(sessionManager, showTagValues: true);
|
||||
|
||||
DashboardLiveReadResult result = await service.ReadAsync(["Tank_001.PV"], CancellationToken.None);
|
||||
|
||||
DashboardTagValue value = Assert.Single(result.Values);
|
||||
Assert.Equal("42.5", value.ValueText);
|
||||
Assert.Equal("Double", value.DataType);
|
||||
}
|
||||
|
||||
private static DashboardLiveDataService CreateService(
|
||||
ISessionManager sessionManager,
|
||||
bool showTagValues = false)
|
||||
{
|
||||
GatewayOptions gatewayOptions = new()
|
||||
{
|
||||
Dashboard = new DashboardOptions { ShowTagValues = showTagValues },
|
||||
};
|
||||
|
||||
return new DashboardLiveDataService(
|
||||
sessionManager,
|
||||
new FakeGatewayAlarmService(),
|
||||
Options.Create(gatewayOptions),
|
||||
NullLogger<DashboardLiveDataService>.Instance);
|
||||
}
|
||||
|
||||
@@ -322,6 +381,12 @@ public sealed class DashboardLiveDataServiceTests
|
||||
/// <summary>Gets or sets a value indicating whether unsubscribe commands throw.</summary>
|
||||
public bool FailUnsubscribe { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the value every bulk read returns. Null (the default) leaves
|
||||
/// the read results value-less, which is all the advise-set tests need.
|
||||
/// </summary>
|
||||
public MxValue? ReadValue { get; set; }
|
||||
|
||||
/// <summary>Gets the item handle bound for a previously subscribed tag.</summary>
|
||||
/// <param name="tagAddress">Tag address to look up.</param>
|
||||
/// <returns>The bound item handle.</returns>
|
||||
@@ -434,14 +499,21 @@ public sealed class DashboardLiveDataServiceTests
|
||||
BulkReadReply readReply = new();
|
||||
foreach (string tagAddress in tagAddresses)
|
||||
{
|
||||
readReply.Results.Add(new BulkReadResult
|
||||
BulkReadResult readResult = new()
|
||||
{
|
||||
ServerHandle = RegisteredServerHandle,
|
||||
TagAddress = tagAddress,
|
||||
ItemHandle = _itemHandles.TryGetValue(tagAddress, out int itemHandle) ? itemHandle : 0,
|
||||
WasSuccessful = true,
|
||||
Quality = 192,
|
||||
});
|
||||
};
|
||||
|
||||
if (ReadValue is not null)
|
||||
{
|
||||
readResult.Value = ReadValue.Clone();
|
||||
}
|
||||
|
||||
readReply.Results.Add(readResult);
|
||||
}
|
||||
|
||||
return readReply;
|
||||
|
||||
Reference in New Issue
Block a user