fix(dashboard): ShowTagValues now gates the alarms hub and /browse live values

This commit is contained in:
Joseph Doherty
2026-08-17 07:14:40 -04:00
parent 222b01f488
commit eff17d177c
9 changed files with 532 additions and 15 deletions
@@ -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;
}
}
}