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
@@ -25,7 +25,7 @@
| TST-10 | Not started | **Confirmed open** | No `docs/Deployment.md` (`ls docs/` — no deploy doc); no deploy script under `scripts/` | Deploy knowledge still lives only in operator memory notes. |
| TST-14 | Not started | **Confirmed open** | Repo root still holds `MxAccessGateway-docs-{issues,fixed,final}.md`, `MxGatewayClient-docs-{issues,fixed}.md`, `oldtasks.md`, `stillpending.md` (root `ls`) | `oldtasks.md` did gain a legitimate new role as the epic-governance record (TST-04), so "delete when epic closes" needs re-deciding; the five untracked `*-docs-*.md` files remain pure clutter. |
| TST-15 | Not started (design done) | **Confirmed open** | `Server/Dashboard/Hubs/EventsHub.cs:42``TODO(per-session-acl)` still present | Design now exists: `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`. Mitigation shipped meanwhile: SEC-25 value redaction in the hub mirror (see TST-27). |
| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29``ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). |
| TST-16 | Not started | **Partially resolved — see TST-27** | `Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16,29``ShowTagValues` now gates value redaction of the SignalR mirror (SEC-25) | The flag is no longer fully dead, but it still does not gate `/browse` live-value display, and the config doc still calls it "Reserved" — fresh drift (TST-27). **Closed 2026-08-17** (branch `feat/followup-closeout`): both residuals shipped — `AlarmsHubPublisher` redacts `current_value`/`limit_value` from a deep clone on both value-bearing `AlarmFeedMessage` arms before the `/hubs/alarms` broadcast, and `DashboardLiveDataService` substitutes `[redacted]` for `/browse` value text at the service boundary (the recommended seam), so the flag now gates every dashboard value surface. Source never mutated on either hub, so gRPC subscribers are unaffected. Tests: `AlarmsHubPublisherTests`, two `DashboardLiveDataServiceTests` cases. TST-16 → `Done` in [../remediation/00-tracking.md](../remediation/00-tracking.md#change-log). |
| TST-17 | Not started | **Confirmed open** | `Worker/MxAccess/WnWrapAlarmConsumer.cs:~264-275``_ = ackOperatorDomain; _ = ackOperatorFullName;` then 6-arg call; no diagnostic surfaced in the ack reply | Silent drop unchanged (comment block explains the 55 stub, but callers still can't see the degrade). |
| TST-18 | Not started | **Confirmed open** | No `*HostedService*` test files under `Tests/Gateway/Sessions/` or `Tests/Gateway/Workers/` | Sweep cores remain covered indirectly (`SessionManagerTests.cs:985,1015` now also cover faulted-reap). |
| TST-19 | Not started | **Confirmed open** | `Tests/Gateway/Workers/Fakes/FakeWorkerHarness.cs` — no lockstep note / `WorkerPipeSession` pointer (grep empty); no README in `Fakes/` | Risk is higher now that the real-worker suite has no scheduled run (TST-25). |
@@ -53,7 +53,7 @@
### TST-27 — `ShowTagValues` config doc still says "Reserved" after SEC-25 made the flag live — **Medium** · documentation currency
- **Files:** `docs/GatewayConfiguration.md:185` ("Reserved display control for tag values"); `src/ZB.MOM.WW.MxGateway.Server/Dashboard/Hubs/DashboardEventBroadcaster.cs:16-29` (flag read and enforced); `docs/GatewayDashboardDesign.md:170` (current, documents the redaction)
- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.)
- **Description:** SEC-25 wired `Dashboard:ShowTagValues` into `DashboardEventBroadcaster`: when `false` (default), tag values are blanked from the deep-cloned event before it is mirrored to SignalR. The flag is therefore no longer dead — but the authoritative config reference still labels it "Reserved", so an operator consulting the options table concludes toggling it does nothing, when it actually controls whether tag values leak to every dashboard hub subscriber. This is remediation-created drift: the dashboard design doc was updated in the same change, the config doc was not. (Residual of TST-16 remains separately: the flag still gates nothing in the `/browse` live-value path.) **Closed 2026-08-17** — the config row was rewritten again when TST-16's residuals shipped: it now states the three seams the flag covers (events-hub mirror, alarms hub, `/browse`), so neither the "Reserved" wording nor the `/browse` carve-out survives. See the TST-16 row above.
- **Recommendation:** Update the `:185` row to describe the mirror-redaction behavior (and its security relevance given the missing per-session ACL, TST-15); note the `/browse` gap or close it when TST-16 is decided.
### TST-28 — Gateway-side `max_frame_bytes` handshake field has no test in the portable suite — **Low** · test coverage
+2 -1
View File
@@ -228,7 +228,7 @@ Full design + implementation for each row lives in the linked domain doc under i
| TST-13 | Medium | P2 | S | — | Done | gateway.md carries stale design-era sketches |
| TST-14 | Medium | P2 | S | — | Not started | Repo-root working artifacts need triage |
| TST-15 | Medium | P2 | M | TST-04 | Done | Dashboard EventsHub has no per-session ACL |
| TST-16 | Medium | | S | — | Not started | `Dashboard:ShowTagValues` is a dead flag |
| TST-16 | Medium | P1 | S | — | Done | `Dashboard:ShowTagValues` is a dead flag |
| TST-17 | Medium | — | S | — | Not started | Vendor-gated alarm parity residuals silently lossy |
| TST-18 | Low | — | S | — | Not started | Hosted-service wrappers untested |
| TST-19 | Low | — | S | — | Not started | Keep FakeWorkerHarness canned replies in lockstep |
@@ -253,6 +253,7 @@ Findings the review flagged as one coordinated design pass — sequence them tog
| Date | Change |
|---|---|
| 2026-08-17 | **TST-16 → `Done`: `Dashboard:ShowTagValues` now gates every dashboard value surface** (branch `feat/followup-closeout`). The flag was live on one seam only (the events-hub mirror, shipped with SEC-25); the two recorded residuals are closed, so it is no longer a partly-dead flag. **Alarms hub:** `AlarmsHubPublisher` took `IOptions<GatewayOptions>` and now clears `current_value`/`limit_value` from a **deep clone** of each `AlarmFeedMessage` on both value-bearing payload arms (`transition`, `active_alarm`) before the `/hubs/alarms` broadcast. Clone-only, matching `DashboardEventBroadcaster.RedactValues`: the source message fans out from one feed to gRPC `StreamAlarms` subscribers and the alarms page, none of which this dashboard-display flag governs. The redaction switches on the value-bearing arms and passes everything else through untouched and uncloned, so a payload arm added later (the `snapshot_status` arm landing alongside this change) is forwarded unbroken rather than silently dropped. **`/browse`:** the gate lives at the service boundary per TST-16's own recommendation — `DashboardLiveDataService` substitutes `DashboardTagValue.RedactedValueText` (`[redacted]`) for the formatted value text, so the value never enters the render tree and `BrowsePage.razor` needed no change; data type, quality, source timestamp, and read errors stay real, keeping the panel diagnostic. A visible marker, not a blank, so a suppressed value is distinguishable from a tag that read back empty. Tests: new `AlarmsHubPublisherTests` (4 — both arms redacted with metadata intact, source not mutated, `true` passes values through as the same instance, valueless arms forwarded as the same instance) and two `DashboardLiveDataServiceTests` cases (`false``[redacted]`, `true` → formatted); `DashboardEventBroadcasterTests` re-run as regression. Docs: `docs/GatewayConfiguration.md`'s `ShowTagValues` row rewritten from "gates only the events hub mirror, two still-open residuals" to the three seams it now covers, with the "no gRPC client is affected" scope statement; `docs/GatewayDashboardDesign.md` gained the redaction note on the `AlarmsHub` row and had its Browse-panel paragraph corrected (it claimed the panel "always shows values regardless of `Dashboard:ShowTagValues`"). |
| 2026-08-17 | **Alarm-snapshot truncation now has a structural degraded-status signal** (branch `feat/deferred-closeout`, commits `693a78d` + `b9fb0dd`). No review ID — this is branch work outside the 153-finding register, recorded here so the tracker is not silent on a shipped change to the alarm surface. Before it, a capped `GetXmlCurrentAlarms2` fetch suppressed absence-implies-Clear inference and said so only in a rate-limited worker stderr warning, so no client and no operator could tell a complete active set from a capped one. Two additive proto3 booleans carry the verdict out — `QueryActiveAlarmsReplyPayload.snapshot_truncated = 2` and `ActiveAlarmSnapshot.from_truncated_snapshot = 16` (per record, because `QueryActiveAlarms` is a bare `stream ActiveAlarmSnapshot` with no envelope; the reply payload states it too, since a prefix filter can leave zero records and a capped fetch with nothing to report still has to say so). Flow: `WnWrapAlarmConsumer``AlarmDispatcher` / `IAlarmCommandHandler``MxAccessCommandExecutor` reply → `GatewayAlarmMonitor``IGatewayAlarmService.SnapshotTruncated``AlarmsPage` banner. `b9fb0dd` then made the pairing structural after review: `IMxAccessAlarmConsumer` and `IAlarmCommandHandler` expose one accessor (`SnapshotActiveAlarms(out bool truncated)` / `QueryActive(..., out bool snapshotTruncated)`) satisfied from a single lock acquisition, so the snapshot and its verdict can no longer be read across a poll; the separate `LastSnapshotTruncated` property is gone from every layer. Detection is deliberately unchanged (`fetchedRecordCount >= maxAlarmsPerFetch`); switching to `ALARM_RECORDS/@COUNT` stays blocked on probe evidence (`docs/AlarmProbeFindings.md`). Not latched, and dropped with the cache generation by `ClearCache`. Additive gateway metadata about our fetch mechanics, not MXAccess behaviour — no synthesized event, so not a parity deviation. Docs: `gateway.md` alarm surface, `docs/DesignDecisions.md`. |
| 2026-08-17 | **TST-15 → `Done` (discharges the ACL half of SEC-25): per-session dashboard event ACL shipped** (branch `feat/deferred-closeout`, commits `693a78d` + `7ec0b35`). Implements `docs/plans/2026-07-10-dashboard-session-acl-tst15.md`, whose header is now `Implemented` with as-built notes in its §12. `IDashboardSessionAcl.CanViewSession` is the single decision **both** subscribe seams consult — `EventsHub.SubscribeSession` (denial is a `HubException`; the caller is neither joined to the group nor registered in `EventsHubViewerRegistry`, so the mirror stays off) and `SessionDetailsPage`'s in-process subscribe (inline denial, no subscription) — so neither path is the weaker one and the `TODO(per-session-acl)` is gone. Decision order, fail-closed on every branch: authenticated Administrator → allow (evaluated **before** the registry lookup, so Admin × unknown-session allows — pinned by a test because reordering the two checks is a plausible refactor); session not found → deny; untagged session → `Dashboard:UntaggedSessionVisibility` (`AdminOnly` default); else allow iff `session.Tags ∩ zb:dashboardtag` claims, ordinal-ignore-case. Session tags are inherited from the owning API key's `dashboard_tags` constraint (`apikey --dashboard-tags`, already in the `ApiKeyConstraints` JSON blob — no SQLite migration) and never from the client's wire request. Viewer grants come from `Dashboard:GroupToTag` over the user's LDAP groups, stamped at cookie login (`DashboardAuthenticator.CreatePrincipal`) and **re-resolved, not copied**, at hub-token mint (`HubTokenService.Issue`), so the 5-minute token lifetime bounds a stale grant. Anonymous localhost is an empty-grant Viewer; `Dashboard:DisableLogin` auto-login carries both roles and so takes the admin bypass unchanged. Tests: `DashboardSessionAclTests` (decision table, every branch asserted in its denying direction too), `EventsHubTests`, `DashboardAuthenticatorTests`, `HubTokenServiceTests`, a `GatewayOptionsTests` case proving `Dashboard:GroupToTag` keeps its ordinal-ignore-case lookup through configuration binding, and two `[LiveLdapFact]`s in `DashboardLdapLiveTests` that drive a real bind against the shared GLAuth (`gw-viewer``team-a` grant admits the `team-a` session and refuses the `team-b` one; `multi-role` bypasses on the sessions its own grant does not cover). The live pair needed **no GLAuth change** — the tag layer is config-side, keyed on the existing `GwAdmin`/`GwReader` groups (recorded in `glauth.md`). Docs: `docs/Sessions.md` (session-tag model), `gateway.md` + CLAUDE.md dashboard-auth paragraphs, `docs/GatewayDashboardDesign.md` (three passages that described the ACL as outstanding), `docs/GatewayConfiguration.md` (`ShowTagValues` row: redaction is now the second of two layers, not the only one), `docs/Authentication.md` (`--dashboard-tags` is the only *constraint* flag that splits on commas). |
| 2026-08-10 | **TST-25 acceptance Check 6 (forced-failure nightly issue) → Done.** The 2026-07-13 record wrote this check off as "abandoned to shared-runner congestion"; that was wrong on both counts. The 2026-07-13 probe *did* land (issue #125, `[CHECK6 PROBE]`, run 375), and since 2026-07-17 the `nightly-windev` `if: failure()` step has filed an issue on **every** red nightly — #126#139, all authored by the `gitea-actions` bot. Traced run 672 (schedule, main, red) line by line: main step fails → `exitcode '1': failure` → the `if: failure()` step runs → `POST /api/v1/repos/dohertj2/mxaccessgw/issues` with the built-in token masked to `***` → issue #139 created at the matching timestamp. Re-confirmed by a fresh forced-failure probe on the throwaway branch `test/tst25-check6-nightly-issue` (temporary `tst25-check6-probe.yml` reproducing the job shape with `exit 1` for the live step; run 677 → issue #140). Branch deleted, issues #125 and #140 closed with explanatory comments. **One real defect found and fixed** (`fix/tst25-nightly-issue-path`, not merged): `${{ github.server_url }}` is the runner-internal `http://gitea:3000`, so every filed issue's run link was unreachable from a browser. The API call must keep using it (the job container resolves `gitea` only on the docker network and has no LAN egress to the public origin), so the fix adds a `PUBLIC_SERVER_URL: https://gitea.dohertylan.com` job env used **only** for the browser-facing link in the issue body; the probe validated the fixed template (#140 carries a `https://gitea.dohertylan.com/...` link that returns 200). **Separately observed, not fixed:** the nightly has been red continuously since at least 2026-07-17 (run 672: `x86 Worker.Tests failed with exit code 1`, 1 failed / 398 passed / 11 skipped — the known `EventBurst_DrainLoopCoalescesFlushes` class of flake), and the step de-duplicates nothing, so 14 issues are open, seven of them (#132#138) for the identical SHA `47c0b64`. Worth a follow-up: fix the red nightly, and consider having the step reuse an open issue with the same title instead of filing a new one. |
+1 -1
View File
@@ -192,7 +192,7 @@ events (a "gap") and must re-snapshot; whatever is still retained is replayed.
| `MxGateway:Dashboard:SnapshotIntervalMilliseconds` | `1000` | Dashboard snapshot refresh interval used by the snapshot SignalR hub and the pages that subscribe to it. |
| `MxGateway:Dashboard:RecentFaultLimit` | `100` | Maximum number of fault summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:RecentSessionLimit` | `200` | Maximum number of session summaries projected into each dashboard snapshot. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard's SignalR events hub mirror. `false` (default): `DashboardEventBroadcaster` blanks tag values from a deep-cloned copy of each `MxEvent` before it reaches any hub subscriber — event metadata (tag reference, quality, status, timestamps) still renders; see `docs/GatewayDashboardDesign.md`'s `EventsHub` row for the mechanism. This is now the second of two independent layers, not the only one: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` therefore exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag. The flag gates only the SignalR events hub mirror — it does **not** cover the `/browse` live-value display, nor the alarms hub (`AlarmsHubPublisher` broadcasts alarm transitions with their `current_value`/`limit_value` fields unredacted); both remain separate, still-open residuals. |
| `MxGateway:Dashboard:ShowTagValues` | `false` | Controls whether tag values reach the dashboard at all. `false` (the default) suppresses them on all three seams that carry one: (1) the **events hub mirror**`DashboardEventBroadcaster` blanks `MxEvent.value` plus the alarm body's `current_value`/`limit_value` from a deep-cloned copy before it reaches any hub subscriber (see `docs/GatewayDashboardDesign.md`'s `EventsHub` row); (2) the **alarms hub**`AlarmsHubPublisher` clears `current_value`/`limit_value` from a deep-cloned copy of each `AlarmFeedMessage`, on both value-bearing payload arms (`transition` and `active_alarm`), before broadcasting to `/hubs/alarms`; (3) the **`/browse` live-value panel** — `DashboardLiveDataService` substitutes the literal `[redacted]` for each tag's value text. Both hub redactions clone: the source `MxEvent` is shared with the gRPC event stream and the replay ring, and the source `AlarmFeedMessage` fans out to gRPC `StreamAlarms` subscribers, so neither is mutated in place and **no gRPC client is affected by this flag** — it is a dashboard-display control only. Everything that is not the value survives on every seam: tag reference, alarm reference/severity/state/operator, data type, quality, status, and timestamps still render, so the dashboard stays diagnostic without disclosing process values. This is one of two independent layers: `IDashboardSessionAcl` decides *which* sessions a caller may subscribe to at all (see `GroupToTag` / `UntaggedSessionVisibility` below), while this flag decides what a permitted subscriber sees. Setting it `true` exposes tag values to everyone the ACL admits — every Administrator, plus each Viewer holding a matching tag — and, because the alarms hub is session-less, exposes alarm values to every dashboard client that can reach `/hubs/alarms`. |
| `MxGateway:Dashboard:GroupToRole` | _(empty)_ | LDAP group → dashboard role mapping. Keys are LDAP group names (short CN or full DN — leading-RDN match). Values must be `Administrator` (read/write, API-key CRUD) or `Viewer` (read-only) — matched ordinally by the startup validator, so the spelling is exact and `Admin` is rejected. A user whose LDAP groups don't intersect this map cannot sign in; with no mapping at all, only the loopback bypass admits anyone. |
| `MxGateway:Dashboard:GroupToTag` | _(empty)_ | LDAP group → dashboard visibility tags. Keys follow the same convention as `GroupToRole` (short CN or full DN — leading-RDN match, case-insensitive); values are tag lists. A dashboard user's granted tag set is the union over the groups they belong to; an unmapped group contributes nothing. **Visibility only:** tags scope which sessions' event streams a Viewer may observe on the dashboard — they never grant or deny data access, which stays with the API key's scopes and constraints. Independent of `GroupToRole`: a group may appear in either map, both, or neither. Empty (the default) means Viewers hold no tags, so under the default `UntaggedSessionVisibility` they observe no session's events. |
| `MxGateway:Dashboard:UntaggedSessionVisibility` | `AdminOnly` | Who may observe a session that carries no tags (its owning API key declared none). `AdminOnly` (default, fail-closed) restricts untagged sessions to dashboard Administrators. `AllViewers` shows them to every Viewer — opt-in for a single-tenant deployment that wants the pre-tag behaviour. Administrators always see every session regardless of tags. |
+10 -7
View File
@@ -273,7 +273,7 @@ bearer). Each hub class is `[Authorize(Policy = HubClientsPolicy)]`.
| Hub | Path | Producer | Payload | Routing |
|---|---|---|---|---|
| `DashboardSnapshotHub` | `/hubs/snapshot` | `DashboardSnapshotPublisher` (BackgroundService consuming `IDashboardSnapshotService.WatchSnapshotsAsync`) | `DashboardSnapshot` | Sent to all connected clients on every snapshot tick, but only while at least one client is connected (see "Idle gating" below); new connections receive the current snapshot synchronously in `OnConnectedAsync`. |
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. |
| `AlarmsHub` | `/hubs/alarms` | `AlarmsHubPublisher` (BackgroundService consuming `IGatewayAlarmService.StreamAsync(filter: null)`) | `AlarmFeedMessage` (`active_alarm` / `snapshot_complete` / `transition`) | Connected clients auto-join `__alarms__`; all clients receive every message. Publisher auto-reconnects every 5s on stream faults. The alarm value fields (`current_value` / `limit_value`) are stripped from a deep-cloned copy of the message when `Dashboard:ShowTagValues` is false (the default) — on both value-bearing payload arms, so neither a live transition nor a snapshot record leaks a process value; every other field still renders. The source message is never mutated: it fans out from the same feed to gRPC `StreamAlarms` subscribers, which this dashboard-display flag does not govern. Arms carrying no value are forwarded as-is, uncloned. |
| `EventsHub` | `/hubs/events` | `DashboardEventBroadcaster` invoked by each session's internal dashboard-mirror subscriber on its `SessionEventDistributor` (registered when the session becomes Ready) | `MxEvent` | Clients call `SubscribeSession(sessionId)` to join `session:{id}`, which also registers them in `EventsHubViewerRegistry` — the mirror is gated on that registry, which counts hub and in-process viewers alike (see "Mirror gating" below). The dashboard is a first-class distributor subscriber, so it receives the session's events whether or not a gRPC client is streaming. It sees RAW session events — not the per-gRPC-subscriber `AfterWorkerSequence` filtering that `EventStreamService` applies at its own boundary — because the dashboard is a separate LDAP-authenticated monitoring view meant to show the session's full event activity. Tag values are stripped from the mirrored `MxEvent` copy by `DashboardEventBroadcaster` when `Dashboard:ShowTagValues` is false (the default) — event metadata (tag reference, quality, status, timestamps) still renders, but the value fields are blanked, so no value leaks through this seam. `SubscribeSession` is gated by `IDashboardSessionAcl` (SEC-25 / TST-15): a denied caller gets a `HubException`, is not joined to the group, and is not registered as a viewer, so the mirror stays off for a session nobody is legitimately watching. The same ACL gates the in-process seam the session-details page uses, so neither path is the weaker one. Value redaction remains an independent layer — it bounds what a *permitted* subscriber sees. |
### Default cadences
@@ -517,12 +517,15 @@ tree to a flat list of matching attributes.
Right-clicking an attribute (or double-clicking it) adds it to the subscription
panel. The panel shows each subscribed tag's live value, MXAccess data type,
quality and source timestamp, refreshed every two seconds. The subscription
panel is the explicit opt-in tag-value surface: it always shows values
regardless of `Dashboard:ShowTagValues`, which governs the diagnostic
session/worker views and the per-session event mirror — both its hub and
in-process audiences (values are redacted from the mirrored events when the flag
is false).
quality and source timestamp, refreshed every two seconds — but the value column
obeys `Dashboard:ShowTagValues` like every other dashboard value surface. With
the flag false (the default) `DashboardLiveDataService` hands the page
`[redacted]` in place of the formatted value; data type, quality, source
timestamp and any read error are untouched, so the panel still answers "is this
tag advising and healthy" without disclosing the value. The substitution happens
at the service, not in the page: one decision point, and the value never enters
the render tree. Set the flag true to make the panel the explicit opt-in
tag-value surface it was originally designed as.
### Alarms page
@@ -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;