feat(alarms): structural degraded-status signal for truncated alarm snapshots

The truncation-cliff fix made alarm transitions truncation-safe but silent:
when GetXmlCurrentAlarms2 returns exactly maxAlmCnt records the worker
suppresses absence-implies-Clear inference and says so only in a rate-limited
stderr warning. 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 (worker IPC reply)
- ActiveAlarmSnapshot.from_truncated_snapshot = 16 (per record)

The per-record field is not an aesthetic choice. QueryActiveAlarms returns a
bare `stream ActiveAlarmSnapshot` with no envelope, header, or trailer, so a
per-record boolean is the only carrier that stays wire-compatible; an envelope
message would change every existing client's stream element type. The reply
payload states it too because a prefix filter can leave zero records and a
truncated fetch with nothing to report still has to say so. The flag means
"this set may be incomplete", never "this record is unreliable" — it is
independent of the subtag-fallback `degraded` field.

Detection is deliberately UNCHANGED: IsTruncatedFetch remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe (docs/AlarmProbeFindings.md,
ce5d8ae) could not verify whether ALARM_RECORDS/@COUNT reports the total active
count or only the records in the reply, so @COUNT is not parsed for detection;
switching to it stays blocked on probe evidence. The probe's comment
annotations in WnWrapAlarmConsumer.cs are preserved.

Reset semantics: not latched. WnWrapAlarmConsumer.FoldFetch replaces the
verdict on every poll under the same lock as the snapshot merge, so the first
sub-cap fetch clears it; GatewayAlarmMonitor.ClearCache drops it with the cache
generation it describes. A caveat that never turns off is one operators learn
to ignore.

Flow: WnWrapAlarmConsumer.LastSnapshotTruncated -> AlarmDispatcher (stamps every
record) / IAlarmCommandHandler (payload) -> MxAccessCommandExecutor reply ->
GatewayAlarmMonitor._snapshotTruncated -> IGatewayAlarmService.SnapshotTruncated
-> DashboardAlarmQueryResult -> AlarmsPage warning banner (render-side only; the
poll loop and DisposeAsync drain are untouched). The public QueryActiveAlarms
RPC forwards worker snapshots unmodified, so the per-record flag needed no
mapper change — a test pins that.

Parity: this describes OUR fetch mechanics — additive gateway metadata — not
MXAccess provider behavior. No event is synthesized and no MXAccess-observable
semantics change, so it is not a parity deviation.

Tests: worker LastSnapshotTruncated set/reset/consecutive-burst (windev-run);
gateway end-to-end truncated reply -> monitor -> public stream, with the
complete-reply control as the load-bearing assertion; AlarmsPage banner
present/absent. Docs: gateway.md alarm surface, docs/DesignDecisions.md entry.
This commit is contained in:
Joseph Doherty
2026-08-17 04:18:34 -04:00
parent b8b7b69ba0
commit 693a78db7d
41 changed files with 2217 additions and 309 deletions
+42 -4
View File
@@ -199,12 +199,50 @@ Consequences, and how this sits with the existing failover/reconcile design:
goes to the worker's console/stderr, which is captured on dev hosts but is goes to the worker's console/stderr, which is captured on dev hosts but is
not a metric, not a dashboard tile, and not part of any session-status or not a metric, not a dashboard tile, and not part of any session-status or
alarm-feed payload, so a production deployment can truncate indefinitely alarm-feed payload, so a production deployment can truncate indefinitely
without anyone noticing. Surfacing truncation as a **structural** degraded without anyone noticing. The structural signal that fixes this landed
status (a field on the alarm-provider mode/status surface the dashboard and separately — see the next decision. A galaxy that truncates persistently
`StreamAlarms` consumers already read) is filed as a follow-up; until it
lands, the log line is the only signal. A galaxy that truncates persistently
is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`. is a configuration problem: raise `MxGateway:Alarms:MaxAlarmsPerFetch`.
### Alarms — truncation is reported per record on the public snapshot stream
Decision (2026-08-17): the truncated-fetch verdict above is carried to clients as
`QueryActiveAlarmsReplyPayload.snapshot_truncated` on the worker IPC reply and as
`ActiveAlarmSnapshot.from_truncated_snapshot` on **every record** of the public
`QueryActiveAlarms` stream, with a matching `IGatewayAlarmService.SnapshotTruncated`
driving a dashboard banner. Both fields are additive proto3 booleans.
A per-record boolean is an odd shape for what is set-level status, so the reason
matters: `rpc QueryActiveAlarms(QueryActiveAlarmsRequest) returns (stream
ActiveAlarmSnapshot)` returns a *bare* message stream. There is no envelope, no
header message, and no trailing summary to hang a set-level field off. Adding one
would mean either a new wrapper message (breaking every existing client's stream
element type) or a trailing metadata convention (invisible to clients that stop
reading early). Stamping the flag identically on each record is the only carrier
that is additive on the wire: clients that ignore the field deserialize exactly
as before. Consumers should read it as "the set this record belongs to may be
incomplete", never as a statement about the record's own fidelity — that is what
`degraded` / `source_provider` mean, and the two are independent. The reply
payload carries the flag as well because a prefix filter (or an empty galaxy) can
leave zero records, and a truncated fetch with nothing to report still has to say
so.
The **detection heuristic is unchanged**: `IsTruncatedFetch` remains
`fetchedRecordCount >= maxAlarmsPerFetch`. The live probe run for this work could
not verify whether `ALARM_RECORDS/@COUNT` reports the total active count or only
the records in the reply (`docs/AlarmProbeFindings.md`), and an exact-looking
signal derived from an unverified attribute is worse than an honest heuristic —
it would read as precise while being wrong in the one direction that matters.
Switching to `@COUNT` stays blocked on probe evidence.
The flag is **not latched**. It is replaced by each fetch's verdict, so the first
sub-cap fetch clears it, and `GatewayAlarmMonitor.ClearCache` drops it with the
cache generation it describes. A caveat that never turns off is a caveat
operators learn to ignore.
This is gateway metadata about **our** fetch mechanics, not a claim about MXAccess
behaviour, so it is not a parity deviation: no event is synthesized and no
MXAccess-observable semantics change.
## Session-Resilience Epic Scope ## Session-Resilience Epic Scope
Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired Decision (2026-07-09, archreview TST-04; migrated here 2026-08-07 from the retired
@@ -295,3 +295,57 @@ to defer heavy revocation.
- **Staleness bound.** A revoked tag grant takes effect within one token lifetime - **Staleness bound.** A revoked tag grant takes effect within one token lifetime
(≤5 min) for token-auth connections and immediately for a fresh cookie login. (≤5 min) for token-auth connections and immediately for a fresh cookie login.
``` ```
## 12. As-built notes (enforcement landed 2026-08)
### 12.1 §4's "no second seam" no longer held — the ACL gates two
This design was written against a dashboard whose only route to a session's event
feed was the SignalR hub, which is why §4 concludes "gating at join is sufficient —
there is no second seam to guard". The 2026-08 in-process feed refactor invalidated
that premise: server-rendered pages stopped opening a loopback SignalR connection to
`/hubs/events` and now read the mirror directly through
`IDashboardSessionEventSubscriber.Subscribe(sessionId)` (`DashboardEventBroadcaster`
implements both the publish and subscribe interfaces). `SessionDetailsPage` is that
second consumer, and it never touches `SubscribeSession`, so a hub-only gate would
have left the page as an ungated path to the same events.
The shipped enforcement therefore puts the *same* `IDashboardSessionAcl` decision in
front of both subscribe calls:
- `EventsHub.SubscribeSession` — denies with `HubException("Not authorized for this
session.")` before the group join **and** before the `EventsHubViewerRegistry`
registration, so a denied caller neither receives events nor turns the mirror on.
- `SessionDetailsPage` — resolves the circuit principal via
`AuthenticationStateProvider` and checks the ACL *before* `Subscribe(SessionId)`.
A denial creates no subscription, starts no pump, and registers no viewer; the
events panel renders "Not authorized for this session's events." in place of its
empty state. The gate wraps only whether the subscription is created — the page's
generation/`ReferenceEquals` guards, `MarkDisconnectedAsync`, and the
`DisposeAsync`/`DetachEventsAsync` coupling are untouched.
Both seams remain subscribe-time-only. Session tags are immutable for the session's
life (§3), so a joined group or a live in-process subscription cannot go stale, and
no per-event check is needed on either path.
### 12.2 Where the grant is stamped
`zb:dashboardtag` claims are added at both principal-construction sites:
`DashboardAuthenticator.CreatePrincipal` (cookie login, so a circuit carries its
grant without a token round-trip) and `HubTokenService.Issue` (hub bearer). Both
resolve the grant from the caller's `mxgateway:ldap_group` claims through
`DashboardGroupTagMapping` + `Dashboard:GroupToTag` rather than copying tag claims
already on the principal — re-resolving at mint is what makes the token's 5-minute
lifetime an actual staleness bound on a changed grant, as §4 claims. Tags are
stamped for Administrators too; they are simply moot, because the ACL's admin bypass
is checked first.
### 12.3 Deviations worth knowing
- `CanViewSession` takes a **nullable** `ClaimsPrincipal`. `HubCallerContext.User` is
nullable, and null denies — the fail-closed reading.
- The admin bypass additionally requires `Identity.IsAuthenticated`, matching
`DashboardSessionAdminService.CanManage`. A role claim on an unauthenticated
identity does not bypass.
- Tag *values* are never logged at either seam; only the identifiers and the
allow/deny outcome are observable.
+18
View File
@@ -240,6 +240,24 @@ monitoring (forced)") when subtag mode is the configured `Fallback:Mode=ForceSub
as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr, as a fault. Metrics: `mxgateway.alarms.provider_mode` gauge (1 = alarmmgr,
2 = subtag) and `mxgateway.alarms.provider_switches` counter. 2 = subtag) and `mxgateway.alarms.provider_switches` counter.
**Truncated-snapshot visibility:** `GetXmlCurrentAlarms2` caps its reply at
`MxGateway:Alarms:MaxAlarmsPerFetch` and offers no confirmed "more available"
flag, so a reply holding exactly the cap is treated as truncated. On such a
fetch `WnWrapAlarmConsumer` merges rather than replaces its retained snapshot,
which suppresses the absence-implies-Clear inference and keeps a capped poll
from broadcasting Clears for alarms it simply had no room to mention. That
suppression is reported structurally rather than only in a rate-limited worker
warning: the `QueryActiveAlarms` reply payload carries `snapshot_truncated`,
every `ActiveAlarmSnapshot` in it carries `from_truncated_snapshot`, and the
dashboard Alarms tab shows a warning banner while the flag is set. The flag
means "this active set may be incomplete", not "this record is unreliable" —
it is independent of the subtag-fallback `degraded` field above. It is not
latched: the first fetch that comes back under the cap is complete, restores
absence authority, and clears it. Detection remains the record-count heuristic;
the reply's `ALARM_RECORDS/@COUNT` attribute would make the test exact only if
it reported the total active count rather than the records in the reply, which
a live probe could not discriminate (see `docs/AlarmProbeFindings.md`).
Forced modes are available via `MxGateway:Alarms:Fallback:Mode`: Forced modes are available via `MxGateway:Alarms:Fallback:Mode`:
`ForceAlarmManager` disables failover; `ForceSubtag` forces the standby `ForceAlarmManager` disables failover; `ForceSubtag` forces the standby
on from startup; `Auto` (default) enables failover and failback. Watch-list on from startup; `Auto` (default) enables failover and failback. Watch-list
@@ -285,248 +285,249 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
"ASgJEhcKD214YWNjZXNzX3Byb2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNp", "ASgJEhcKD214YWNjZXNzX3Byb2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNp",
"ZBgEIAEoCSJAChBEcmFpbkV2ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIc", "ZBgEIAEoCSJAChBEcmFpbkV2ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIc",
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudCI1ChxBY2tub3dsZWRnZUFs", "Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudCI1ChxBY2tub3dsZWRnZUFs",
"YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUiXAodUXVl", "YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUieAodUXVl",
"cnlBY3RpdmVBbGFybXNSZXBseVBheWxvYWQSOwoJc25hcHNob3RzGAEgAygL", "cnlBY3RpdmVBbGFybXNSZXBseVBheWxvYWQSOwoJc25hcHNob3RzGAEgAygL",
"MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90Io8I", "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90EhoK",
"CgdNeEV2ZW50EjIKBmZhbWlseRgBIAEoDjIiLm14YWNjZXNzX2dhdGV3YXku", "EnNuYXBzaG90X3RydW5jYXRlZBgCIAEoCCKPCAoHTXhFdmVudBIyCgZmYW1p",
"djEuTXhFdmVudEZhbWlseRISCgpzZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZl", "bHkYASABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnRGYW1pbHkS",
"cl9oYW5kbGUYAyABKAUSEwoLaXRlbV9oYW5kbGUYBCABKAUSKwoFdmFsdWUY", "EgoKc2Vzc2lvbl9pZBgCIAEoCRIVCg1zZXJ2ZXJfaGFuZGxlGAMgASgFEhMK",
"BSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0", "C2l0ZW1faGFuZGxlGAQgASgFEisKBXZhbHVlGAUgASgLMhwubXhhY2Nlc3Nf",
"eRgGIAEoBRI0ChBzb3VyY2VfdGltZXN0YW1wGAcgASgLMhouZ29vZ2xlLnBy", "Z2F0ZXdheS52MS5NeFZhbHVlEg8KB3F1YWxpdHkYBiABKAUSNAoQc291cmNl",
"b3RvYnVmLlRpbWVzdGFtcBI0CghzdGF0dXNlcxgIIAMoCzIiLm14YWNjZXNz", "X3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAS",
"X2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eRIXCg93b3JrZXJfc2VxdWVuY2UY", "NAoIc3RhdHVzZXMYCCADKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3Rh",
"CSABKAQSNAoQd29ya2VyX3RpbWVzdGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90", "dHVzUHJveHkSFwoPd29ya2VyX3NlcXVlbmNlGAkgASgEEjQKEHdvcmtlcl90",
"b2J1Zi5UaW1lc3RhbXASPQoZZ2F0ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgL", "aW1lc3RhbXAYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEj0K",
"IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgM", "GWdhdGV3YXlfcmVjZWl2ZV90aW1lc3RhbXAYCyABKAsyGi5nb29nbGUucHJv",
"IAEoBUgBiAEBEhIKCnJhd19zdGF0dXMYDSABKAkSNwoKcmVwbGF5X2dhcBgO", "dG9idWYuVGltZXN0YW1wEhQKB2hyZXN1bHQYDCABKAVIAYgBARISCgpyYXdf",
"IAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuUmVwbGF5R2FwSAKIAQESQAoO", "c3RhdHVzGA0gASgJEjcKCnJlcGxheV9nYXAYDiABKAsyHi5teGFjY2Vzc19n",
"b25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLk9u", "YXRld2F5LnYxLlJlcGxheUdhcEgCiAEBEkAKDm9uX2RhdGFfY2hhbmdlGBQg",
"RGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29tcGxldGUYFSABKAsy", "ASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkRhdGFDaGFuZ2VFdmVudEgA",
"KS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21wbGV0ZUV2ZW50SAAS", "EkYKEW9uX3dyaXRlX2NvbXBsZXRlGBUgASgLMikubXhhY2Nlc3NfZ2F0ZXdh",
"SQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhhY2Nlc3NfZ2F0ZXdh", "eS52MS5PbldyaXRlQ29tcGxldGVFdmVudEgAEkkKEm9wZXJhdGlvbl9jb21w",
"eS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoXb25fYnVmZmVyZWRf", "bGV0ZRgWIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlcmF0aW9uQ29t",
"ZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRld2F5LnYxLk9uQnVm", "cGxldGVFdmVudEgAElEKF29uX2J1ZmZlcmVkX2RhdGFfY2hhbmdlGBcgASgL",
"ZmVyZWREYXRhQ2hhbmdlRXZlbnRIABJKChNvbl9hbGFybV90cmFuc2l0aW9u", "Mi4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkJ1ZmZlcmVkRGF0YUNoYW5nZUV2",
"GBggASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtVHJhbnNpdGlv", "ZW50SAASSgoTb25fYWxhcm1fdHJhbnNpdGlvbhgYIAEoCzIrLm14YWNjZXNz",
"bkV2ZW50SAASXgoeb25fYWxhcm1fcHJvdmlkZXJfbW9kZV9jaGFuZ2VkGBkg", "X2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEl4KHm9uX2Fs",
"ASgLMjQubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtUHJvdmlkZXJNb2Rl", "YXJtX3Byb3ZpZGVyX21vZGVfY2hhbmdlZBgZIAEoCzI0Lm14YWNjZXNzX2dh",
"Q2hhbmdlZEV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdEINCgtfcmVwbGF5", "dGV3YXkudjEuT25BbGFybVByb3ZpZGVyTW9kZUNoYW5nZWRFdmVudEgAQgYK",
"X2dhcCJQCglSZXBsYXlHYXASIAoYcmVxdWVzdGVkX2FmdGVyX3NlcXVlbmNl", "BGJvZHlCCgoIX2hyZXN1bHRCDQoLX3JlcGxheV9nYXAiUAoJUmVwbGF5R2Fw",
"GAEgASgEEiEKGW9sZGVzdF9hdmFpbGFibGVfc2VxdWVuY2UYAiABKAQiEwoR", "EiAKGHJlcXVlc3RlZF9hZnRlcl9zZXF1ZW5jZRgBIAEoBBIhChlvbGRlc3Rf",
"T25EYXRhQ2hhbmdlRXZlbnQiFgoUT25Xcml0ZUNvbXBsZXRlRXZlbnQiGAoW", "YXZhaWxhYmxlX3NlcXVlbmNlGAIgASgEIhMKEU9uRGF0YUNoYW5nZUV2ZW50",
"T3BlcmF0aW9uQ29tcGxldGVFdmVudCLUAQoZT25CdWZmZXJlZERhdGFDaGFu", "IhYKFE9uV3JpdGVDb21wbGV0ZUV2ZW50IhgKFk9wZXJhdGlvbkNvbXBsZXRl",
"Z2VFdmVudBIyCglkYXRhX3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5", "RXZlbnQi1AEKGU9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnQSMgoJZGF0YV90",
"LnYxLk14RGF0YVR5cGUSNAoOcXVhbGl0eV92YWx1ZXMYAiABKAsyHC5teGFj", "eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEjQK",
"Y2Vzc19nYXRld2F5LnYxLk14QXJyYXkSNgoQdGltZXN0YW1wX3ZhbHVlcxgD", "DnF1YWxpdHlfdmFsdWVzGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N",
"IAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRIVCg1yYXdfZGF0", "eEFycmF5EjYKEHRpbWVzdGFtcF92YWx1ZXMYAyABKAsyHC5teGFjY2Vzc19n",
"YV90eXBlGAQgASgFItAEChZPbkFsYXJtVHJhbnNpdGlvbkV2ZW50EhwKFGFs", "YXRld2F5LnYxLk14QXJyYXkSFQoNcmF3X2RhdGFfdHlwZRgEIAEoBSLQBAoW",
"YXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8KF3NvdXJjZV9vYmplY3RfcmVm", "T25BbGFybVRyYW5zaXRpb25FdmVudBIcChRhbGFybV9mdWxsX3JlZmVyZW5j",
"ZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVfbmFtZRgDIAEoCRJBCg90cmFu", "ZRgBIAEoCRIfChdzb3VyY2Vfb2JqZWN0X3JlZmVyZW5jZRgCIAEoCRIXCg9h",
"c2l0aW9uX2tpbmQYBCABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYxLkFsYXJt", "bGFybV90eXBlX25hbWUYAyABKAkSQQoPdHJhbnNpdGlvbl9raW5kGAQgASgO",
"VHJhbnNpdGlvbktpbmQSEAoIc2V2ZXJpdHkYBSABKAUSPAoYb3JpZ2luYWxf", "MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVRyYW5zaXRpb25LaW5kEhAK",
"cmFpc2VfdGltZXN0YW1wGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz", "CHNldmVyaXR5GAUgASgFEjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgG",
"dGFtcBI4ChR0cmFuc2l0aW9uX3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5w", "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOAoUdHJhbnNpdGlv",
"cm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3JfdXNlchgIIAEoCRIYChBv", "bl90aW1lc3RhbXAYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
"cGVyYXRvcl9jb21tZW50GAkgASgJEhAKCGNhdGVnb3J5GAogASgJEhMKC2Rl", "EhUKDW9wZXJhdG9yX3VzZXIYCCABKAkSGAoQb3BlcmF0b3JfY29tbWVudBgJ",
"c2NyaXB0aW9uGAsgASgJEjMKDWN1cnJlbnRfdmFsdWUYDCABKAsyHC5teGFj", "IAEoCRIQCghjYXRlZ29yeRgKIAEoCRITCgtkZXNjcmlwdGlvbhgLIAEoCRIz",
"Y2Vzc19nYXRld2F5LnYxLk14VmFsdWUSMQoLbGltaXRfdmFsdWUYDSABKAsy", "Cg1jdXJyZW50X3ZhbHVlGAwgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N",
"HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSEAoIZGVncmFkZWQYDiAB", "eFZhbHVlEjEKC2xpbWl0X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdh",
"KAgSPwoPc291cmNlX3Byb3ZpZGVyGA8gASgOMiYubXhhY2Nlc3NfZ2F0ZXdh", "eS52MS5NeFZhbHVlEhAKCGRlZ3JhZGVkGA4gASgIEj8KD3NvdXJjZV9wcm92",
"eS52MS5BbGFybVByb3ZpZGVyTW9kZSKgAQofT25BbGFybVByb3ZpZGVyTW9k", "aWRlchgPIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl",
"ZUNoYW5nZWRFdmVudBI0CgRtb2RlGAEgASgOMiYubXhhY2Nlc3NfZ2F0ZXdh", "ck1vZGUioAEKH09uQWxhcm1Qcm92aWRlck1vZGVDaGFuZ2VkRXZlbnQSNAoE",
"eS52MS5BbGFybVByb3ZpZGVyTW9kZRIOCgZyZWFzb24YAiABKAkSDwoHaHJl", "bW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl",
"c3VsdBgDIAEoBRImCgJhdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l", "ck1vZGUSDgoGcmVhc29uGAIgASgJEg8KB2hyZXN1bHQYAyABKAUSJgoCYXQY",
"c3RhbXAi0AQKE0FjdGl2ZUFsYXJtU25hcHNob3QSHAoUYWxhcm1fZnVsbF9y", "BCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvEEChNBY3RpdmVB",
"ZWZlcmVuY2UYASABKAkSHwoXc291cmNlX29iamVjdF9yZWZlcmVuY2UYAiAB", "bGFybVNuYXBzaG90EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8K",
"KAkSFwoPYWxhcm1fdHlwZV9uYW1lGAMgASgJEhAKCHNldmVyaXR5GAQgASgF", "F3NvdXJjZV9vYmplY3RfcmVmZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVf",
"EjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5w", "bmFtZRgDIAEoCRIQCghzZXZlcml0eRgEIAEoBRI8ChhvcmlnaW5hbF9yYWlz",
"cm90b2J1Zi5UaW1lc3RhbXASPwoNY3VycmVudF9zdGF0ZRgGIAEoDjIoLm14", "ZV90aW1lc3RhbXAYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
"YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Db25kaXRpb25TdGF0ZRIQCghjYXRl", "Ej8KDWN1cnJlbnRfc3RhdGUYBiABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYx",
"Z29yeRgHIAEoCRITCgtkZXNjcmlwdGlvbhgIIAEoCRI9ChlsYXN0X3RyYW5z", "LkFsYXJtQ29uZGl0aW9uU3RhdGUSEAoIY2F0ZWdvcnkYByABKAkSEwoLZGVz",
"aXRpb25fdGltZXN0YW1wGAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz", "Y3JpcHRpb24YCCABKAkSPQoZbGFzdF90cmFuc2l0aW9uX3RpbWVzdGFtcBgJ",
"dGFtcBIVCg1vcGVyYXRvcl91c2VyGAogASgJEhgKEG9wZXJhdG9yX2NvbW1l", "IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3Jf",
"bnQYCyABKAkSMwoNY3VycmVudF92YWx1ZRgMIAEoCzIcLm14YWNjZXNzX2dh", "dXNlchgKIAEoCRIYChBvcGVyYXRvcl9jb21tZW50GAsgASgJEjMKDWN1cnJl",
"dGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1pdF92YWx1ZRgNIAEoCzIcLm14YWNj", "bnRfdmFsdWUYDCABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUS",
"ZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIQCghkZWdyYWRlZBgOIAEoCBI/Cg9z", "MQoLbGltaXRfdmFsdWUYDSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14",
"b3VyY2VfcHJvdmlkZXIYDyABKA4yJi5teGFjY2Vzc19nYXRld2F5LnYxLkFs", "VmFsdWUSEAoIZGVncmFkZWQYDiABKAgSPwoPc291cmNlX3Byb3ZpZGVyGA8g",
"YXJtUHJvdmlkZXJNb2RlIpABChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBId", "ASgOMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVByb3ZpZGVyTW9kZRIf",
"ChVjbGllbnRfY29ycmVsYXRpb25faWQYAiABKAkSHAoUYWxhcm1fZnVsbF9y", "Chdmcm9tX3RydW5jYXRlZF9zbmFwc2hvdBgQIAEoCCKQAQoXQWNrbm93bGVk",
"ZWZlcmVuY2UYAyABKAkSDwoHY29tbWVudBgEIAEoCRIVCg1vcGVyYXRvcl91", "Z2VBbGFybVJlcXVlc3QSHQoVY2xpZW50X2NvcnJlbGF0aW9uX2lkGAIgASgJ",
"c2VyGAUgASgJSgQIARACUgpzZXNzaW9uX2lkIvEBChVBY2tub3dsZWRnZUFs", "EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAMgASgJEg8KB2NvbW1lbnQYBCAB",
"YXJtUmVwbHkSFgoOY29ycmVsYXRpb25faWQYAiABKAkSPAoPcHJvdG9jb2xf", "KAkSFQoNb3BlcmF0b3JfdXNlchgFIAEoCUoECAEQAlIKc2Vzc2lvbl9pZCLx",
"c3RhdHVzGAMgASgLMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0", "AQoVQWNrbm93bGVkZ2VBbGFybVJlcGx5EhYKDmNvcnJlbGF0aW9uX2lkGAIg",
"YXR1cxIUCgdocmVzdWx0GAQgASgFSACIAQESMgoGc3RhdHVzGAUgASgLMiIu", "ASgJEjwKD3Byb3RvY29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3",
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0", "YXkudjEuUHJvdG9jb2xTdGF0dXMSFAoHaHJlc3VsdBgEIAEoBUgAiAEBEjIK",
"aWNfbWVzc2FnZRgGIAEoCUIKCghfaHJlc3VsdEoECAEQAlIKc2Vzc2lvbl9p", "BnN0YXR1cxgFIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ",
"ZCJRChNTdHJlYW1BbGFybXNSZXF1ZXN0Eh0KFWNsaWVudF9jb3JyZWxhdGlv", "cm94eRIaChJkaWFnbm9zdGljX21lc3NhZ2UYBiABKAlCCgoIX2hyZXN1bHRK",
"bl9pZBgBIAEoCRIbChNhbGFybV9maWx0ZXJfcHJlZml4GAIgASgJIoQCChBB", "BAgBEAJSCnNlc3Npb25faWQiUQoTU3RyZWFtQWxhcm1zUmVxdWVzdBIdChVj",
"bGFybUZlZWRNZXNzYWdlEkAKDGFjdGl2ZV9hbGFybRgBIAEoCzIoLm14YWNj", "bGllbnRfY29ycmVsYXRpb25faWQYASABKAkSGwoTYWxhcm1fZmlsdGVyX3By",
"ZXNzX2dhdGV3YXkudjEuQWN0aXZlQWxhcm1TbmFwc2hvdEgAEhsKEXNuYXBz", "ZWZpeBgCIAEoCSKEAgoQQWxhcm1GZWVkTWVzc2FnZRJACgxhY3RpdmVfYWxh",
"aG90X2NvbXBsZXRlGAIgASgISAASQQoKdHJhbnNpdGlvbhgDIAEoCzIrLm14", "cm0YASABKAsyKC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25h",
"YWNjZXNzX2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEkMK", "cHNob3RIABIbChFzbmFwc2hvdF9jb21wbGV0ZRgCIAEoCEgAEkEKCnRyYW5z",
"D3Byb3ZpZGVyX3N0YXR1cxgEIAEoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEu", "aXRpb24YAyABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLk9uQWxhcm1UcmFu",
"QWxhcm1Qcm92aWRlclN0YXR1c0gAQgkKB3BheWxvYWQimAEKE0FsYXJtUHJv", "c2l0aW9uRXZlbnRIABJDCg9wcm92aWRlcl9zdGF0dXMYBCABKAsyKC5teGFj",
"dmlkZXJTdGF0dXMSNAoEbW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXku", "Y2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJTdGF0dXNIAEIJCgdwYXls",
"djEuQWxhcm1Qcm92aWRlck1vZGUSEAoIZGVncmFkZWQYAiABKAgSDgoGcmVh", "b2FkIpgBChNBbGFybVByb3ZpZGVyU3RhdHVzEjQKBG1vZGUYASABKA4yJi5t",
"c29uGAMgASgJEikKBXNpbmNlGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp", "eGFjY2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlEhAKCGRlZ3Jh",
"bWVzdGFtcCLrAQoNTXhTdGF0dXNQcm94eRIPCgdzdWNjZXNzGAEgASgFEjcK", "ZGVkGAIgASgIEg4KBnJlYXNvbhgDIAEoCRIpCgVzaW5jZRgEIAEoCzIaLmdv",
"CGNhdGVnb3J5GAIgASgOMiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1", "b2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi6wEKDU14U3RhdHVzUHJveHkSDwoH",
"c0NhdGVnb3J5EjgKC2RldGVjdGVkX2J5GAMgASgOMiMubXhhY2Nlc3NfZ2F0", "c3VjY2VzcxgBIAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dh",
"ZXdheS52MS5NeFN0YXR1c1NvdXJjZRIOCgZkZXRhaWwYBCABKAUSFAoMcmF3", "dGV3YXkudjEuTXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEo",
"X2NhdGVnb3J5GAUgASgFEhcKD3Jhd19kZXRlY3RlZF9ieRgGIAEoBRIXCg9k", "DjIjLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0",
"aWFnbm9zdGljX3RleHQYByABKAki6QMKB014VmFsdWUSMgoJZGF0YV90eXBl", "YWlsGAQgASgFEhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0",
"GAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZh", "ZWRfYnkYBiABKAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIukDCgdNeFZh",
"cmlhbnRfdHlwZRgCIAEoCRIPCgdpc19udWxsGAMgASgIEhYKDnJhd19kaWFn", "bHVlEjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEu",
"bm9zdGljGAQgASgJEhUKDXJhd19kYXRhX3R5cGUYBSABKAUSFAoKYm9vbF92", "TXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgD",
"YWx1ZRgKIAEoCEgAEhUKC2ludDMyX3ZhbHVlGAsgASgFSAASFQoLaW50NjRf", "IAEoCBIWCg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBl",
"dmFsdWUYDCABKANIABIVCgtmbG9hdF92YWx1ZRgNIAEoAkgAEhYKDGRvdWJs", "GAUgASgFEhQKCmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgL",
"ZV92YWx1ZRgOIAEoAUgAEhYKDHN0cmluZ192YWx1ZRgPIAEoCUgAEjUKD3Rp", "IAEoBUgAEhUKC2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUY",
"bWVzdGFtcF92YWx1ZRgQIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh", "DSABKAJIABIWCgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFs",
"bXBIABIzCgthcnJheV92YWx1ZRgRIAEoCzIcLm14YWNjZXNzX2dhdGV3YXku", "dWUYDyABKAlIABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUu",
"djEuTXhBcnJheUgAEhMKCXJhd192YWx1ZRgSIAEoDEgAEkAKEnNwYXJzZV9h", "cHJvdG9idWYuVGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5t",
"cnJheV92YWx1ZRgTIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTcGFy", "eGFjY2Vzc19nYXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiAB",
"c2VBcnJheUgAQgYKBGtpbmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRh", "KAxIABJAChJzcGFyc2VfYXJyYXlfdmFsdWUYEyABKAsyIi5teGFjY2Vzc19n",
"X3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUS", "YXRld2F5LnYxLk14U3BhcnNlQXJyYXlIAEIGCgRraW5kIv4ECgdNeEFycmF5",
"FAoMdmFyaWFudF90eXBlGAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoO", "EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdh",
"cmF3X2RpYWdub3N0aWMYBCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBl", "eS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRISCgpkaW1l",
"GAUgASgFEjUKC2Jvb2xfdmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdh", "bnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEh0KFXJhd19l",
"eS52MS5Cb29sQXJyYXlIABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFj", "bGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29sX3ZhbHVlcxgKIAEoCzIe",
"Y2Vzc19nYXRld2F5LnYxLkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMY", "Lm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5SAASNwoMaW50MzJfdmFs",
"DCABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3Cgxm", "dWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQzMkFycmF5SAAS",
"bG9hdF92YWx1ZXMYDSABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0", "NwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5J",
"QXJyYXlIABI5Cg1kb3VibGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0", "bnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0gASgLMh8ubXhhY2Nlc3Nf",
"ZXdheS52MS5Eb3VibGVBcnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsy", "Z2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91YmxlX3ZhbHVlcxgOIAEo",
"IC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0", "CzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91YmxlQXJyYXlIABI5Cg1zdHJp",
"YW1wX3ZhbHVlcxgQIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0", "bmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJpbmdB",
"YW1wQXJyYXlIABIzCgpyYXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0", "cnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECABKAsyIy5teGFjY2Vzc19n",
"ZXdheS52MS5SYXdBcnJheUgAQggKBnZhbHVlcyKZAQoNTXhTcGFyc2VBcnJh", "YXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoKcmF3X3ZhbHVlcxgRIAEo",
"eRI6ChFlbGVtZW50X2RhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3", "CzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJyYXlIAEIICgZ2YWx1ZXMi",
"YXkudjEuTXhEYXRhVHlwZRIUCgx0b3RhbF9sZW5ndGgYAiABKA0SNgoIZWxl", "mQEKDU14U3BhcnNlQXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5cGUYASABKA4y",
"bWVudHMYAyADKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLk14U3BhcnNlRWxl", "Hy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoMdG90YWxfbGVu",
"bWVudCJNCg9NeFNwYXJzZUVsZW1lbnQSDQoFaW5kZXgYASABKA0SKwoFdmFs", "Z3RoGAIgASgNEjYKCGVsZW1lbnRzGAMgAygLMiQubXhhY2Nlc3NfZ2F0ZXdh",
"dWUYAiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiGwoJQm9v", "eS52MS5NeFNwYXJzZUVsZW1lbnQiTQoPTXhTcGFyc2VFbGVtZW50Eg0KBWlu",
"bEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVl", "ZGV4GAEgASgNEisKBXZhbHVlGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52",
"cxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9h", "MS5NeFZhbHVlIhsKCUJvb2xBcnJheRIOCgZ2YWx1ZXMYASADKAgiHAoKSW50",
"dEFycmF5Eg4KBnZhbHVlcxgBIAMoAiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1", "MzJBcnJheRIOCgZ2YWx1ZXMYASADKAUiHAoKSW50NjRBcnJheRIOCgZ2YWx1",
"ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkSDgoGdmFsdWVzGAEgAygJIjwKDlRp", "ZXMYASADKAMiHAoKRmxvYXRBcnJheRIOCgZ2YWx1ZXMYASADKAIiHQoLRG91",
"bWVzdGFtcEFycmF5EioKBnZhbHVlcxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1", "YmxlQXJyYXkSDgoGdmFsdWVzGAEgAygBIh0KC1N0cmluZ0FycmF5Eg4KBnZh",
"Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkSDgoGdmFsdWVzGAEgAygMIlgKDlBy", "bHVlcxgBIAMoCSI8Cg5UaW1lc3RhbXBBcnJheRIqCgZ2YWx1ZXMYASADKAsy",
"b3RvY29sU3RhdHVzEjUKBGNvZGUYASABKA4yJy5teGFjY2Vzc19nYXRld2F5", "Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIhoKCFJhd0FycmF5Eg4KBnZh",
"LnYxLlByb3RvY29sU3RhdHVzQ29kZRIPCgdtZXNzYWdlGAIgASgJKp8LCg1N", "bHVlcxgBIAMoDCJYCg5Qcm90b2NvbFN0YXR1cxI1CgRjb2RlGAEgASgOMicu",
"eENvbW1hbmRLaW5kEh8KG01YX0NPTU1BTkRfS0lORF9VTlNQRUNJRklFRBAA", "bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1c0NvZGUSDwoHbWVz",
"EhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJU1RFUhABEh4KGk1YX0NPTU1BTkRf", "c2FnZRgCIAEoCSqfCwoNTXhDb21tYW5kS2luZBIfChtNWF9DT01NQU5EX0tJ",
"S0lORF9VTlJFR0lTVEVSEAISHAoYTVhfQ09NTUFORF9LSU5EX0FERF9JVEVN", "TkRfVU5TUEVDSUZJRUQQABIcChhNWF9DT01NQU5EX0tJTkRfUkVHSVNURVIQ",
"EAMSHQoZTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNMhAEEh8KG01YX0NPTU1B", "ARIeChpNWF9DT01NQU5EX0tJTkRfVU5SRUdJU1RFUhACEhwKGE1YX0NPTU1B",
"TkRfS0lORF9SRU1PVkVfSVRFTRAFEhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJ", "TkRfS0lORF9BRERfSVRFTRADEh0KGU1YX0NPTU1BTkRfS0lORF9BRERfSVRF",
"U0UQBhIdChlNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFEAcSJgoiTVhfQ09N", "TTIQBBIfChtNWF9DT01NQU5EX0tJTkRfUkVNT1ZFX0lURU0QBRIaChZNWF9D",
"TUFORF9LSU5EX0FEVklTRV9TVVBFUlZJU09SWRAIEiUKIU1YX0NPTU1BTkRf", "T01NQU5EX0tJTkRfQURWSVNFEAYSHQoZTVhfQ09NTUFORF9LSU5EX1VOX0FE",
"S0lORF9BRERfQlVGRkVSRURfSVRFTRAJEjAKLE1YX0NPTU1BTkRfS0lORF9T", "VklTRRAHEiYKIk1YX0NPTU1BTkRfS0lORF9BRFZJU0VfU1VQRVJWSVNPUlkQ",
"RVRfQlVGRkVSRURfVVBEQVRFX0lOVEVSVkFMEAoSGwoXTVhfQ09NTUFORF9L", "CBIlCiFNWF9DT01NQU5EX0tJTkRfQUREX0JVRkZFUkVEX0lURU0QCRIwCixN",
"SU5EX1NVU1BFTkQQCxIcChhNWF9DT01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZ", "WF9DT01NQU5EX0tJTkRfU0VUX0JVRkZFUkVEX1VQREFURV9JTlRFUlZBTBAK",
"ChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQDRIaChZNWF9DT01NQU5EX0tJTkRf", "EhsKF01YX0NPTU1BTkRfS0lORF9TVVNQRU5EEAsSHAoYTVhfQ09NTUFORF9L",
"V1JJVEUyEA4SIQodTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIi", "SU5EX0FDVElWQVRFEAwSGQoVTVhfQ09NTUFORF9LSU5EX1dSSVRFEA0SGgoW",
"Ch5NWF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDIQEBIlCiFNWF9DT01N", "TVhfQ09NTUFORF9LSU5EX1dSSVRFMhAOEiEKHU1YX0NPTU1BTkRfS0lORF9X",
"QU5EX0tJTkRfQVVUSEVOVElDQVRFX1VTRVIQERIoCiRNWF9DT01NQU5EX0tJ", "UklURV9TRUNVUkVEEA8SIgoeTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VS",
"TkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQEhIhCh1NWF9DT01NQU5EX0tJTkRf", "RUQyEBASJQohTVhfQ09NTUFORF9LSU5EX0FVVEhFTlRJQ0FURV9VU0VSEBES",
"QUREX0lURU1fQlVMSxATEiQKIE1YX0NPTU1BTkRfS0lORF9BRFZJU0VfSVRF", "KAokTVhfQ09NTUFORF9LSU5EX0FSQ0hFU1RSQV9VU0VSX1RPX0lEEBISIQod",
"TV9CVUxLEBQSJAogTVhfQ09NTUFORF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQ", "TVhfQ09NTUFORF9LSU5EX0FERF9JVEVNX0JVTEsQExIkCiBNWF9DT01NQU5E",
"FRInCiNNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFX0lURU1fQlVMSxAWEiIK", "X0tJTkRfQURWSVNFX0lURU1fQlVMSxAUEiQKIE1YX0NPTU1BTkRfS0lORF9S",
"Hk1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1B", "RU1PVkVfSVRFTV9CVUxLEBUSJwojTVhfQ09NTUFORF9LSU5EX1VOX0FEVklT",
"TkRfS0lORF9VTlNVQlNDUklCRV9CVUxLEBgSJAogTVhfQ09NTUFORF9LSU5E", "RV9JVEVNX0JVTEsQFhIiCh5NWF9DT01NQU5EX0tJTkRfU1VCU0NSSUJFX0JV",
"X1NVQlNDUklCRV9BTEFSTVMQGRImCiJNWF9DT01NQU5EX0tJTkRfVU5TVUJT", "TEsQFxIkCiBNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJQkVfQlVMSxAYEiQK",
"Q1JJQkVfQUxBUk1TEBoSJQohTVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdF", "IE1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQUxBUk1TEBkSJgoiTVhfQ09N",
"X0FMQVJNEBsSJwojTVhfQ09NTUFORF9LSU5EX1FVRVJZX0FDVElWRV9BTEFS", "TUFORF9LSU5EX1VOU1VCU0NSSUJFX0FMQVJNUxAaEiUKIU1YX0NPTU1BTkRf",
"TVMQHBItCilNWF9DT01NQU5EX0tJTkRfQUNLTk9XTEVER0VfQUxBUk1fQllf", "S0lORF9BQ0tOT1dMRURHRV9BTEFSTRAbEicKI01YX0NPTU1BTkRfS0lORF9R",
"TkFNRRAdEh4KGk1YX0NPTU1BTkRfS0lORF9XUklURV9CVUxLEB4SHwobTVhf", "VUVSWV9BQ1RJVkVfQUxBUk1TEBwSLQopTVhfQ09NTUFORF9LSU5EX0FDS05P",
"Q09NTUFORF9LSU5EX1dSSVRFMl9CVUxLEB8SJgoiTVhfQ09NTUFORF9LSU5E", "V0xFREdFX0FMQVJNX0JZX05BTUUQHRIeChpNWF9DT01NQU5EX0tJTkRfV1JJ",
"X1dSSVRFX1NFQ1VSRURfQlVMSxAgEicKI01YX0NPTU1BTkRfS0lORF9XUklU", "VEVfQlVMSxAeEh8KG01YX0NPTU1BTkRfS0lORF9XUklURTJfQlVMSxAfEiYK",
"RV9TRUNVUkVEMl9CVUxLECESHQoZTVhfQ09NTUFORF9LSU5EX1JFQURfQlVM", "Ik1YX0NPTU1BTkRfS0lORF9XUklURV9TRUNVUkVEX0JVTEsQIBInCiNNWF9D",
"SxAiEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9L", "T01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDJfQlVMSxAhEh0KGU1YX0NPTU1B",
"SU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dF", "TkRfS0lORF9SRUFEX0JVTEsQIhIYChRNWF9DT01NQU5EX0tJTkRfUElORxBk",
"VF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVO", "EiUKIU1YX0NPTU1BTkRfS0lORF9HRVRfU0VTU0lPTl9TVEFURRBlEiMKH01Y",
"VFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09SS0VSEGgqegoR", "X0NPTU1BTkRfS0lORF9HRVRfV09SS0VSX0lORk8QZhIgChxNWF9DT01NQU5E",
"QWxhcm1Qcm92aWRlck1vZGUSIwofQUxBUk1fUFJPVklERVJfTU9ERV9VTlNQ", "X0tJTkRfRFJBSU5fRVZFTlRTEGcSIwofTVhfQ09NTUFORF9LSU5EX1NIVVRE",
"RUNJRklFRBAAEiAKHEFMQVJNX1BST1ZJREVSX01PREVfQUxBUk1NR1IQARIe", "T1dOX1dPUktFUhBoKnoKEUFsYXJtUHJvdmlkZXJNb2RlEiMKH0FMQVJNX1BS",
"ChpBTEFSTV9QUk9WSURFUl9NT0RFX1NVQlRBRxACKq0CCg1NeEV2ZW50RmFt", "T1ZJREVSX01PREVfVU5TUEVDSUZJRUQQABIgChxBTEFSTV9QUk9WSURFUl9N",
"aWx5Eh8KG01YX0VWRU5UX0ZBTUlMWV9VTlNQRUNJRklFRBAAEiIKHk1YX0VW", "T0RFX0FMQVJNTUdSEAESHgoaQUxBUk1fUFJPVklERVJfTU9ERV9TVUJUQUcQ",
"RU5UX0ZBTUlMWV9PTl9EQVRBX0NIQU5HRRABEiUKIU1YX0VWRU5UX0ZBTUlM", "AiqtAgoNTXhFdmVudEZhbWlseRIfChtNWF9FVkVOVF9GQU1JTFlfVU5TUEVD",
"WV9PTl9XUklURV9DT01QTEVURRACEiYKIk1YX0VWRU5UX0ZBTUlMWV9PUEVS", "SUZJRUQQABIiCh5NWF9FVkVOVF9GQU1JTFlfT05fREFUQV9DSEFOR0UQARIl",
"QVRJT05fQ09NUExFVEUQAxIrCidNWF9FVkVOVF9GQU1JTFlfT05fQlVGRkVS", "CiFNWF9FVkVOVF9GQU1JTFlfT05fV1JJVEVfQ09NUExFVEUQAhImCiJNWF9F",
"RURfREFUQV9DSEFOR0UQBBInCiNNWF9FVkVOVF9GQU1JTFlfT05fQUxBUk1f", "VkVOVF9GQU1JTFlfT1BFUkFUSU9OX0NPTVBMRVRFEAMSKwonTVhfRVZFTlRf",
"VFJBTlNJVElPThAFEjIKLk1YX0VWRU5UX0ZBTUlMWV9PTl9BTEFSTV9QUk9W", "RkFNSUxZX09OX0JVRkZFUkVEX0RBVEFfQ0hBTkdFEAQSJwojTVhfRVZFTlRf",
"SURFUl9NT0RFX0NIQU5HRUQQBirKAQoTQWxhcm1UcmFuc2l0aW9uS2luZBIl", "RkFNSUxZX09OX0FMQVJNX1RSQU5TSVRJT04QBRIyCi5NWF9FVkVOVF9GQU1J",
"CiFBTEFSTV9UUkFOU0lUSU9OX0tJTkRfVU5TUEVDSUZJRUQQABIfChtBTEFS", "TFlfT05fQUxBUk1fUFJPVklERVJfTU9ERV9DSEFOR0VEEAYqygEKE0FsYXJt",
"TV9UUkFOU0lUSU9OX0tJTkRfUkFJU0UQARIlCiFBTEFSTV9UUkFOU0lUSU9O", "VHJhbnNpdGlvbktpbmQSJQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX1VOU1BF",
"X0tJTkRfQUNLTk9XTEVER0UQAhIfChtBTEFSTV9UUkFOU0lUSU9OX0tJTkRf", "Q0lGSUVEEAASHwobQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JBSVNFEAESJQoh",
"Q0xFQVIQAxIjCh9BTEFSTV9UUkFOU0lUSU9OX0tJTkRfUkVUUklHR0VSEAQq", "QUxBUk1fVFJBTlNJVElPTl9LSU5EX0FDS05PV0xFREdFEAISHwobQUxBUk1f",
"qgEKE0FsYXJtQ29uZGl0aW9uU3RhdGUSJQohQUxBUk1fQ09ORElUSU9OX1NU", "VFJBTlNJVElPTl9LSU5EX0NMRUFSEAMSIwofQUxBUk1fVFJBTlNJVElPTl9L",
"QVRFX1VOU1BFQ0lGSUVEEAASIAocQUxBUk1fQ09ORElUSU9OX1NUQVRFX0FD", "SU5EX1JFVFJJR0dFUhAEKqoBChNBbGFybUNvbmRpdGlvblN0YXRlEiUKIUFM",
"VElWRRABEiYKIkFMQVJNX0NPTkRJVElPTl9TVEFURV9BQ1RJVkVfQUNLRUQQ", "QVJNX0NPTkRJVElPTl9TVEFURV9VTlNQRUNJRklFRBAAEiAKHEFMQVJNX0NP",
"AhIiCh5BTEFSTV9DT05ESVRJT05fU1RBVEVfSU5BQ1RJVkUQAyqlAwoQTXhT", "TkRJVElPTl9TVEFURV9BQ1RJVkUQARImCiJBTEFSTV9DT05ESVRJT05fU1RB",
"dGF0dXNDYXRlZ29yeRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJ", "VEVfQUNUSVZFX0FDS0VEEAISIgoeQUxBUk1fQ09ORElUSU9OX1NUQVRFX0lO",
"RUQQABIeChpNWF9TVEFUVVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NU", "QUNUSVZFEAMqpQMKEE14U3RhdHVzQ2F0ZWdvcnkSIgoeTVhfU1RBVFVTX0NB",
"QVRVU19DQVRFR09SWV9PSxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5E", "VEVHT1JZX1VOU1BFQ0lGSUVEEAASHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1VO",
"SU5HEAMSHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9T", "S05PV04QARIZChVNWF9TVEFUVVNfQ0FURUdPUllfT0sQAhIeChpNWF9TVEFU",
"VEFUVVNfQ0FURUdPUllfQ09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NU", "VVNfQ0FURUdPUllfUEVORElORxADEh4KGk1YX1NUQVRVU19DQVRFR09SWV9X",
"QVRVU19DQVRFR09SWV9DT05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RB", "QVJOSU5HEAQSKgomTVhfU1RBVFVTX0NBVEVHT1JZX0NPTU1VTklDQVRJT05f",
"VFVTX0NBVEVHT1JZX09QRVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVT", "RVJST1IQBRIqCiZNWF9TVEFUVVNfQ0FURUdPUllfQ09ORklHVVJBVElPTl9F",
"X0NBVEVHT1JZX1NFQ1VSSVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVH", "UlJPUhAGEigKJE1YX1NUQVRVU19DQVRFR09SWV9PUEVSQVRJT05BTF9FUlJP",
"T1JZX1NPRlRXQVJFX0VSUk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09U", "UhAHEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TRUNVUklUWV9FUlJPUhAIEiUK",
"SEVSX0VSUk9SEAoqygIKDk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19T", "IU1YX1NUQVRVU19DQVRFR09SWV9TT0ZUV0FSRV9FUlJPUhAJEiIKHk1YX1NU",
"T1VSQ0VfVU5TUEVDSUZJRUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05P", "QVRVU19DQVRFR09SWV9PVEhFUl9FUlJPUhAKKsoCCg5NeFN0YXR1c1NvdXJj",
"V04QARIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwof", "ZRIgChxNWF9TVEFUVVNfU09VUkNFX1VOU1BFQ0lGSUVEEAASHAoYTVhfU1RB",
"TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRV", "VFVTX1NPVVJDRV9VTktOT1dOEAESIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFV",
"U19TT1VSQ0VfUkVRVUVTVElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNF", "RVNUSU5HX0xNWBACEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19M",
"X1JFU1BPTkRJTkdfTk1YEAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNU", "TVgQAxIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTk1YEAQSIwof",
"SU5HX0FVVE9NQVRJT05fT0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9S", "TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX05NWBAFEjEKLU1YX1NUQVRV",
"RVNQT05ESU5HX0FVVE9NQVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUS", "U19TT1VSQ0VfUkVRVUVTVElOR19BVVRPTUFUSU9OX09CSkVDVBAGEjEKLU1Y",
"HAoYTVhfREFUQV9UWVBFX1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBF", "X1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19BVVRPTUFUSU9OX09CSkVDVBAH",
"X1VOS05PV04QARIYChRNWF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RB", "Kt0ECgpNeERhdGFUeXBlEhwKGE1YX0RBVEFfVFlQRV9VTlNQRUNJRklFRBAA",
"VEFfVFlQRV9CT09MRUFOEAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIW", "EhgKFE1YX0RBVEFfVFlQRV9VTktOT1dOEAESGAoUTVhfREFUQV9UWVBFX05P",
"ChJNWF9EQVRBX1RZUEVfRkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxF", "X0RBVEEQAhIYChRNWF9EQVRBX1RZUEVfQk9PTEVBThADEhgKFE1YX0RBVEFf",
"EAYSFwoTTVhfREFUQV9UWVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9U", "VFlQRV9JTlRFR0VSEAQSFgoSTVhfREFUQV9UWVBFX0ZMT0FUEAUSFwoTTVhf",
"SU1FEAgSHQoZTVhfREFUQV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RB", "REFUQV9UWVBFX0RPVUJMRRAGEhcKE01YX0RBVEFfVFlQRV9TVFJJTkcQBxIV",
"VEFfVFlQRV9SRUZFUkVOQ0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFU", "ChFNWF9EQVRBX1RZUEVfVElNRRAIEh0KGU1YX0RBVEFfVFlQRV9FTEFQU0VE",
"VVNfVFlQRRALEhUKEU1YX0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9U", "X1RJTUUQCRIfChtNWF9EQVRBX1RZUEVfUkVGRVJFTkNFX1RZUEUQChIcChhN",
"WVBFX1NFQ1VSSVRZX0NMQVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRB", "WF9EQVRBX1RZUEVfU1RBVFVTX1RZUEUQCxIVChFNWF9EQVRBX1RZUEVfRU5V",
"X1RZUEVfREFUQV9RVUFMSVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVB", "TRAMEi0KKU1YX0RBVEFfVFlQRV9TRUNVUklUWV9DTEFTU0lGSUNBVElPTl9F",
"TElGSUVEX0VOVU0QDxIhCh1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVD", "TlVNEA0SIgoeTVhfREFUQV9UWVBFX0RBVEFfUVVBTElUWV9UWVBFEA4SHwob",
"VBAQEikKJU1YX0RBVEFfVFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQ", "TVhfREFUQV9UWVBFX1FVQUxJRklFRF9FTlVNEA8SIQodTVhfREFUQV9UWVBF",
"ERIbChdNWF9EQVRBX1RZUEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQ", "X1FVQUxJRklFRF9TVFJVQ1QQEBIpCiVNWF9EQVRBX1RZUEVfSU5URVJOQVRJ",
"RV9FTkQQEyqjAwoSUHJvdG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NU", "T05BTElaRURfU1RSSU5HEBESGwoXTVhfREFUQV9UWVBFX0JJR19TVFJJTkcQ",
"QVRVU19DT0RFX1VOU1BFQ0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NP", "EhIUChBNWF9EQVRBX1RZUEVfRU5EEBMqowMKElByb3RvY29sU3RhdHVzQ29k",
"REVfT0sQARIoCiRQUk9UT0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVF", "ZRIkCiBQUk9UT0NPTF9TVEFUVVNfQ09ERV9VTlNQRUNJRklFRBAAEhsKF1BS",
"U1QQAhIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VO", "T1RPQ09MX1NUQVRVU19DT0RFX09LEAESKAokUFJPVE9DT0xfU1RBVFVTX0NP",
"RBADEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZ", "REVfSU5WQUxJRF9SRVFVRVNUEAISKgomUFJPVE9DT0xfU1RBVFVTX0NPREVf",
"EAQSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxF", "U0VTU0lPTl9OT1RfRk9VTkQQAxIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9T",
"EAUSIAocUFJPVE9DT0xfU1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RP", "RVNTSU9OX05PVF9SRUFEWRAEEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1dP",
"Q09MX1NUQVRVU19DT0RFX0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVT", "UktFUl9VTkFWQUlMQUJMRRAFEiAKHFBST1RPQ09MX1NUQVRVU19DT0RFX1RJ",
"X0NPREVfUFJPVE9DT0xfVklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVT", "TUVPVVQQBhIhCh1QUk9UT0NPTF9TVEFUVVNfQ09ERV9DQU5DRUxFRBAHEisK",
"X0NPREVfTVhBQ0NFU1NfRkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZ", "J1BST1RPQ09MX1NUQVRVU19DT0RFX1BST1RPQ09MX1ZJT0xBVElPThAIEikK",
"U0VTU0lPTl9TVEFURV9VTlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVf", "JVBST1RPQ09MX1NUQVRVU19DT0RFX01YQUNDRVNTX0ZBSUxVUkUQCSq/AgoM",
"Q1JFQVRJTkcQARIhCh1TRVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhAC", "U2Vzc2lvblN0YXRlEh0KGVNFU1NJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIa",
"EiIKHlNFU1NJT05fU1RBVEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJ", "ChZTRVNTSU9OX1NUQVRFX0NSRUFUSU5HEAESIQodU0VTU0lPTl9TVEFURV9T",
"T05fU1RBVEVfSEFORFNIQUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJ", "VEFSVElOR19XT1JLRVIQAhIiCh5TRVNTSU9OX1NUQVRFX1dBSVRJTkdfRk9S",
"QUxJWklOR19XT1JLRVIQBRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoV", "X1BJUEUQAxIdChlTRVNTSU9OX1NUQVRFX0hBTkRTSEFLSU5HEAQSJQohU0VT",
"U0VTU0lPTl9TVEFURV9DTE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9T", "U0lPTl9TVEFURV9JTklUSUFMSVpJTkdfV09SS0VSEAUSFwoTU0VTU0lPTl9T",
"RUQQCBIZChVTRVNTSU9OX1NUQVRFX0ZBVUxURUQQCTLDBQoPTXhBY2Nlc3NH", "VEFURV9SRUFEWRAGEhkKFVNFU1NJT05fU1RBVEVfQ0xPU0lORxAHEhgKFFNF",
"YXRld2F5El0KC09wZW5TZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5P", "U1NJT05fU1RBVEVfQ0xPU0VEEAgSGQoVU0VTU0lPTl9TVEFURV9GQVVMVEVE",
"cGVuU2Vzc2lvblJlcXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5T", "EAkywwUKD014QWNjZXNzR2F0ZXdheRJdCgtPcGVuU2Vzc2lvbhInLm14YWNj",
"ZXNzaW9uUmVwbHkSYAoMQ2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdh", "ZXNzX2dhdGV3YXkudjEuT3BlblNlc3Npb25SZXF1ZXN0GiUubXhhY2Nlc3Nf",
"eS52MS5DbG9zZVNlc3Npb25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52", "Z2F0ZXdheS52MS5PcGVuU2Vzc2lvblJlcGx5EmAKDENsb3NlU2Vzc2lvbhIo",
"MS5DbG9zZVNlc3Npb25SZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRl", "Lm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVxdWVzdBomLm14",
"d2F5LnYxLk14Q29tbWFuZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYx", "YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVwbHkSVAoGSW52b2tl",
"Lk14Q29tbWFuZFJlcGx5ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dh", "EiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXF1ZXN0GiMubXhh",
"dGV3YXkudjEuU3RyZWFtRXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3", "Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXBseRJYCgxTdHJlYW1FdmVu",
"YXkudjEuTXhFdmVudDABEmwKEEFja25vd2xlZGdlQWxhcm0SLC5teGFjY2Vz", "dHMSKC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmVhbUV2ZW50c1JlcXVlc3Qa",
"c19nYXRld2F5LnYxLkFja25vd2xlZGdlQWxhcm1SZXF1ZXN0GioubXhhY2Nl", "HC5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnQwARJsChBBY2tub3dsZWRn",
"c3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVwbHkSYQoMU3RyZWFt", "ZUFsYXJtEiwubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJt",
"QWxhcm1zEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJlYW1BbGFybXNSZXF1", "UmVxdWVzdBoqLm14YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFy",
"ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybUZlZWRNZXNzYWdlMAES", "bVJlcGx5EmEKDFN0cmVhbUFsYXJtcxIoLm14YWNjZXNzX2dhdGV3YXkudjEu",
"bgoRUXVlcnlBY3RpdmVBbGFybXMSLS5teGFjY2Vzc19nYXRld2F5LnYxLlF1", "U3RyZWFtQWxhcm1zUmVxdWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuQWxh",
"ZXJ5QWN0aXZlQWxhcm1zUmVxdWVzdBooLm14YWNjZXNzX2dhdGV3YXkudjEu", "cm1GZWVkTWVzc2FnZTABEm4KEVF1ZXJ5QWN0aXZlQWxhcm1zEi0ubXhhY2Nl",
"QWN0aXZlQWxhcm1TbmFwc2hvdDABQiaqAiNaQi5NT00uV1cuTXhHYXRld2F5", "c3NfZ2F0ZXdheS52MS5RdWVyeUFjdGl2ZUFsYXJtc1JlcXVlc3QaKC5teGFj",
"LkNvbnRyYWN0cy5Qcm90b2IGcHJvdG8z")); "Y2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25hcHNob3QwAUImqgIjWkIu",
"TU9NLldXLk14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9iBnByb3RvMw=="));
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData, descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, }, new pbr::FileDescriptor[] { global::Google.Protobuf.WellKnownTypes.DurationReflection.Descriptor, global::Google.Protobuf.WellKnownTypes.TimestampReflection.Descriptor, },
new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] { new pbr::GeneratedClrTypeInfo(new[] {typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxCommandKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEventFamily), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmTransitionKind), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmConditionState), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusCategory), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxStatusSource), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxDataType), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ProtocolStatusCode), typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.SessionState), }, null, new pbr::GeneratedClrTypeInfo[] {
@@ -602,7 +603,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply.Parser, new[]{ "WorkerProcessId", "WorkerVersion", "MxaccessProgid", "MxaccessClsid" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.WorkerInfoReply.Parser, new[]{ "WorkerProcessId", "WorkerVersion", "MxaccessProgid", "MxaccessClsid" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply.Parser, new[]{ "Events" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.DrainEventsReply.Parser, new[]{ "Events" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload.Parser, new[]{ "NativeStatus" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReplyPayload.Parser, new[]{ "NativeStatus" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload), global::ZB.MOM.WW.MxGateway.Contracts.Proto.QueryActiveAlarmsReplyPayload.Parser, new[]{ "Snapshots", "SnapshotTruncated" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser, new[]{ "Family", "SessionId", "ServerHandle", "ItemHandle", "Value", "Quality", "SourceTimestamp", "Statuses", "WorkerSequence", "WorkerTimestamp", "GatewayReceiveTimestamp", "Hresult", "RawStatus", "ReplayGap", "OnDataChange", "OnWriteComplete", "OperationComplete", "OnBufferedDataChange", "OnAlarmTransition", "OnAlarmProviderModeChanged" }, new[]{ "Body", "Hresult", "ReplayGap" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.MxEvent.Parser, new[]{ "Family", "SessionId", "ServerHandle", "ItemHandle", "Value", "Quality", "SourceTimestamp", "Statuses", "WorkerSequence", "WorkerTimestamp", "GatewayReceiveTimestamp", "Hresult", "RawStatus", "ReplayGap", "OnDataChange", "OnWriteComplete", "OperationComplete", "OnBufferedDataChange", "OnAlarmTransition", "OnAlarmProviderModeChanged" }, new[]{ "Body", "Hresult", "ReplayGap" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap.Parser, new[]{ "RequestedAfterSequence", "OldestAvailableSequence" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ReplayGap.Parser, new[]{ "RequestedAfterSequence", "OldestAvailableSequence" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent.Parser, null, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnDataChangeEvent.Parser, null, null, null, null, null),
@@ -611,7 +612,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent.Parser, new[]{ "DataType", "QualityValues", "TimestampValues", "RawDataType" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnBufferedDataChangeEvent.Parser, new[]{ "DataType", "QualityValues", "TimestampValues", "RawDataType" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmTransitionEvent.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "TransitionKind", "Severity", "OriginalRaiseTimestamp", "TransitionTimestamp", "OperatorUser", "OperatorComment", "Category", "Description", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent.Parser, new[]{ "Mode", "Reason", "Hresult", "At" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent), global::ZB.MOM.WW.MxGateway.Contracts.Proto.OnAlarmProviderModeChangedEvent.Parser, new[]{ "Mode", "Reason", "Hresult", "At" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot), global::ZB.MOM.WW.MxGateway.Contracts.Proto.ActiveAlarmSnapshot.Parser, new[]{ "AlarmFullReference", "SourceObjectReference", "AlarmTypeName", "Severity", "OriginalRaiseTimestamp", "CurrentState", "Category", "Description", "LastTransitionTimestamp", "OperatorUser", "OperatorComment", "CurrentValue", "LimitValue", "Degraded", "SourceProvider", "FromTruncatedSnapshot" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFullReference", "Comment", "OperatorUser" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply), global::ZB.MOM.WW.MxGateway.Contracts.Proto.AcknowledgeAlarmReply.Parser, new[]{ "CorrelationId", "ProtocolStatus", "Hresult", "Status", "DiagnosticMessage" }, new[]{ "Hresult" }, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null), new pbr::GeneratedClrTypeInfo(typeof(global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest), global::ZB.MOM.WW.MxGateway.Contracts.Proto.StreamAlarmsRequest.Parser, new[]{ "ClientCorrelationId", "AlarmFilterPrefix" }, null, null, null, null),
@@ -23224,6 +23225,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public QueryActiveAlarmsReplyPayload(QueryActiveAlarmsReplyPayload other) : this() { public QueryActiveAlarmsReplyPayload(QueryActiveAlarmsReplyPayload other) : this() {
snapshots_ = other.snapshots_.Clone(); snapshots_ = other.snapshots_.Clone();
snapshotTruncated_ = other.snapshotTruncated_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
} }
@@ -23244,6 +23246,26 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
get { return snapshots_; } get { return snapshots_; }
} }
/// <summary>Field number for the "snapshot_truncated" field.</summary>
public const int SnapshotTruncatedFieldNumber = 2;
private bool snapshotTruncated_;
/// <summary>
/// True when the provider fetch backing this reply came back holding the
/// per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
/// active alarms, and the worker suspends its absence-implies-Clear inference
/// for that poll — so a reference missing from `snapshots` is not evidence the
/// alarm cleared. Carried on the payload as well as per-record because a
/// truncated fetch that filters down to zero records still has to say so.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool SnapshotTruncated {
get { return snapshotTruncated_; }
set {
snapshotTruncated_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) { public override bool Equals(object other) {
@@ -23260,6 +23282,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
return true; return true;
} }
if(!snapshots_.Equals(other.snapshots_)) return false; if(!snapshots_.Equals(other.snapshots_)) return false;
if (SnapshotTruncated != other.SnapshotTruncated) return false;
return Equals(_unknownFields, other._unknownFields); return Equals(_unknownFields, other._unknownFields);
} }
@@ -23268,6 +23291,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
public override int GetHashCode() { public override int GetHashCode() {
int hash = 1; int hash = 1;
hash ^= snapshots_.GetHashCode(); hash ^= snapshots_.GetHashCode();
if (SnapshotTruncated != false) hash ^= SnapshotTruncated.GetHashCode();
if (_unknownFields != null) { if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode(); hash ^= _unknownFields.GetHashCode();
} }
@@ -23287,6 +23311,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
output.WriteRawMessage(this); output.WriteRawMessage(this);
#else #else
snapshots_.WriteTo(output, _repeated_snapshots_codec); snapshots_.WriteTo(output, _repeated_snapshots_codec);
if (SnapshotTruncated != false) {
output.WriteRawTag(16);
output.WriteBool(SnapshotTruncated);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(output); _unknownFields.WriteTo(output);
} }
@@ -23298,6 +23326,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) { void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
snapshots_.WriteTo(ref output, _repeated_snapshots_codec); snapshots_.WriteTo(ref output, _repeated_snapshots_codec);
if (SnapshotTruncated != false) {
output.WriteRawTag(16);
output.WriteBool(SnapshotTruncated);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(ref output); _unknownFields.WriteTo(ref output);
} }
@@ -23309,6 +23341,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
public int CalculateSize() { public int CalculateSize() {
int size = 0; int size = 0;
size += snapshots_.CalculateSize(_repeated_snapshots_codec); size += snapshots_.CalculateSize(_repeated_snapshots_codec);
if (SnapshotTruncated != false) {
size += 1 + 1;
}
if (_unknownFields != null) { if (_unknownFields != null) {
size += _unknownFields.CalculateSize(); size += _unknownFields.CalculateSize();
} }
@@ -23322,6 +23357,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
return; return;
} }
snapshots_.Add(other.snapshots_); snapshots_.Add(other.snapshots_);
if (other.SnapshotTruncated != false) {
SnapshotTruncated = other.SnapshotTruncated;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
} }
@@ -23345,6 +23383,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
snapshots_.AddEntriesFrom(input, _repeated_snapshots_codec); snapshots_.AddEntriesFrom(input, _repeated_snapshots_codec);
break; break;
} }
case 16: {
SnapshotTruncated = input.ReadBool();
break;
}
} }
} }
#endif #endif
@@ -23368,6 +23410,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
snapshots_.AddEntriesFrom(ref input, _repeated_snapshots_codec); snapshots_.AddEntriesFrom(ref input, _repeated_snapshots_codec);
break; break;
} }
case 16: {
SnapshotTruncated = input.ReadBool();
break;
}
} }
} }
} }
@@ -26732,6 +26778,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
limitValue_ = other.limitValue_ != null ? other.limitValue_.Clone() : null; limitValue_ = other.limitValue_ != null ? other.limitValue_.Clone() : null;
degraded_ = other.degraded_; degraded_ = other.degraded_;
sourceProvider_ = other.sourceProvider_; sourceProvider_ = other.sourceProvider_;
fromTruncatedSnapshot_ = other.fromTruncatedSnapshot_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields); _unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
} }
@@ -26944,6 +26991,29 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
} }
} }
/// <summary>Field number for the "from_truncated_snapshot" field.</summary>
public const int FromTruncatedSnapshotFieldNumber = 16;
private bool fromTruncatedSnapshot_;
/// <summary>
/// True when the provider fetch that produced this snapshot hit the per-fetch
/// cap: the snapshot set may omit active alarms, and the worker suspended its
/// absence-implies-Clear inference for that poll. Says nothing about THIS
/// record's fidelity — the record is as accurate as any other; it flags that
/// the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
/// bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
/// boolean is the only additive way to carry set-level degraded status on that
/// RPC. Distinct from `degraded`, which is about the subtag fallback provider.
/// Additive (proto3): clients that ignore it deserialize the stream unchanged.
/// </summary>
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public bool FromTruncatedSnapshot {
get { return fromTruncatedSnapshot_; }
set {
fromTruncatedSnapshot_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute] [global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)] [global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) { public override bool Equals(object other) {
@@ -26974,6 +27044,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (!object.Equals(LimitValue, other.LimitValue)) return false; if (!object.Equals(LimitValue, other.LimitValue)) return false;
if (Degraded != other.Degraded) return false; if (Degraded != other.Degraded) return false;
if (SourceProvider != other.SourceProvider) return false; if (SourceProvider != other.SourceProvider) return false;
if (FromTruncatedSnapshot != other.FromTruncatedSnapshot) return false;
return Equals(_unknownFields, other._unknownFields); return Equals(_unknownFields, other._unknownFields);
} }
@@ -26996,6 +27067,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (limitValue_ != null) hash ^= LimitValue.GetHashCode(); if (limitValue_ != null) hash ^= LimitValue.GetHashCode();
if (Degraded != false) hash ^= Degraded.GetHashCode(); if (Degraded != false) hash ^= Degraded.GetHashCode();
if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) hash ^= SourceProvider.GetHashCode(); if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) hash ^= SourceProvider.GetHashCode();
if (FromTruncatedSnapshot != false) hash ^= FromTruncatedSnapshot.GetHashCode();
if (_unknownFields != null) { if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode(); hash ^= _unknownFields.GetHashCode();
} }
@@ -27074,6 +27146,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
output.WriteRawTag(120); output.WriteRawTag(120);
output.WriteEnum((int) SourceProvider); output.WriteEnum((int) SourceProvider);
} }
if (FromTruncatedSnapshot != false) {
output.WriteRawTag(128, 1);
output.WriteBool(FromTruncatedSnapshot);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(output); _unknownFields.WriteTo(output);
} }
@@ -27144,6 +27220,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
output.WriteRawTag(120); output.WriteRawTag(120);
output.WriteEnum((int) SourceProvider); output.WriteEnum((int) SourceProvider);
} }
if (FromTruncatedSnapshot != false) {
output.WriteRawTag(128, 1);
output.WriteBool(FromTruncatedSnapshot);
}
if (_unknownFields != null) { if (_unknownFields != null) {
_unknownFields.WriteTo(ref output); _unknownFields.WriteTo(ref output);
} }
@@ -27199,6 +27279,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) { if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) {
size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) SourceProvider); size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) SourceProvider);
} }
if (FromTruncatedSnapshot != false) {
size += 2 + 1;
}
if (_unknownFields != null) { if (_unknownFields != null) {
size += _unknownFields.CalculateSize(); size += _unknownFields.CalculateSize();
} }
@@ -27268,6 +27351,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
if (other.SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) { if (other.SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) {
SourceProvider = other.SourceProvider; SourceProvider = other.SourceProvider;
} }
if (other.FromTruncatedSnapshot != false) {
FromTruncatedSnapshot = other.FromTruncatedSnapshot;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields); _unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
} }
@@ -27359,6 +27445,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum(); SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum();
break; break;
} }
case 128: {
FromTruncatedSnapshot = input.ReadBool();
break;
}
} }
} }
#endif #endif
@@ -27450,6 +27540,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum(); SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum();
break; break;
} }
case 128: {
FromTruncatedSnapshot = input.ReadBool();
break;
}
} }
} }
} }
@@ -726,6 +726,13 @@ message AcknowledgeAlarmReplyPayload {
// stream. // stream.
message QueryActiveAlarmsReplyPayload { message QueryActiveAlarmsReplyPayload {
repeated ActiveAlarmSnapshot snapshots = 1; repeated ActiveAlarmSnapshot snapshots = 1;
// True when the provider fetch backing this reply came back holding the
// per-fetch cap (MxGateway:Alarms:MaxAlarmsPerFetch). The reply may then omit
// active alarms, and the worker suspends its absence-implies-Clear inference
// for that poll so a reference missing from `snapshots` is not evidence the
// alarm cleared. Carried on the payload as well as per-record because a
// truncated fetch that filters down to zero records still has to say so.
bool snapshot_truncated = 2;
} }
message MxEvent { message MxEvent {
@@ -932,6 +939,16 @@ message ActiveAlarmSnapshot {
// OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the // OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the
// wire (never UNSPECIFIED). // wire (never UNSPECIFIED).
AlarmProviderMode source_provider = 15; AlarmProviderMode source_provider = 15;
// True when the provider fetch that produced this snapshot hit the per-fetch
// cap: the snapshot set may omit active alarms, and the worker suspended its
// absence-implies-Clear inference for that poll. Says nothing about THIS
// record's fidelity the record is as accurate as any other; it flags that
// the set it belongs to is possibly incomplete. QueryActiveAlarms returns a
// bare `stream ActiveAlarmSnapshot` with no envelope message, so a per-record
// boolean is the only additive way to carry set-level degraded status on that
// RPC. Distinct from `degraded`, which is about the subtag fallback provider.
// Additive (proto3): clients that ignore it deserialize the stream unchanged.
bool from_truncated_snapshot = 16;
} }
enum AlarmConditionState { enum AlarmConditionState {
@@ -170,6 +170,7 @@ public sealed class DashboardLdapLiveTests
return new DashboardAuthenticator( return new DashboardAuthenticator(
new LdapAuthService(ldapOptions), new LdapAuthService(ldapOptions),
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)), new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
Options.Create(gatewayOptions),
NullLogger<DashboardAuthenticator>.Instance); NullLogger<DashboardAuthenticator>.Instance);
} }
@@ -57,6 +57,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
private string _providerReason = string.Empty; private string _providerReason = string.Empty;
private DateTimeOffset _providerSince = DateTimeOffset.UtcNow; private DateTimeOffset _providerSince = DateTimeOffset.UtcNow;
// Whether the worker's most recent reconcile fetch was capped, guarded by _sync.
// Written only by ApplyReconcile, so it always describes the same pass that
// produced the current _alarms generation.
private bool _snapshotTruncated;
private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled; private volatile GatewayAlarmMonitorState _state = GatewayAlarmMonitorState.Disabled;
private volatile string? _lastError; private volatile string? _lastError;
private GatewaySession? _session; private GatewaySession? _session;
@@ -110,6 +115,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
} }
} }
/// <inheritdoc />
public bool SnapshotTruncated
{
get { lock (_sync) { return _snapshotTruncated; } }
}
/// <inheritdoc /> /// <inheritdoc />
protected override async Task ExecuteAsync(CancellationToken stoppingToken) protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{ {
@@ -416,7 +427,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms; QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms;
if (payload is not null) if (payload is not null)
{ {
ApplyReconcile(payload.Snapshots); ApplyReconcile(payload.Snapshots, payload.SnapshotTruncated);
} }
} }
@@ -610,7 +621,13 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
// suppressed. The dedup fires only on a positive marker match, so the contract stays // suppressed. The dedup fires only on a positive marker match, so the contract stays
// at-least-once: consumers must still treat alarm state idempotently — apply a transition as // at-least-once: consumers must still treat alarm state idempotently — apply a transition as
// "set the alarm to this state", never as an increment or a toggle. // "set the alarm to this state", never as an increment or a toggle.
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots) //
// Truncation (`snapshotTruncated`) needs no special handling here, and that is worth saying
// because the obvious worry — a capped fetch reading as a wave of Clears — is answered one
// level down. The worker merges rather than replaces its retained snapshot on a capped fetch,
// so the set arriving here still carries the alarms the capped reply had no room to mention.
// The flag is therefore only recorded, for the operator-facing completeness caveat.
private void ApplyReconcile(IEnumerable<ActiveAlarmSnapshot> snapshots, bool snapshotTruncated)
{ {
Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal); Dictionary<string, ActiveAlarmSnapshot> next = new(StringComparer.Ordinal);
foreach (ActiveAlarmSnapshot snapshot in snapshots) foreach (ActiveAlarmSnapshot snapshot in snapshots)
@@ -669,6 +686,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
_alarms[incoming.Key] = incoming.Value; _alarms[incoming.Key] = incoming.Value;
} }
_snapshotTruncated = snapshotTruncated;
_currentAlarmsProjection = null; _currentAlarmsProjection = null;
} }
} }
@@ -716,6 +734,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
lock (_sync) lock (_sync)
{ {
_alarms.Clear(); _alarms.Clear();
// The truncation verdict describes the cache generation being discarded, so it goes
// with it. Carrying it across a monitor restart would caveat an empty set as "may be
// incomplete" on evidence from a session that no longer exists.
_snapshotTruncated = false;
_currentAlarmsProjection = null; _currentAlarmsProjection = null;
} }
} }
@@ -38,6 +38,16 @@ public interface IGatewayAlarmService
/// <summary>A point-in-time copy of the current active-alarm set.</summary> /// <summary>A point-in-time copy of the current active-alarm set.</summary>
IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; } IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; }
/// <summary>
/// True when the worker's most recent reconcile fetch hit the provider's
/// per-fetch cap, so <see cref="CurrentAlarms"/> may be missing active
/// alarms. The monitor is otherwise healthy — this is not a fault, it is
/// a completeness caveat, which is why it is separate from
/// <see cref="State"/> and <see cref="LastError"/>. Cleared by the first
/// reconcile whose fetch comes back under the cap.
/// </summary>
bool SnapshotTruncated { get; }
/// <summary> /// <summary>
/// Attaches to the central alarm feed. The returned stream yields one /// Attaches to the central alarm feed. The returned stream yields one
/// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a /// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a
@@ -34,6 +34,17 @@
<div class="alert alert-danger">Alarm query failed: @_queryError</div> <div class="alert alert-danger">Alarm query failed: @_queryError</div>
} }
@* Warning, not danger: the rows below are all real and the monitor is healthy — only the
completeness of the set is in doubt, so this must not read as "alarms are broken". *@
@if (_snapshotTruncated)
{
<div class="alert alert-warning">
Alarm snapshot may be incomplete — the provider returned a capped fetch, so alarms beyond
the cap are not listed. Alarms already known stay listed rather than clearing. Raise
<code>MxGateway:Alarms:MaxAlarmsPerFetch</code> or narrow the subscription if this persists.
</div>
}
<section class="metric-grid compact"> <section class="metric-grid compact">
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" /> <MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" /> <MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
@@ -156,6 +167,7 @@
@code { @code {
private readonly List<DashboardActiveAlarm> _alarms = []; private readonly List<DashboardActiveAlarm> _alarms = [];
private string? _queryError; private string? _queryError;
private bool _snapshotTruncated;
private int? _workerPid; private int? _workerPid;
private DateTimeOffset? _lastRefresh; private DateTimeOffset? _lastRefresh;
private int _unackedCount; private int _unackedCount;
@@ -386,6 +398,7 @@
{ {
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token); DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
_queryError = result.Error; _queryError = result.Error;
_snapshotTruncated = result.SnapshotTruncated;
_workerPid = result.WorkerProcessId; _workerPid = result.WorkerProcessId;
_lastRefresh = DateTimeOffset.UtcNow; _lastRefresh = DateTimeOffset.UtcNow;
_alarms.Clear(); _alarms.Clear();
@@ -10,6 +10,7 @@
@inject AuthenticationStateProvider AuthenticationStateProvider @inject AuthenticationStateProvider AuthenticationStateProvider
@inject IDashboardSessionAdminService SessionAdminService @inject IDashboardSessionAdminService SessionAdminService
@inject IDashboardSessionEventSubscriber EventSubscriber @inject IDashboardSessionEventSubscriber EventSubscriber
@inject IDashboardSessionAcl SessionAcl
<PageTitle>Dashboard Session</PageTitle> <PageTitle>Dashboard Session</PageTitle>
@@ -114,7 +115,11 @@ else
<span>@(_eventsConnected ? "live" : "offline")</span> <span>@(_eventsConnected ? "live" : "offline")</span>
</span> </span>
</div> </div>
@if (_recentEvents.Count == 0) @if (!_eventsAuthorized)
{
<div class="empty-state">Not authorized for this session's events.</div>
}
else if (_recentEvents.Count == 0)
{ {
<div class="empty-state"> <div class="empty-state">
Waiting for events. The dashboard subscribes to this session's events directly, so Waiting for events. The dashboard subscribes to this session's events directly, so
@@ -175,6 +180,10 @@ else
private CancellationTokenSource? _eventPumpCancellation; private CancellationTokenSource? _eventPumpCancellation;
private Task? _eventPumpTask; private Task? _eventPumpTask;
private bool _eventsConnected; private bool _eventsConnected;
// Renders the denial message in place of the events panel's empty state. Starts true so the
// panel reads as "waiting" until the gate has actually been evaluated for a session id;
// AttachEventsAsync is the only writer, and it writes on the renderer's dispatcher.
private bool _eventsAuthorized = true;
private string? _subscribedSessionId; private string? _subscribedSessionId;
private readonly LinkedList<MxEvent> _recentEvents = new(); private readonly LinkedList<MxEvent> _recentEvents = new();
@@ -203,7 +212,7 @@ else
// renderer's dispatcher so the new subscription is published to // renderer's dispatcher so the new subscription is published to
// _eventSubscription from the same thread the pump's guard reads it on. // _eventSubscription from the same thread the pump's guard reads it on.
await DetachEventsAsync(); await DetachEventsAsync();
AttachEvents(); await AttachEventsAsync();
} }
} }
@@ -288,19 +297,34 @@ else
// IDashboardEventBroadcaster, and the subscription registers with // IDashboardEventBroadcaster, and the subscription registers with
// EventsHubViewerRegistry, so the "nobody is watching" gate keeps working for // EventsHubViewerRegistry, so the "nobody is watching" gate keeps working for
// both audiences. // both audiences.
// ACL posture is unchanged from the hub path: any dashboard Viewer may watch // ACL posture matches the hub path exactly: IDashboardSessionAcl gates this seam with the
// any session (SEC-25 tracks the per-session ACL for both seams). // same decision EventsHub.SubscribeSession applies (SEC-25 / TST-15). The gate wraps only
private void AttachEvents() // whether a subscription is created at all — the generation guards, the pump, and the detach
// coupling below it are untouched, so a denied page holds no subscription to leak and never
// registers a viewer, which keeps the broadcaster's mirror off for that session.
private async Task AttachEventsAsync()
{ {
if (string.IsNullOrWhiteSpace(SessionId)) if (string.IsNullOrWhiteSpace(SessionId))
{ {
return; return;
} }
// Deliberately no ConfigureAwait(false): the decision and everything it publishes must
// land back on the renderer's dispatcher, which is where the fields below are owned.
AuthenticationState authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync();
_subscribedSessionId = SessionId;
_eventsAuthorized = SessionAcl.CanViewSession(authenticationState.User, SessionId);
if (!_eventsAuthorized)
{
// No subscription, no pump, no viewer registration — the panel renders the denial.
return;
}
_eventSubscription = EventSubscriber.Subscribe(SessionId); _eventSubscription = EventSubscriber.Subscribe(SessionId);
_eventPumpCancellation = new CancellationTokenSource(); _eventPumpCancellation = new CancellationTokenSource();
_eventsConnected = true; _eventsConnected = true;
_subscribedSessionId = SessionId;
// Deliberately not awaited: the pump runs for as long as the page watches this // Deliberately not awaited: the pump runs for as long as the page watches this
// session and is cancelled and drained by DetachEventsAsync. // session and is cancelled and drained by DetachEventsAsync.
@@ -57,7 +57,14 @@ public sealed record DashboardActiveAlarm(
/// <param name="Alarms">The active alarms, or an empty list on error.</param> /// <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="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> /// <param name="WorkerProcessId">The worker process id backing the dashboard session, when available.</param>
/// <param name="SnapshotTruncated">
/// True when the provider fetch behind <paramref name="Alarms"/> hit its per-fetch cap, so the
/// list may be missing active alarms. Distinct from <paramref name="Error"/>: the query
/// succeeded and every row shown is real — only the set's completeness is in doubt, which the
/// page states as a caveat rather than a failure.
/// </param>
public sealed record DashboardAlarmQueryResult( public sealed record DashboardAlarmQueryResult(
IReadOnlyList<DashboardActiveAlarm> Alarms, IReadOnlyList<DashboardActiveAlarm> Alarms,
string? Error, string? Error,
int? WorkerProcessId); int? WorkerProcessId,
bool SnapshotTruncated = false);
@@ -36,6 +36,16 @@ public static class DashboardAuthenticationDefaults
public const string LdapGroupClaimType = "mxgateway:ldap_group"; public const string LdapGroupClaimType = "mxgateway:ldap_group";
public const string KeyPrefixClaimType = "mxgateway:key_prefix"; public const string KeyPrefixClaimType = "mxgateway:key_prefix";
/// <summary>
/// Claim carrying one dashboard event-visibility tag the caller is granted (SEC-25). Stamped
/// at cookie login by <see cref="DashboardAuthenticator"/> and at hub-token mint by
/// <see cref="HubTokenService"/>, both resolving the caller's LDAP groups through
/// <c>MxGateway:Dashboard:GroupToTag</c>; read by <see cref="IDashboardSessionAcl"/>. A
/// principal carrying none of these claims is an empty-grant Viewer, which is the fail-closed
/// default. Visibility only — it never grants data access.
/// </summary>
public const string DashboardTagClaimType = "zb:dashboardtag";
/// <summary> /// <summary>
/// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure /// Dashboard auth cookie name used when the cookie is not guaranteed to be Secure
/// (<c>RequireHttpsCookie=false</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.SameAsRequest"/>) /// (<c>RequireHttpsCookie=false</c> → <see cref="Microsoft.AspNetCore.Authentication.Cookies.CookieSecurePolicy.SameAsRequest"/>)
@@ -1,7 +1,9 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.Auth.Abstractions.Ldap; using ZB.MOM.WW.Auth.Abstractions.Ldap;
using ZB.MOM.WW.Auth.Abstractions.Roles; using ZB.MOM.WW.Auth.Abstractions.Roles;
using ZB.MOM.WW.Auth.AspNetCore; using ZB.MOM.WW.Auth.AspNetCore;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
@@ -17,10 +19,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// </summary> /// </summary>
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param> /// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</param>
/// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param> /// <param name="roleMapper">Maps LDAP groups to dashboard roles.</param>
/// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map that turns the user's
/// LDAP groups into the dashboard visibility tags stamped on the cookie principal (SEC-25).
/// </param>
/// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param> /// <param name="logger">Logger for diagnostic, credential-free login outcomes.</param>
public sealed class DashboardAuthenticator( public sealed class DashboardAuthenticator(
ILdapAuthService ldapAuthService, ILdapAuthService ldapAuthService,
IGroupRoleMapper<string> roleMapper, IGroupRoleMapper<string> roleMapper,
IOptions<GatewayOptions> options,
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
{ {
private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized."; private const string GenericFailureMessage = "The username or password is invalid, or the user is not authorized.";
@@ -70,7 +77,8 @@ public sealed class DashboardAuthenticator(
ldapResult.Username, ldapResult.Username,
ldapResult.DisplayName, ldapResult.DisplayName,
ldapResult.Groups, ldapResult.Groups,
roles)); roles,
options.Value.Dashboard.GroupToTag));
} }
/// <summary> /// <summary>
@@ -97,12 +105,23 @@ public sealed class DashboardAuthenticator(
/// is role-based), so the shape change is non-breaking for dashboard consumers. /// is role-based), so the shape change is non-breaking for dashboard consumers.
/// </param> /// </param>
/// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param> /// <param name="roles">The dashboard roles resolved from <paramref name="groups"/>.</param>
/// <param name="groupToTag">
/// The configured <c>Dashboard:GroupToTag</c> map. The tags it grants are stamped as
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims so a
/// cookie-authenticated circuit carries its grant without a hub-token round-trip — the
/// session-details page's in-process subscribe seam reads exactly these claims.
/// </param>
private static ClaimsPrincipal CreatePrincipal( private static ClaimsPrincipal CreatePrincipal(
string username, string username,
string displayName, string displayName,
IEnumerable<string> groups, IEnumerable<string> groups,
IEnumerable<string> roles) IEnumerable<string> roles,
IReadOnlyDictionary<string, string[]> groupToTag)
{ {
// Materialized because the groups are read twice below (group claims and tag mapping) and
// the source is only guaranteed to be enumerable.
string[] groupNames = groups as string[] ?? [.. groups];
List<Claim> claims = List<Claim> claims =
[ [
// Keep NameIdentifier so any existing read-site that uses it continues to work. // Keep NameIdentifier so any existing read-site that uses it continues to work.
@@ -120,9 +139,14 @@ public sealed class DashboardAuthenticator(
// Groups are short RDN names from ILdapAuthService (see param doc above), so // Groups are short RDN names from ILdapAuthService (see param doc above), so
// this claim value is the short group name, not the original DN. // this claim value is the short group name, not the original DN.
// LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups. // LdapGroupClaimType is MxGateway-specific ("mxgateway:ldap_group") — no ZbClaimType for groups.
claims.AddRange(groups.Select(group => new Claim( claims.AddRange(groupNames.Select(group => new Claim(
DashboardAuthenticationDefaults.LdapGroupClaimType, DashboardAuthenticationDefaults.LdapGroupClaimType,
group))); group)));
// Dashboard event-visibility tags (SEC-25). Visibility only — never a data-access grant —
// and never logged: only the decision, never the tag values, reaches diagnostics.
claims.AddRange(DashboardGroupTagMapping
.MapGroupsToTags(groupNames, groupToTag)
.Select(tag => new Claim(DashboardAuthenticationDefaults.DashboardTagClaimType, tag)));
ClaimsIdentity claimsIdentity = new( ClaimsIdentity claimsIdentity = new(
claims, claims,
@@ -127,7 +127,11 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
? null ? null
: _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}."; : _alarmService.LastError ?? $"Alarm monitor is {_alarmService.State}.";
return Task.FromResult(new DashboardAlarmQueryResult(alarms, error, _alarmService.WorkerProcessId)); return Task.FromResult(new DashboardAlarmQueryResult(
alarms,
error,
_alarmService.WorkerProcessId,
_alarmService.SnapshotTruncated));
} }
// Promotes every already-advised tag in this read to the front of the recency // Promotes every already-advised tag in this read to the front of the recency
@@ -45,6 +45,10 @@ public static class DashboardServiceCollectionExtensions
services.AddSingleton<DashboardApiKeyAuthorization>(); services.AddSingleton<DashboardApiKeyAuthorization>();
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>(); services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>(); services.AddSingleton<IDashboardSessionAdminService, DashboardSessionAdminService>();
// Singleton and stateless: it reads the session registry and options per call, and is
// consulted from both subscribe seams (the EventsHub join and the session-details page's
// in-process subscription).
services.AddSingleton<IDashboardSessionAcl, DashboardSessionAcl>();
// Singleton, and the only consumer scope left is HubTokenAuthenticationHandler plus // Singleton, and the only consumer scope left is HubTokenAuthenticationHandler plus
// the /hubs/token endpoint: server-rendered pages read the in-process feeds, so // the /hubs/token endpoint: server-rendered pages read the in-process feeds, so
// nothing in this process builds a hub connection or needs a token for one. // nothing in this process builds a hub connection or needs a token for one.
@@ -0,0 +1,85 @@
using System.Security.Claims;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Sessions;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Tag-intersection implementation of <see cref="IDashboardSessionAcl"/>. Fails closed on
/// every branch: an unknown session, an empty tag grant, and an untagged session under the
/// default <see cref="UntaggedSessionVisibility.AdminOnly"/> all deny.
/// </summary>
/// <remarks>
/// <para>
/// Decision order (first match wins):
/// </para>
/// <list type="number">
/// <item><description>Authenticated caller in <see cref="DashboardRoles.Admin"/> → allow. Admin
/// already reaches every destructive surface, so event-metadata visibility is strictly weaker.</description></item>
/// <item><description>Session not present in <see cref="ISessionManager"/> → deny. No subscription
/// is created for a phantom id.</description></item>
/// <item><description>Session carries no tags → allow only when
/// <c>MxGateway:Dashboard:UntaggedSessionVisibility</c> is
/// <see cref="UntaggedSessionVisibility.AllViewers"/>.</description></item>
/// <item><description>Otherwise allow iff the session's tags intersect the caller's granted tags
/// (ordinal-ignore-case).</description></item>
/// </list>
/// <para>
/// Granted tags are read from the caller's <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/>
/// claims, stamped at login (<see cref="DashboardAuthenticator"/>) or at hub-token mint
/// (<see cref="HubTokenService"/>). A principal with no such claims — anonymous localhost included —
/// is an empty-grant Viewer.
/// </para>
/// <para>
/// This sits on the <em>subscribe</em> path, not the per-event path, and must stay cheap enough to
/// keep it there: the only per-call work is the claim scan plus a session-registry lookup, with no
/// intermediate collection built. A per-event re-check is deliberately not needed — a joined SignalR
/// group and an in-process subscription are both per-session, and <see cref="GatewaySession.Tags"/>
/// is immutable for the session's life, so the decision taken at subscribe time cannot go stale
/// while the subscription lives.
/// </para>
/// </remarks>
/// <param name="sessionManager">Registry the session id is resolved against.</param>
/// <param name="options">Gateway options supplying <c>Dashboard:UntaggedSessionVisibility</c>.</param>
public sealed class DashboardSessionAcl(
ISessionManager sessionManager,
IOptions<GatewayOptions> options) : IDashboardSessionAcl
{
/// <inheritdoc />
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
{
if (principal is null || string.IsNullOrWhiteSpace(sessionId))
{
return false;
}
if (principal.Identity?.IsAuthenticated == true && principal.IsInRole(DashboardRoles.Admin))
{
return true;
}
if (!sessionManager.TryGetSession(sessionId, out GatewaySession? session))
{
return false;
}
if (session.Tags.Count == 0)
{
return options.Value.Dashboard.UntaggedSessionVisibility == UntaggedSessionVisibility.AllViewers;
}
// Session.Tags is an ordinal-ignore-case set, so the containment test carries the
// comparison; scanning the claims (rather than materializing the grant) keeps this
// allocation-free beyond the claim enumerator.
foreach (Claim tagClaim in principal.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType))
{
if (session.Tags.Contains(tagClaim.Value))
{
return true;
}
}
return false;
}
}
@@ -2,14 +2,16 @@ using System.Security.Claims;
using System.Security.Cryptography; using System.Security.Cryptography;
using System.Text.Json; using System.Text.Json;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard; namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary> /// <summary>
/// Mints and validates short-lived bearer tokens for SignalR hub connections. /// Mints and validates short-lived bearer tokens for SignalR hub connections.
/// The token is a data-protected JSON payload containing the user's name and /// The token is a data-protected JSON payload containing the user's name, role
/// role claims. Validity is enforced by the data-protection time-limited /// claims, and granted dashboard visibility tags. Validity is enforced by the
/// protector; no separate signing keys are configured. /// data-protection time-limited protector; no separate signing keys are configured.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// This service is registered as a singleton in /// This service is registered as a singleton in
@@ -32,24 +34,33 @@ public sealed class HubTokenService
// Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side // Hub bearer tokens are single-purpose, data-protection-encrypted, and NOT server-side
// revocable. A short lifetime bounds the exposure window of a token captured from a proxy // revocable. A short lifetime bounds the exposure window of a token captured from a proxy
// or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and // or log after logout (the cookie is cleared on logout, but outstanding tokens are not), and
// bounds how long a stale role set survives a role change. Five minutes is transparent to // bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant
// clients that re-fetch from /hubs/token on every (re)connect, which is what a remote hub // the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at
// consumer is expected to do; see docs/GatewayDashboardDesign.md. Heavier jti-denylist // mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub
// revocation is deliberately deferred until per-session hub ACLs land, when tokens gain // connections within one lifetime — the natural place the deferred "tokens gain session
// session binding. // binding" note landed. Five minutes is transparent to clients that re-fetch from /hubs/token
// on every (re)connect, which is what a remote hub consumer is expected to do; see
// docs/GatewayDashboardDesign.md. Heavier jti-denylist revocation stays deferred.
internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5); internal static readonly TimeSpan TokenLifetime = TimeSpan.FromMinutes(5);
private readonly ITimeLimitedDataProtector _protector; private readonly ITimeLimitedDataProtector _protector;
private readonly IOptions<GatewayOptions> _options;
/// <summary>Initializes a new instance of the HubTokenService with a data protection provider.</summary> /// <summary>Initializes a new instance of the HubTokenService with a data protection provider.</summary>
/// <param name="dataProtection">The data protection provider for token encryption.</param> /// <param name="dataProtection">The data protection provider for token encryption.</param>
public HubTokenService(IDataProtectionProvider dataProtection) /// <param name="options">
/// Gateway options supplying <c>MxGateway:Dashboard:GroupToTag</c>, the map used to resolve the
/// caller's granted visibility tags at mint time.
/// </param>
public HubTokenService(IDataProtectionProvider dataProtection, IOptions<GatewayOptions> options)
{ {
ArgumentNullException.ThrowIfNull(dataProtection); ArgumentNullException.ThrowIfNull(dataProtection);
ArgumentNullException.ThrowIfNull(options);
_protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector(); _protector = dataProtection.CreateProtector(ProtectorPurpose).ToTimeLimitedDataProtector();
_options = options;
} }
/// <summary>Issues a bearer token carrying the user's identity and roles.</summary> /// <summary>Issues a bearer token carrying the user's identity, roles, and granted tags.</summary>
/// <param name="user">The claims principal representing the user.</param> /// <param name="user">The claims principal representing the user.</param>
/// <returns>The data-protected bearer token string.</returns> /// <returns>The data-protected bearer token string.</returns>
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime); public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
@@ -65,10 +76,20 @@ public sealed class HubTokenService
internal string Issue(ClaimsPrincipal user, TimeSpan lifetime) internal string Issue(ClaimsPrincipal user, TimeSpan lifetime)
{ {
ArgumentNullException.ThrowIfNull(user); ArgumentNullException.ThrowIfNull(user);
// Resolved from the caller's LDAP-group claims rather than copied from any tag claims the
// principal already carries: re-resolving is what makes the 5-minute lifetime an actual
// staleness bound on the grant. Tags are stamped for every caller — an Administrator
// bypasses the ACL, so theirs are simply moot rather than a special case here.
IReadOnlySet<string> grantedTags = DashboardGroupTagMapping.MapGroupsToTags(
user.FindAll(DashboardAuthenticationDefaults.LdapGroupClaimType).Select(c => c.Value),
_options.Value.Dashboard.GroupToTag);
HubTokenPayload payload = new( HubTokenPayload payload = new(
user.Identity?.Name, user.Identity?.Name,
user.FindFirstValue(ClaimTypes.NameIdentifier), user.FindFirstValue(ClaimTypes.NameIdentifier),
[.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)]); [.. user.FindAll(ClaimTypes.Role).Select(c => c.Value)],
[.. grantedTags]);
return _protector.Protect(JsonSerializer.Serialize(payload), lifetime); return _protector.Protect(JsonSerializer.Serialize(payload), lifetime);
} }
@@ -107,6 +128,12 @@ public sealed class HubTokenService
} }
claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r))); claims.AddRange((payload.Roles ?? []).Select(r => new Claim(ClaimTypes.Role, r)));
// Rehydrated alongside the roles so the reconstructed principal is what
// IDashboardSessionAcl reads on the hub path — a token minted before the tag field
// existed (or by a caller with no grant) simply yields an empty grant, which denies.
claims.AddRange((payload.Tags ?? []).Select(t => new Claim(
DashboardAuthenticationDefaults.DashboardTagClaimType,
t)));
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
claims, claims,
@@ -121,5 +148,5 @@ public sealed class HubTokenService
} }
} }
private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles); private sealed record HubTokenPayload(string? Name, string? NameIdentifier, string[]? Roles, string[]? Tags);
} }
@@ -15,8 +15,11 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
/// registry to skip all mirror work for sessions nobody is watching. /// registry to skip all mirror work for sessions nobody is watching.
/// </remarks> /// </remarks>
/// <param name="viewerRegistry">Registry tracking which sessions have live subscribers.</param> /// <param name="viewerRegistry">Registry tracking which sessions have live subscribers.</param>
/// <param name="sessionAcl">Per-session visibility gate consulted before any group join.</param>
[Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)] [Authorize(Policy = DashboardAuthenticationDefaults.HubClientsPolicy)]
public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub public sealed class EventsHub(
EventsHubViewerRegistry viewerRegistry,
IDashboardSessionAcl sessionAcl) : Hub
{ {
/// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary> /// <summary>Method name used to push individual <c>MxEvent</c> values to clients.</summary>
public const string EventMessage = "MxEvent"; public const string EventMessage = "MxEvent";
@@ -33,27 +36,21 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
/// client. /// client.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// In v1 the hub-level <see cref="AuthorizeAttribute"/> /// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
/// (<c>HubClientsPolicy</c>) only checks that the caller carries one of /// only checks that the caller carries one of the dashboard roles, which by
/// the dashboard roles (Admin or Viewer); both roles may subscribe to /// itself would let any Viewer subscribe to any session id they name. The
/// any session id they choose. This is acceptable today because (a) the /// per-session decision is <see cref="IDashboardSessionAcl"/>'s
/// dashboard's per-session views show non-secret session metadata that /// (SEC-25 / TST-15): Administrators see every session, a Viewer sees a
/// any authenticated dashboard user can already see, and (b) tag values /// session only when its tags intersect their granted tags, and an unknown
/// are stripped from the mirrored events by /// session id is denied. A denied caller is not joined to the group and is
/// <see cref="DashboardEventBroadcaster"/> when /// not registered with <see cref="EventsHubViewerRegistry"/>, so the mirror
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), so the /// stays off for a session nobody is legitimately watching. The same ACL
/// most sensitive payload cannot leak through this seam regardless of the /// gates the in-process seam used by the session-details page, so neither
/// still-missing ACL. The per-session ACL that gates the gRPC /// path is the weaker one.
/// <c>StreamEvents</c> RPC is intentionally not yet mirrored here.
/// TODO(per-session-acl): tracked as remediation roadmap item 12
/// (SEC-25). Once a role/scope is introduced that scopes a Viewer to a
/// specific session or tenant, add a session-access check at this seam —
/// either inline (consult the per-user allowed-session set on
/// <c>Context.User</c> claims / <c>Context.Items</c>) or via a dedicated
/// authorization policy applied to the hub method itself.
/// </remarks> /// </remarks>
/// <param name="sessionId">Session id to subscribe the caller to.</param> /// <param name="sessionId">Session id to subscribe the caller to.</param>
/// <returns>A task representing the subscription operation.</returns> /// <returns>A task representing the subscription operation.</returns>
/// <exception cref="HubException">The caller may not observe this session.</exception>
public Task SubscribeSession(string sessionId) public Task SubscribeSession(string sessionId)
{ {
if (string.IsNullOrWhiteSpace(sessionId)) if (string.IsNullOrWhiteSpace(sessionId))
@@ -61,6 +58,13 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
return Task.CompletedTask; return Task.CompletedTask;
} }
if (!sessionAcl.CanViewSession(Context.User, sessionId))
{
// Surfaced rather than swallowed so a client can tell "denied" from "no events yet".
// The message names neither the session's tags nor the caller's grant.
throw new HubException("Not authorized for this session.");
}
// Register before joining the group: the reverse order would leave a window // Register before joining the group: the reverse order would leave a window
// in which this connection is a group member but the broadcaster's gate still // in which this connection is a group member but the broadcaster's gate still
// reports the session unwatched, silently dropping events it should receive. // reports the session unwatched, silently dropping events it should receive.
@@ -0,0 +1,33 @@
using System.Security.Claims;
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
/// <summary>
/// Decides whether a dashboard principal may observe one session's mirrored
/// event stream (SEC-25 / TST-15). Consulted at every subscribe seam: the
/// SignalR <c>EventsHub.SubscribeSession</c> join and the in-process
/// <c>IDashboardSessionEventSubscriber.Subscribe</c> used by the
/// session-details page.
/// </summary>
/// <remarks>
/// The dashboard authenticates LDAP users while sessions are owned by API keys —
/// two disjoint identity domains — so the bridge is the session <em>tag</em>: a
/// session inherits its owning key's tags, and a dashboard group grants tags via
/// <c>MxGateway:Dashboard:GroupToTag</c>. See
/// <c>docs/plans/2026-07-10-dashboard-session-acl-tst15.md</c>.
/// </remarks>
public interface IDashboardSessionAcl
{
/// <summary>
/// Returns whether <paramref name="principal"/> may observe the events of the
/// session identified by <paramref name="sessionId"/>.
/// </summary>
/// <param name="principal">
/// The dashboard caller. <see langword="null"/>, unauthenticated, or claim-less
/// principals (including the anonymous-localhost path) are treated as Viewers
/// holding an empty tag grant.
/// </param>
/// <param name="sessionId">Session id the caller wants to observe.</param>
/// <returns><see langword="true"/> when the caller may observe the session; otherwise <see langword="false"/>.</returns>
bool CanViewSession(ClaimsPrincipal? principal, string sessionId);
}
@@ -0,0 +1,329 @@
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using System.Threading.Channels;
using Microsoft.Extensions.Logging.Abstractions;
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.Grpc;
using ZB.MOM.WW.MxGateway.Server.Metrics;
using ZB.MOM.WW.MxGateway.Server.Security.Authorization;
using ZB.MOM.WW.MxGateway.Server.Sessions;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
namespace ZB.MOM.WW.MxGateway.Tests.Alarms;
/// <summary>
/// Carries the worker's truncated-fetch verdict across the gateway: worker
/// reply payload → <see cref="GatewayAlarmMonitor"/> → the public
/// <c>QueryActiveAlarms</c> stream.
/// </summary>
/// <remarks>
/// <para>
/// The truncation guard itself (the worker merging rather than replacing
/// a capped snapshot) is already covered in the worker suite. What was
/// missing is that the guard is <em>silent</em>: a capped fetch suppresses
/// absence-implies-Clear inference and says so only in a rate-limited
/// stderr warning, so a consumer of the alarm surface could not tell a
/// complete active set from a capped one. These tests pin the structural
/// signal that replaces the guesswork.
/// </para>
/// <para>
/// The load-bearing assertion is the <em>false</em> one
/// (<see cref="QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset"/>).
/// "Truncated reply sets the flag" would also pass against a field
/// hard-wired to true; only the complete-reply case proves the flag is
/// actually derived from the worker's verdict.
/// </para>
/// </remarks>
public sealed class AlarmTruncationSignalTests
{
private static readonly TimeSpan WaitTimeout = TimeSpan.FromSeconds(15);
/// <summary>
/// A capped worker reply sets the monitor's completeness caveat and
/// stamps every cached snapshot, so both the dashboard (which reads the
/// service flag) and the RPC (which reads the records) can surface it.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Reconcile_WithTruncatedWorkerReply_SurfacesTheFlagOnMonitorAndSnapshots()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new()
{
SnapshotTruncated = true,
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
};
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
Assert.True(monitor.SnapshotTruncated);
ActiveAlarmSnapshot cached = Assert.Single(monitor.CurrentAlarms);
Assert.True(cached.FromTruncatedSnapshot);
// Truncation is about the completeness of the SET, not the fidelity of
// the record — the subtag-fallback flag must stay independent of it.
Assert.False(cached.Degraded);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
/// <summary>
/// The public <c>QueryActiveAlarms</c> stream carries the per-record flag
/// through untouched. That RPC returns a bare
/// <c>stream ActiveAlarmSnapshot</c> with no envelope message, so the
/// per-record boolean is the only place set-level degraded status can
/// ride — if the service ever starts re-projecting records instead of
/// forwarding them, this is what catches the dropped field.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryActiveAlarms_WithTruncatedSnapshot_StreamsTheFlagToTheClient()
{
FakeGatewayAlarmService alarms = new()
{
SnapshotTruncated = true,
CurrentAlarms = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: true)],
};
MxAccessGatewayService service = CreateService(alarms);
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest(),
sink,
new TestServerCallContext());
ActiveAlarmSnapshot streamed = Assert.Single(sink.Messages);
Assert.True(streamed.FromTruncatedSnapshot);
Assert.Equal("Galaxy!Area.Tank01.Level.HiHi", streamed.AlarmFullReference);
}
/// <summary>
/// The control. A complete worker reply must leave both the monitor flag
/// and the streamed records unset — otherwise every snapshot would read
/// as possibly-incomplete and the signal would carry no information.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task QueryActiveAlarms_WithCompleteWorkerReply_LeavesFlagUnset()
{
using GatewayMetrics metrics = new();
StubSessionManager sessions = new()
{
SnapshotTruncated = false,
Snapshots = [NewSnapshot("Galaxy!Area.Tank01.Level.HiHi", fromTruncatedSnapshot: false)],
};
using GatewayAlarmMonitor monitor = CreateMonitor(sessions, metrics);
using CancellationTokenSource cts = new();
await monitor.StartAsync(cts.Token);
await sessions.WaitForReconcileAsync(WaitTimeout);
await WaitUntilAsync(() => monitor.CurrentAlarms.Count == 1, WaitTimeout);
Assert.False(monitor.SnapshotTruncated);
MxAccessGatewayService service = CreateService(new FakeGatewayAlarmService
{
SnapshotTruncated = monitor.SnapshotTruncated,
CurrentAlarms = monitor.CurrentAlarms,
});
RecordingServerStreamWriter<ActiveAlarmSnapshot> sink = new();
await service.QueryActiveAlarms(
new QueryActiveAlarmsRequest(),
sink,
new TestServerCallContext());
Assert.False(Assert.Single(sink.Messages).FromTruncatedSnapshot);
await cts.CancelAsync();
await monitor.StopAsync(CancellationToken.None);
}
private static ActiveAlarmSnapshot NewSnapshot(string reference, bool fromTruncatedSnapshot)
{
return new ActiveAlarmSnapshot
{
AlarmFullReference = reference,
SourceObjectReference = "Tank01.Level",
AlarmTypeName = "HiHi",
Category = "Area",
Severity = 500,
CurrentState = AlarmConditionState.Active,
SourceProvider = AlarmProviderMode.Alarmmgr,
FromTruncatedSnapshot = fromTruncatedSnapshot,
};
}
private static GatewayAlarmMonitor CreateMonitor(StubSessionManager sessions, GatewayMetrics metrics)
{
AlarmsOptions options = new()
{
Enabled = true,
SubscriptionExpression = @"\\NODE\Galaxy!Area",
};
return new GatewayAlarmMonitor(
sessions,
new StubWatchListResolver(),
metrics,
Microsoft.Extensions.Options.Options.Create(new GatewayOptions { Alarms = options }),
NullLogger<GatewayAlarmMonitor>.Instance);
}
private static MxAccessGatewayService CreateService(FakeGatewayAlarmService alarms)
{
StubSessionManager sessions = new();
return new MxAccessGatewayService(
sessions,
new GatewayRequestIdentityAccessor(),
new AllowAllConstraintEnforcer(),
new MxAccessGrpcRequestValidator(),
new MxAccessGrpcMapper(),
new StubEventStreamService(),
new GatewayMetrics(),
NullLogger<MxAccessGatewayService>.Instance,
alarms);
}
private static async Task WaitUntilAsync(Func<bool> condition, TimeSpan timeout)
{
DateTime deadline = DateTime.UtcNow + timeout;
while (DateTime.UtcNow < deadline)
{
if (condition())
{
return;
}
await Task.Delay(25);
}
throw new TimeoutException("Condition was not met in time.");
}
/// <summary><see cref="IAlarmWatchListResolver"/> that resolves an empty watch-list.</summary>
private sealed class StubWatchListResolver : IAlarmWatchListResolver
{
/// <inheritdoc />
public Task<IReadOnlyList<AlarmSubtagTarget>> ResolveAsync(
AlarmsOptions options,
CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<AlarmSubtagTarget>>([]);
}
/// <summary>
/// Minimal <see cref="ISessionManager"/> that answers the monitor's
/// QueryActiveAlarms with a scripted reply payload — the seam this suite
/// drives the truncation verdict through.
/// </summary>
private sealed class StubSessionManager : ISessionManager
{
private readonly Channel<WorkerEvent> _events = Channel.CreateUnbounded<WorkerEvent>();
private readonly TaskCompletionSource _reconciled =
new(TaskCreationOptions.RunContinuationsAsynchronously);
/// <summary>Gets or sets the truncation verdict the scripted reply carries.</summary>
public bool SnapshotTruncated { get; init; }
/// <summary>Gets or sets the snapshots the scripted reply carries.</summary>
public IReadOnlyList<ActiveAlarmSnapshot> Snapshots { get; init; } = [];
/// <summary>Completes once the monitor has issued its first QueryActiveAlarms.</summary>
/// <param name="timeout">The maximum time to wait.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
public Task WaitForReconcileAsync(TimeSpan timeout) => _reconciled.Task.WaitAsync(timeout);
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken)
{
GatewaySession session = new(
Guid.NewGuid().ToString("N"),
"Galaxy",
"pipe-test",
"nonce-test",
clientIdentity,
null,
null,
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
TimeSpan.FromSeconds(30),
DateTimeOffset.UtcNow);
session.AttachWorkerClient(new ChannelWorkerClient(session.SessionId, _events.Reader));
session.MarkReady();
return Task.FromResult(session);
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken)
{
MxCommandReply reply = new()
{
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
};
if (command.Command?.Kind == MxCommandKind.QueryActiveAlarms)
{
QueryActiveAlarmsReplyPayload payload = new() { SnapshotTruncated = SnapshotTruncated };
payload.Snapshots.AddRange(Snapshots.Select(snapshot => snapshot.Clone()));
reply.QueryActiveAlarms = payload;
_reconciled.TrySetResult();
}
return Task.FromResult(new WorkerCommandReply { Reply = reply });
}
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = null;
return false;
}
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(string sessionId, CancellationToken cancellationToken)
{
_events.Writer.TryComplete();
return Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
}
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(string sessionId, string reason, CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(DateTimeOffset now, CancellationToken cancellationToken) =>
Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
/// <summary>
/// <see cref="IEventStreamService"/> stub — QueryActiveAlarms never
/// touches the event path, but the service constructor requires one.
/// </summary>
private sealed class StubEventStreamService : IEventStreamService
{
/// <inheritdoc />
public async IAsyncEnumerable<MxEvent> StreamEventsAsync(
StreamEventsRequest request,
string? callerKeyId,
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.CompletedTask.ConfigureAwait(false);
yield break;
}
}
}
@@ -0,0 +1,105 @@
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Alarms;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Tests.TestSupport;
using HtmlRenderer = Microsoft.AspNetCore.Components.Web.HtmlRenderer;
namespace ZB.MOM.WW.MxGateway.Tests.Dashboard;
/// <summary>
/// Renders <see cref="AlarmsPage"/> and asserts the truncated-snapshot
/// caveat banner appears exactly when the alarm query reports a capped
/// provider fetch.
/// </summary>
/// <remarks>
/// <para>
/// The absence assertion is the load-bearing one: a banner that renders
/// unconditionally would satisfy the positive case while telling every
/// operator, on every normal day, that the alarm list might be missing
/// alarms. A caveat that is always on is a caveat nobody reads.
/// </para>
/// <para>
/// Static rendering via the framework's <see cref="HtmlRenderer"/>, as in
/// <c>SecretsNavRenderTests</c> — the assertion is about markup the server
/// emits, so no component-testing dependency is warranted. The page's
/// poll loop runs its first pass inline during <c>OnInitialized</c>
/// (the stub query completes synchronously), so the rendered markup
/// already reflects the query result.
/// </para>
/// </remarks>
public sealed class AlarmsPageTruncationBannerTests
{
private const string BannerMarker = "Alarm snapshot may be incomplete";
/// <summary>A capped provider fetch puts the completeness caveat on the page.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_WhenSnapshotTruncated_RendersTheCaveatBanner()
{
string html = await RenderAsync(snapshotTruncated: true);
Assert.Contains(BannerMarker, html, StringComparison.Ordinal);
}
/// <summary>
/// The proof the banner is gated. A complete fetch must render no caveat
/// at all, while the page itself still renders — the alarm-table heading
/// is the control that keeps this from passing over a blank page.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task AlarmsPage_WhenSnapshotComplete_OmitsTheCaveatBanner()
{
string html = await RenderAsync(snapshotTruncated: false);
Assert.DoesNotContain(BannerMarker, html, StringComparison.Ordinal);
Assert.Contains("Active Alarms", html, StringComparison.Ordinal);
}
private static async Task<string> RenderAsync(bool snapshotTruncated)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardLiveDataService>(
new StubLiveDataService(snapshotTruncated));
services.AddSingleton<IGatewayAlarmService>(
new FakeGatewayAlarmService { SnapshotTruncated = snapshotTruncated });
services.AddSingleton<IOptions<GatewayOptions>>(
Options.Create(new GatewayOptions { Alarms = new AlarmsOptions { Enabled = true } }));
await using ServiceProvider provider = services.BuildServiceProvider();
await using HtmlRenderer renderer = new(
provider,
provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<AlarmsPage>();
return output.ToHtmlString();
});
}
// Answers the page's 3-second poll synchronously, so the first pass completes
// inline inside OnInitialized and the rendered markup reflects it.
private sealed class StubLiveDataService(bool snapshotTruncated) : IDashboardLiveDataService
{
/// <inheritdoc />
public Task<DashboardLiveReadResult> ReadAsync(
IReadOnlyCollection<string> tagAddresses,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardLiveReadResult.Empty);
/// <inheritdoc />
public Task<DashboardAlarmQueryResult> QueryAlarmsAsync(CancellationToken cancellationToken) =>
Task.FromResult(new DashboardAlarmQueryResult(
Alarms: [],
Error: null,
WorkerProcessId: null,
SnapshotTruncated: snapshotTruncated));
}
}
@@ -291,6 +291,7 @@ public sealed class DashboardAuthenticatorTests
return new DashboardAuthenticator( return new DashboardAuthenticator(
ldapAuthService, ldapAuthService,
roleMapper, roleMapper,
Options.Create(options),
NullLogger<DashboardAuthenticator>.Instance); NullLogger<DashboardAuthenticator>.Instance);
} }
@@ -0,0 +1,264 @@
using System.Diagnostics.CodeAnalysis;
using System.Security.Claims;
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;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers <see cref="DashboardSessionAcl"/>, the single decision both dashboard subscribe seams
/// consult (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// Every branch is asserted in its denying direction as well as its allowing one, because the
/// pre-ACL behaviour was "allow everything": an assertion that a permitted caller is permitted
/// cannot distinguish a working gate from no gate at all.
/// </remarks>
public sealed class DashboardSessionAclTests
{
private const string TaggedSessionId = "session-tagged";
private const string UntaggedSessionId = "session-untagged";
/// <summary>An Administrator bypasses the tag check entirely, including for a tag they hold none of.</summary>
[Fact]
public void CanViewSession_Administrator_BypassesTagCheck()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), TaggedSessionId));
Assert.True(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), UntaggedSessionId));
}
/// <summary>
/// The admin bypass is what keeps <c>Dashboard:DisableLogin</c> auto-login (which stamps both
/// roles and no tags) working exactly as before this change.
/// </summary>
[Fact]
public void CanViewSession_AutoLoginStyleBothRolesNoTags_Allowed()
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(
Principal(roles: [DashboardRoles.Admin, DashboardRoles.Viewer]),
TaggedSessionId));
}
/// <summary>
/// An unknown session id is denied even for a caller holding every configured tag: no
/// subscription is created for a session the registry does not have.
/// </summary>
[Fact]
public void CanViewSession_UnknownSession_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: ["team-a", "team-b"]),
"session-does-not-exist"));
}
/// <summary>A blank session id is denied without consulting anything.</summary>
[Theory]
[InlineData("")]
[InlineData(" ")]
public void CanViewSession_BlankSessionId_Denied(string sessionId)
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(Principal(roles: [DashboardRoles.Admin]), sessionId));
}
/// <summary>A null principal denies — the fail-closed reading of an unauthenticated hub context.</summary>
[Fact]
public void CanViewSession_NullPrincipal_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(null, UntaggedSessionId));
}
/// <summary>
/// Untagged sessions follow <c>Dashboard:UntaggedSessionVisibility</c>: hidden from Viewers
/// under the shipped <see cref="UntaggedSessionVisibility.AdminOnly"/> default, visible under
/// the opt-in <see cref="UntaggedSessionVisibility.AllViewers"/>.
/// </summary>
/// <param name="visibility">The configured untagged-session visibility.</param>
/// <param name="expected">Whether a tagless Viewer may observe the untagged session.</param>
[Theory]
[InlineData(UntaggedSessionVisibility.AdminOnly, false)]
[InlineData(UntaggedSessionVisibility.AllViewers, true)]
public void CanViewSession_UntaggedSession_FollowsConfiguredVisibility(
UntaggedSessionVisibility visibility,
bool expected)
{
DashboardSessionAcl acl = CreateAcl(visibility);
Assert.Equal(
expected,
acl.CanViewSession(Principal(roles: [DashboardRoles.Viewer]), UntaggedSessionId));
}
/// <summary>
/// A Viewer whose grant intersects the session's tags is allowed; the comparison is
/// ordinal-ignore-case, matching the session's tag set and the config map.
/// </summary>
/// <param name="grantedTag">The single tag the Viewer holds.</param>
[Theory]
[InlineData("team-a")]
[InlineData("TEAM-A")]
public void CanViewSession_ViewerGrantIntersectsSessionTags_Allowed(string grantedTag)
{
DashboardSessionAcl acl = CreateAcl();
Assert.True(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: [grantedTag]),
TaggedSessionId));
}
/// <summary>A Viewer holding only another tenant's tag is denied — the load-bearing negative.</summary>
[Fact]
public void CanViewSession_ViewerGrantDisjointFromSessionTags_Denied()
{
DashboardSessionAcl acl = CreateAcl();
Assert.False(acl.CanViewSession(
Principal(roles: [DashboardRoles.Viewer], tags: ["team-b"]),
TaggedSessionId));
}
/// <summary>
/// A principal carrying no tag claims — the anonymous-localhost / empty-grant Viewer of
/// SEC-02 — sees a tagged session never, and an untagged one only when the operator opted
/// into <see cref="UntaggedSessionVisibility.AllViewers"/>.
/// </summary>
[Fact]
public void CanViewSession_NoTagClaims_IsEmptyGrantViewer()
{
ClaimsPrincipal anonymous = new(new ClaimsIdentity());
Assert.False(CreateAcl().CanViewSession(anonymous, TaggedSessionId));
Assert.False(CreateAcl(UntaggedSessionVisibility.AdminOnly).CanViewSession(anonymous, UntaggedSessionId));
Assert.True(CreateAcl(UntaggedSessionVisibility.AllViewers).CanViewSession(anonymous, UntaggedSessionId));
}
/// <summary>
/// An unauthenticated principal that nonetheless carries an Administrator role claim does not
/// get the bypass: the bypass requires a real authenticated identity, as elsewhere in the
/// dashboard (<c>DashboardSessionAdminService.CanManage</c>).
/// </summary>
[Fact]
public void CanViewSession_UnauthenticatedAdminRoleClaim_DoesNotBypass()
{
// No authentication type => IsAuthenticated is false.
ClaimsPrincipal principal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Role, DashboardRoles.Admin)],
authenticationType: null,
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role));
Assert.False(CreateAcl().CanViewSession(principal, TaggedSessionId));
}
private static DashboardSessionAcl CreateAcl(
UntaggedSessionVisibility visibility = UntaggedSessionVisibility.AdminOnly)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions { UntaggedSessionVisibility = visibility },
};
return new DashboardSessionAcl(
new TwoSessionManager(
CreateSession(TaggedSessionId, ["team-a"]),
CreateSession(UntaggedSessionId, tags: null)),
Options.Create(options));
}
private static ClaimsPrincipal Principal(string[] roles, string[]? tags = null)
{
List<Claim> claims = [new Claim(ClaimTypes.Name, "viewer-user")];
claims.AddRange(roles.Select(role => new Claim(ClaimTypes.Role, role)));
claims.AddRange((tags ?? []).Select(tag => new Claim(
DashboardAuthenticationDefaults.DashboardTagClaimType,
tag)));
return new ClaimsPrincipal(new ClaimsIdentity(
claims,
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role));
}
private static GatewaySession CreateSession(string sessionId, string[]? tags)
{
return new GatewaySession(
sessionId: sessionId,
backendName: "backend",
pipeName: $"pipe-{sessionId}",
nonce: "nonce",
clientIdentity: "client",
ownerKeyId: "key-1",
clientSessionName: "client-session",
clientCorrelationId: "correlation",
commandTimeout: TimeSpan.FromSeconds(5),
startupTimeout: TimeSpan.FromSeconds(5),
shutdownTimeout: TimeSpan.FromSeconds(5),
leaseDuration: TimeSpan.FromMinutes(30),
openedAt: DateTimeOffset.UnixEpoch,
ownerDashboardTags: tags);
}
/// <summary>Registry double serving exactly the two sessions the ACL cases need.</summary>
private sealed class TwoSessionManager(GatewaySession tagged, GatewaySession untagged) : ISessionManager
{
/// <inheritdoc />
public Task<GatewaySession> OpenSessionAsync(
SessionOpenRequest request,
string? clientIdentity,
string? ownerKeyId,
CancellationToken cancellationToken) => Task.FromResult(tagged);
/// <inheritdoc />
public bool TryGetSession(string sessionId, [MaybeNullWhen(false)] out GatewaySession session)
{
session = sessionId switch
{
TaggedSessionId => tagged,
UntaggedSessionId => untagged,
_ => null,
};
return session is not null;
}
/// <inheritdoc />
public Task<WorkerCommandReply> InvokeAsync(
string sessionId,
WorkerCommand command,
CancellationToken cancellationToken) => Task.FromResult(new WorkerCommandReply());
/// <inheritdoc />
public Task<SessionCloseResult> CloseSessionAsync(
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<SessionCloseResult> KillWorkerAsync(
string sessionId,
string reason,
CancellationToken cancellationToken) =>
Task.FromResult(new SessionCloseResult(sessionId, SessionState.Closed, AlreadyClosed: false));
/// <inheritdoc />
public Task<int> CloseExpiredLeasesAsync(
DateTimeOffset now,
CancellationToken cancellationToken) => Task.FromResult(0);
/// <inheritdoc />
public Task ShutdownAsync(CancellationToken cancellationToken) => Task.CompletedTask;
}
}
@@ -0,0 +1,175 @@
using System.Security.Claims;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.SignalR;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers the ACL gate on <see cref="EventsHub.SubscribeSession"/> (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// The denial assertions are the load-bearing ones — before the gate existed every caller was
/// joined, so "an allowed caller is joined" is indistinguishable from no gate. They assert the
/// absence of BOTH effects of a join: the SignalR group membership and the viewer registration
/// that turns the broadcaster's mirror on for the session. Leaving either behind would keep the
/// event clone running for a caller who may not observe it.
/// </remarks>
public sealed class EventsHubTests
{
private const string SessionId = "session-1";
private const string ConnectionId = "connection-1";
private static readonly ClaimsPrincipal TestPrincipal = new(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "viewer-user")],
authenticationType: "test"));
/// <summary>An allowed caller joins the group and registers as a viewer.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeSession_WhenAclAllows_JoinsGroupAndRegistersViewer()
{
EventsHubViewerRegistry registry = new();
RecordingGroupManager groups = new();
EventsHub hub = CreateHub(registry, groups, allow: true);
await hub.SubscribeSession(SessionId);
Assert.Equal([(ConnectionId, EventsHub.GroupName(SessionId))], groups.Added);
Assert.True(registry.HasViewers(SessionId));
}
/// <summary>
/// A denied caller gets a <see cref="HubException"/>, is not joined, and is not registered.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeSession_WhenAclDenies_ThrowsAndDoesNotJoin()
{
EventsHubViewerRegistry registry = new();
RecordingGroupManager groups = new();
EventsHub hub = CreateHub(registry, groups, allow: false);
HubException error = await Assert.ThrowsAsync<HubException>(() => hub.SubscribeSession(SessionId));
Assert.Equal("Not authorized for this session.", error.Message);
Assert.Empty(groups.Added);
Assert.False(registry.HasViewers(SessionId));
}
/// <summary>
/// A blank session id is still a no-op rather than a denial, so a client that sends one is not
/// told it lacks authorization for a session it never named.
/// </summary>
/// <param name="sessionId">The blank session id supplied by the caller.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
[Theory]
[InlineData("")]
[InlineData(" ")]
public async Task SubscribeSession_BlankSessionId_IsNoOp(string sessionId)
{
EventsHubViewerRegistry registry = new();
RecordingGroupManager groups = new();
EventsHub hub = CreateHub(registry, groups, allow: false);
await hub.SubscribeSession(sessionId);
Assert.Empty(groups.Added);
}
/// <summary>The ACL is asked about the session the caller named, with the caller's own principal.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task SubscribeSession_AsksAclAboutTheRequestedSession()
{
StubSessionAcl acl = new(allow: true);
EventsHub hub = new(new EventsHubViewerRegistry(), acl)
{
Groups = new RecordingGroupManager(),
Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
};
await hub.SubscribeSession(SessionId);
Assert.Equal(SessionId, acl.LastSessionId);
Assert.Same(TestPrincipal, acl.LastPrincipal);
}
private static EventsHub CreateHub(
EventsHubViewerRegistry registry,
RecordingGroupManager groups,
bool allow)
{
return new EventsHub(registry, new StubSessionAcl(allow))
{
Groups = groups,
Context = new StubHubCallerContext(ConnectionId, TestPrincipal),
};
}
private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
{
/// <summary>Gets the principal passed to the most recent call.</summary>
public ClaimsPrincipal? LastPrincipal { get; private set; }
/// <summary>Gets the session id passed to the most recent call.</summary>
public string? LastSessionId { get; private set; }
/// <inheritdoc />
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId)
{
LastPrincipal = principal;
LastSessionId = sessionId;
return allow;
}
}
private sealed class RecordingGroupManager : IGroupManager
{
/// <summary>Gets the (connection id, group name) pairs added, in order.</summary>
public List<(string ConnectionId, string GroupName)> Added { get; } = [];
/// <inheritdoc />
public Task AddToGroupAsync(string connectionId, string groupName, CancellationToken cancellationToken = default)
{
Added.Add((connectionId, groupName));
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveFromGroupAsync(
string connectionId,
string groupName,
CancellationToken cancellationToken = default) => Task.CompletedTask;
}
private sealed class StubHubCallerContext(string connectionId, ClaimsPrincipal user) : HubCallerContext
{
/// <inheritdoc />
public override string ConnectionId { get; } = connectionId;
/// <inheritdoc />
public override string? UserIdentifier => User?.Identity?.Name;
/// <inheritdoc />
public override ClaimsPrincipal? User { get; } = user;
/// <inheritdoc />
public override IDictionary<object, object?> Items { get; } = new Dictionary<object, object?>();
/// <inheritdoc />
public override IFeatureCollection Features { get; } = new FeatureCollection();
/// <inheritdoc />
public override CancellationToken ConnectionAborted => CancellationToken.None;
/// <inheritdoc />
public override void Abort()
{
// Nothing to abort in a unit-constructed context.
}
}
}
@@ -1,5 +1,7 @@
using System.Security.Claims; using System.Security.Claims;
using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.DataProtection;
using Microsoft.Extensions.Options;
using ZB.MOM.WW.MxGateway.Server.Configuration;
using ZB.MOM.WW.MxGateway.Server.Dashboard; using ZB.MOM.WW.MxGateway.Server.Dashboard;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard; namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
@@ -19,7 +21,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull() public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
// Issue from a principal with NO Name claim and NO NameIdentifier // Issue from a principal with NO Name claim and NO NameIdentifier
// claim. The Issue method's payload will then carry // claim. The Issue method's payload will then carry
@@ -43,7 +45,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal() public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
[ [
@@ -72,7 +74,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal() public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
[ [
@@ -93,7 +95,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void Validate_NullToken_ReturnsNull() public void Validate_NullToken_ReturnsNull()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
Assert.Null(service.Validate(null)); Assert.Null(service.Validate(null));
} }
@@ -102,7 +104,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void Validate_EmptyToken_ReturnsNull() public void Validate_EmptyToken_ReturnsNull()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
Assert.Null(service.Validate(string.Empty)); Assert.Null(service.Validate(string.Empty));
} }
@@ -111,7 +113,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void Validate_GarbageToken_ReturnsNull() public void Validate_GarbageToken_ReturnsNull()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
Assert.Null(service.Validate("this-is-not-a-protected-payload")); Assert.Null(service.Validate("this-is-not-a-protected-payload"));
} }
@@ -123,7 +125,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles() public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
[ [
new Claim(ClaimTypes.Name, "bob"), new Claim(ClaimTypes.Name, "bob"),
@@ -163,7 +165,7 @@ public sealed class HubTokenServiceTests
[Fact] [Fact]
public void Validate_ExpiredToken_ReturnsNull() public void Validate_ExpiredToken_ReturnsNull()
{ {
HubTokenService service = new(new EphemeralDataProtectionProvider()); HubTokenService service = CreateService();
ClaimsIdentity identity = new( ClaimsIdentity identity = new(
[new Claim(ClaimTypes.Name, "carol")], [new Claim(ClaimTypes.Name, "carol")],
authenticationType: "test"); authenticationType: "test");
@@ -174,4 +176,85 @@ public sealed class HubTokenServiceTests
Assert.Null(service.Validate(expiredToken)); Assert.Null(service.Validate(expiredToken));
} }
/// <summary>
/// The dashboard visibility grant (SEC-25) survives the mint/validate round-trip: tags are
/// resolved from the caller's LDAP-group claims through <c>Dashboard:GroupToTag</c> at
/// <see cref="HubTokenService.Issue(ClaimsPrincipal)"/> and rehydrated as
/// <see cref="DashboardAuthenticationDefaults.DashboardTagClaimType"/> claims on the principal
/// <see cref="HubTokenService.Validate"/> reconstructs — which is the principal
/// <c>IDashboardSessionAcl</c> reads on the hub path.
/// </summary>
[Fact]
public void IssueThenValidate_ResolvesAndRoundTripsGrantedTags()
{
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["GwViewer"] = ["team-a"],
["TeamBViewers"] = ["team-b"],
});
ClaimsIdentity identity = new(
[
new Claim(ClaimTypes.Name, "dana"),
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "TeamBViewers"),
],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
Assert.NotNull(result);
Assert.Equal(
["team-a", "team-b"],
result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType)
.Select(c => c.Value)
.Order(StringComparer.Ordinal));
}
/// <summary>
/// A caller whose groups map to nothing mints a token with no tags, and validating it yields a
/// principal carrying no tag claims — the empty grant the ACL denies tagged sessions on. This
/// is also the shape of every token minted before the tag field existed (the payload field
/// deserializes to null), so the fail-closed direction is covered for both.
/// </summary>
[Fact]
public void IssueThenValidate_WithNoMatchingGroups_ProducesEmptyGrant()
{
HubTokenService service = CreateService(new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase)
{
["SomeOtherGroup"] = ["team-a"],
});
ClaimsIdentity identity = new(
[
new Claim(ClaimTypes.Name, "erin"),
new Claim(ClaimTypes.Role, DashboardRoles.Viewer),
new Claim(DashboardAuthenticationDefaults.LdapGroupClaimType, "GwViewer"),
],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role);
ClaimsPrincipal? result = service.Validate(service.Issue(new ClaimsPrincipal(identity)));
Assert.NotNull(result);
Assert.Empty(result.FindAll(DashboardAuthenticationDefaults.DashboardTagClaimType));
}
private static HubTokenService CreateService(Dictionary<string, string[]>? groupToTag = null)
{
GatewayOptions options = new()
{
Dashboard = new DashboardOptions
{
GroupToTag = groupToTag ?? new Dictionary<string, string[]>(StringComparer.OrdinalIgnoreCase),
},
};
return new HubTokenService(new EphemeralDataProtectionProvider(), Options.Create(options));
}
} }
@@ -0,0 +1,218 @@
using System.Runtime.CompilerServices;
using System.Security.Claims;
using System.Threading.Channels;
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.AspNetCore.Components.Web;
using Microsoft.AspNetCore.Components.Web.HtmlRendering;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.MxGateway.Contracts.Proto;
using ZB.MOM.WW.MxGateway.Server.Dashboard;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Components.Pages;
using ZB.MOM.WW.MxGateway.Server.Dashboard.Hubs;
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
/// <summary>
/// Covers the ACL gate on the session-details page's in-process subscribe seam (SEC-25 / TST-15).
/// </summary>
/// <remarks>
/// <para>
/// The 2026-08 in-process feed refactor gave the dashboard a second way to subscribe to a
/// session's events — <see cref="IDashboardSessionEventSubscriber"/>, used by this page — so
/// gating the hub join alone would leave the page as an ungated path to the same feed. The
/// denial assertion here is the one that proves the second seam is closed: it asserts that
/// <see cref="IDashboardSessionEventSubscriber.Subscribe"/> is never called, not merely that the
/// panel renders differently.
/// </para>
/// <para>
/// Rendered through the framework's static <see cref="HtmlRenderer"/>, the same idiom
/// <c>SecretsNavRenderTests</c> uses — no component-testing package, because the assertions are
/// about the emitted markup and the calls the lifecycle makes, not about interactivity.
/// </para>
/// </remarks>
public sealed class SessionDetailsPageEventAclTests
{
private const string SessionId = "session-1";
// Matched without the trailing possessive so the assertion does not depend on how the
// renderer escapes the apostrophe.
private const string DeniedMessage = "Not authorized for this session";
private const string WaitingMarker = "Waiting for events.";
/// <summary>A denied caller gets the message and no subscription is opened.</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_WhenAclDenies_RendersMessageAndDoesNotSubscribe()
{
RecordingEventSubscriber subscriber = new();
string html = await RenderAsync(subscriber, allow: false);
Assert.Contains(DeniedMessage, html, StringComparison.Ordinal);
Assert.DoesNotContain(WaitingMarker, html, StringComparison.Ordinal);
Assert.Empty(subscriber.SubscribedSessionIds);
}
/// <summary>
/// The control for the denial above: an allowed caller subscribes and sees the ordinary
/// waiting state. Without this, a page that failed to render its events panel at all would
/// satisfy the "no subscription" assertion and the suite would report a working gate over a
/// broken panel.
/// </summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[Fact]
public async Task Page_WhenAclAllows_SubscribesAndRendersWaitingState()
{
RecordingEventSubscriber subscriber = new();
string html = await RenderAsync(subscriber, allow: true);
Assert.Equal([SessionId], subscriber.SubscribedSessionIds);
Assert.Contains(WaitingMarker, html, StringComparison.Ordinal);
Assert.DoesNotContain(DeniedMessage, html, StringComparison.Ordinal);
}
private static async Task<string> RenderAsync(RecordingEventSubscriber subscriber, bool allow)
{
ServiceCollection services = new();
services.AddLogging();
services.AddSingleton<IDashboardSnapshotService>(new StubSnapshotService());
services.AddSingleton<IDashboardSnapshotFeed>(new IdleSnapshotFeed());
services.AddSingleton<IDashboardSessionAdminService>(new NonManagingSessionAdminService());
services.AddSingleton<IDashboardSessionEventSubscriber>(subscriber);
services.AddSingleton<IDashboardSessionAcl>(new StubSessionAcl(allow));
services.AddSingleton<AuthenticationStateProvider>(new StubAuthenticationStateProvider());
await using ServiceProvider provider = services.BuildServiceProvider();
await using HtmlRenderer renderer = new(provider, provider.GetRequiredService<ILoggerFactory>());
return await renderer.Dispatcher.InvokeAsync(async () =>
{
HtmlRootComponent output = await renderer.RenderComponentAsync<SessionDetailsPage>(
ParameterView.FromDictionary(new Dictionary<string, object?>
{
[nameof(SessionDetailsPage.SessionId)] = SessionId,
}));
return output.ToHtmlString();
});
}
private sealed class StubSessionAcl(bool allow) : IDashboardSessionAcl
{
/// <inheritdoc />
public bool CanViewSession(ClaimsPrincipal? principal, string sessionId) => allow;
}
private sealed class RecordingEventSubscriber : IDashboardSessionEventSubscriber
{
/// <summary>Gets the session ids <see cref="Subscribe"/> was called with, in order.</summary>
public List<string> SubscribedSessionIds { get; } = [];
/// <inheritdoc />
public IDashboardEventSubscription Subscribe(string sessionId)
{
SubscribedSessionIds.Add(sessionId);
return new IdleSubscription();
}
// A subscription whose channel never yields and never completes, so the page's pump parks
// exactly as it would against a quiet session.
private sealed class IdleSubscription : IDashboardEventSubscription
{
private readonly Channel<MxEvent> _channel = Channel.CreateUnbounded<MxEvent>();
/// <inheritdoc />
public ChannelReader<MxEvent> Reader => _channel.Reader;
/// <inheritdoc />
public void Dispose() => _channel.Writer.TryComplete();
}
}
private sealed class StubSnapshotService : IDashboardSnapshotService
{
/// <inheritdoc />
public DashboardSnapshot GetSnapshot() => new(
GeneratedAt: DateTimeOffset.UnixEpoch,
GatewayStartedAt: DateTimeOffset.UnixEpoch,
GatewayUptime: TimeSpan.Zero,
GatewayStatus: "Healthy",
GatewayVersion: "test",
Sessions:
[
new DashboardSessionSummary(
SessionId: SessionId,
BackendName: "backend",
State: SessionState.Ready,
ClientIdentity: "client",
ClientSessionName: "client-session",
ClientCorrelationId: "correlation",
OpenedAt: DateTimeOffset.UnixEpoch,
LastClientActivityAt: DateTimeOffset.UnixEpoch,
LeaseExpiresAt: null,
WorkerProcessId: null,
WorkerState: null,
LastWorkerHeartbeatAt: null,
EventsReceived: 0,
LastFault: null),
],
Workers: [],
Metrics: [],
Faults: [],
ApiKeys: [],
Configuration: null!,
Galaxy: null!);
/// <inheritdoc />
public IAsyncEnumerable<DashboardSnapshot> WatchSnapshotsAsync(CancellationToken cancellationToken) =>
new IdleSnapshotFeed().WatchAsync(cancellationToken);
}
// Parks until the page is disposed, so the base page's watch loop neither spins nor pushes a
// second snapshot mid-assertion.
private sealed class IdleSnapshotFeed : IDashboardSnapshotFeed
{
/// <inheritdoc />
public async IAsyncEnumerable<DashboardSnapshot> WatchAsync(
[EnumeratorCancellation] CancellationToken cancellationToken)
{
await Task.Delay(Timeout.Infinite, cancellationToken).ConfigureAwait(false);
yield break;
}
}
private sealed class NonManagingSessionAdminService : IDashboardSessionAdminService
{
/// <inheritdoc />
public bool CanManage(ClaimsPrincipal user) => false;
/// <inheritdoc />
public Task<DashboardSessionAdminResult> CloseSessionAsync(
ClaimsPrincipal user,
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
/// <inheritdoc />
public Task<DashboardSessionAdminResult> KillWorkerAsync(
ClaimsPrincipal user,
string sessionId,
CancellationToken cancellationToken) =>
Task.FromResult(DashboardSessionAdminResult.Fail("not supported"));
}
private sealed class StubAuthenticationStateProvider : AuthenticationStateProvider
{
/// <inheritdoc />
public override Task<AuthenticationState> GetAuthenticationStateAsync() =>
Task.FromResult(new AuthenticationState(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "viewer-user"), new Claim(ClaimTypes.Role, DashboardRoles.Viewer)],
authenticationType: "test",
nameType: ClaimTypes.Name,
roleType: ClaimTypes.Role))));
}
}
@@ -24,6 +24,9 @@ public sealed class FakeGatewayAlarmService : IGatewayAlarmService
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; set; } = []; public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; set; } = [];
/// <inheritdoc />
public bool SnapshotTruncated { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync( public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
string? alarmFilterPrefix, string? alarmFilterPrefix,
@@ -371,6 +371,9 @@ public sealed class AlarmCommandExecutorTests
/// <summary>Gets the last alarm filter prefix.</summary> /// <summary>Gets the last alarm filter prefix.</summary>
public string? LastFilterPrefix { get; private set; } public string? LastFilterPrefix { get; private set; }
/// <summary>Gets or sets the truncation verdict the executor stamps onto the reply payload.</summary>
public bool LastSnapshotTruncated { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public void Subscribe(SubscribeAlarmsCommand command, string sessionId) public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{ {
@@ -473,6 +473,9 @@ public sealed class AlarmCommandHandlerTests
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult; public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => SnapshotResult;
/// <summary>Gets or sets the truncation verdict reported for the last fetch.</summary>
public bool LastSnapshotTruncated { get; set; }
/// <summary>Gets the number of times polled.</summary> /// <summary>Gets the number of times polled.</summary>
public int PollCount { get; private set; } public int PollCount { get; private set; }
@@ -438,6 +438,9 @@ public sealed class AlarmDispatcherTests
return SnapshotResult; return SnapshotResult;
} }
/// <summary>Gets or sets the truncation verdict the dispatcher stamps onto snapshots.</summary>
public bool LastSnapshotTruncated { get; set; }
/// <summary>Gets the count of poll operations.</summary> /// <summary>Gets the count of poll operations.</summary>
public int PollCount { get; private set; } public int PollCount { get; private set; }
@@ -80,6 +80,9 @@ public sealed class FailoverAlarmConsumerTests
/// <inheritdoc /> /// <inheritdoc />
public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => Array.Empty<MxAlarmSnapshotRecord>(); public IReadOnlyList<MxAlarmSnapshotRecord> SnapshotActiveAlarms() => Array.Empty<MxAlarmSnapshotRecord>();
/// <summary>Gets or sets the truncation verdict this child reports, so delegation is observable.</summary>
public bool LastSnapshotTruncated { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() { } public void Dispose() { }
@@ -132,6 +135,9 @@ public sealed class FailoverAlarmConsumerTests
return Array.Empty<MxAlarmSnapshotRecord>(); return Array.Empty<MxAlarmSnapshotRecord>();
} }
/// <summary>Gets or sets the truncation verdict this child reports, so delegation is observable.</summary>
public bool LastSnapshotTruncated { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public void Dispose() { } public void Dispose() { }
@@ -643,6 +643,9 @@ public sealed class MxAccessStaSessionTests
get { lock (gate) return lastPollThreadId; } get { lock (gate) return lastPollThreadId; }
} }
/// <summary>Gets or sets the truncation verdict reported for the last fetch.</summary>
public bool LastSnapshotTruncated { get; set; }
/// <inheritdoc /> /// <inheritdoc />
public void Subscribe(SubscribeAlarmsCommand command, string sessionId) public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
{ {
@@ -688,6 +688,88 @@ public sealed class WnWrapAlarmConsumerXmlTests
Assert.Equal(MxAlarmStateKind.UnackAlm, record.State); Assert.Equal(MxAlarmStateKind.UnackAlm, record.State);
} }
// -------------------------------------------------------------------------
// Degraded-status signal. The truncation guard above keeps a capped fetch
// from broadcasting phantom Clears, but it does so silently: the retained
// snapshot simply stops shrinking. LastSnapshotTruncated is what makes that
// suppression visible to the QueryActiveAlarms reply and, through it, the
// dashboard banner — so its set/reset behaviour is the contract, not detail.
// -------------------------------------------------------------------------
/// <summary>
/// A capped fetch sets the retained truncation verdict. Without this the
/// signal never leaves the consumer and the reply builder stamps a
/// complete-looking snapshot over a capped one.
/// </summary>
[Fact]
public void FoldFetch_WhenFetchTruncated_SetsLastSnapshotTruncated()
{
const int Cap = 8;
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
Assert.False(consumer.LastSnapshotTruncated);
Dictionary<Guid, MxAlarmSnapshotRecord> next =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out int fetchedRecordCount);
Assert.True(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
consumer.FoldFetch(next, truncated: true, out int retainedCount);
Assert.True(consumer.LastSnapshotTruncated);
Assert.Equal(Cap, retainedCount);
}
/// <summary>
/// THE reset test. A sub-cap fetch is complete, so it restores absence
/// authority and must clear the verdict. Latching it instead would leave
/// the operator banner asserting "snapshot may be incomplete" forever
/// after a single burst above the cap, which trains operators to ignore
/// it — the opposite of what the signal is for.
/// </summary>
[Fact]
public void FoldFetch_AfterTruncatedFetch_SubCapFetchClearsLastSnapshotTruncated()
{
const int Cap = 8;
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
Dictionary<Guid, MxAlarmSnapshotRecord> capped =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
consumer.FoldFetch(capped, truncated: true, out _);
Assert.True(consumer.LastSnapshotTruncated);
Dictionary<Guid, MxAlarmSnapshotRecord> complete =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap - 1), out int fetchedRecordCount);
Assert.False(WnWrapAlarmConsumer.IsTruncatedFetch(fetchedRecordCount, Cap));
consumer.FoldFetch(complete, truncated: false, out int retainedCount);
Assert.False(consumer.LastSnapshotTruncated);
// The complete fetch also replaced the snapshot wholesale, which is what
// makes it authoritative about absence — pinned here so a future change
// cannot clear the verdict while keeping the merge semantics.
Assert.Equal(Cap - 1, retainedCount);
}
/// <summary>
/// Consecutive capped fetches keep the verdict set. It is per-fetch state,
/// not an edge-triggered one-shot: an operator arriving mid-burst must
/// still see the caveat.
/// </summary>
[Fact]
public void FoldFetch_WithConsecutiveTruncatedFetches_KeepsLastSnapshotTruncatedSet()
{
const int Cap = 8;
using WnWrapAlarmConsumer consumer = new WnWrapAlarmConsumer(Cap);
for (int pass = 0; pass < 3; pass++)
{
Dictionary<Guid, MxAlarmSnapshotRecord> capped =
WnWrapAlarmConsumer.ParseSnapshotXml(BuildAlarmXml(Cap), out _);
consumer.FoldFetch(capped, truncated: true, out _);
Assert.True(consumer.LastSnapshotTruncated);
}
}
private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state) private static MxAlarmSnapshotRecord NewRecord(Guid guid, MxAlarmStateKind state)
{ {
return new MxAlarmSnapshotRecord return new MxAlarmSnapshotRecord
@@ -342,6 +342,25 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
return filtered; return filtered;
} }
/// <inheritdoc />
/// <remarks>
/// Deliberately does not go through <c>GetDispatcherOrThrow</c>: an
/// unsubscribed handler has performed no fetch, and "no fetch" is not
/// truncated. Throwing here would turn a status read into a command
/// failure on a path the reply builder takes after the snapshot has
/// already been produced.
/// </remarks>
public bool LastSnapshotTruncated
{
get
{
if (disposed) return false;
AlarmDispatcher? d;
lock (syncRoot) d = dispatcher;
return d is not null && d.LastSnapshotTruncated;
}
}
/// <inheritdoc /> /// <inheritdoc />
public void PollOnce() public void PollOnce()
{ {
@@ -158,16 +158,28 @@ public sealed class AlarmDispatcher : IDisposable
public IReadOnlyList<ActiveAlarmSnapshot> SnapshotActiveAlarms() public IReadOnlyList<ActiveAlarmSnapshot> SnapshotActiveAlarms()
{ {
if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher)); if (disposed) throw new ObjectDisposedException(nameof(AlarmDispatcher));
// Read the truncation verdict before the snapshot, so a poll landing
// between the two can only widen the warning (a stale "truncated" over a
// complete snapshot), never narrow it into a false all-clear.
bool truncated = consumer.LastSnapshotTruncated;
IReadOnlyList<MxAlarmSnapshotRecord> records = consumer.SnapshotActiveAlarms(); IReadOnlyList<MxAlarmSnapshotRecord> records = consumer.SnapshotActiveAlarms();
if (records.Count == 0) return Array.Empty<ActiveAlarmSnapshot>(); if (records.Count == 0) return Array.Empty<ActiveAlarmSnapshot>();
List<ActiveAlarmSnapshot> snapshots = new List<ActiveAlarmSnapshot>(records.Count); List<ActiveAlarmSnapshot> snapshots = new List<ActiveAlarmSnapshot>(records.Count);
foreach (MxAlarmSnapshotRecord record in records) foreach (MxAlarmSnapshotRecord record in records)
{ {
snapshots.Add(MapToSnapshot(record)); snapshots.Add(MapToSnapshot(record, truncated));
} }
return snapshots; return snapshots;
} }
/// <summary>
/// Whether the consumer's most recent fetch hit the per-fetch cap, so
/// the set <see cref="SnapshotActiveAlarms"/> returns may omit actives.
/// Stamped onto the QueryActiveAlarms reply payload, which is the only
/// carrier when the snapshot filters down to zero records.
/// </summary>
public bool LastSnapshotTruncated => !disposed && consumer.LastSnapshotTruncated;
private void OnTransition(object? sender, MxAlarmTransitionEvent transition) private void OnTransition(object? sender, MxAlarmTransitionEvent transition)
{ {
if (disposed) return; if (disposed) return;
@@ -196,7 +208,7 @@ public sealed class AlarmDispatcher : IDisposable
degraded: record.Degraded); degraded: record.Degraded);
} }
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record) private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated)
{ {
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
{ {
@@ -212,6 +224,12 @@ public sealed class AlarmDispatcher : IDisposable
Description = string.Empty, Description = string.Empty,
Degraded = record.Degraded, Degraded = record.Degraded,
SourceProvider = record.Degraded ? AlarmProviderMode.Subtag : AlarmProviderMode.Alarmmgr, SourceProvider = record.Degraded ? AlarmProviderMode.Subtag : AlarmProviderMode.Alarmmgr,
// Set-level status, stamped identically on every record of the
// snapshot: QueryActiveAlarms streams bare ActiveAlarmSnapshot
// messages with no envelope to hang it off. Independent of
// Degraded above — that is about this record's provider, this is
// about whether the set it belongs to is complete.
FromTruncatedSnapshot = truncated,
}; };
if (record.TransitionTimestampUtc != DateTime.MinValue) if (record.TransitionTimestampUtc != DateTime.MinValue)
{ {
@@ -266,6 +266,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
return ActiveChild.SnapshotActiveAlarms(); return ActiveChild.SnapshotActiveAlarms();
} }
/// <inheritdoc />
/// <remarks>
/// Delegated to the active child, matching
/// <see cref="SnapshotActiveAlarms"/>: the flag describes the snapshot
/// the same child produced, so reading it off the standby would pair a
/// verdict with a snapshot it does not belong to. A failover to the
/// subtag standby therefore reports not-truncated — correctly, since
/// that child performs no capped fetch.
/// </remarks>
public bool LastSnapshotTruncated => !disposed && ActiveChild.LastSnapshotTruncated;
private IMxAccessAlarmConsumer ActiveChild => active == Active.Primary ? primary : standby; private IMxAccessAlarmConsumer ActiveChild => active == Active.Primary ? primary : standby;
/// <summary> /// <summary>
@@ -74,6 +74,16 @@ public interface IAlarmCommandHandler : IDisposable
/// <returns>The currently active alarms matching the filter.</returns> /// <returns>The currently active alarms matching the filter.</returns>
IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix); IReadOnlyList<ActiveAlarmSnapshot> QueryActive(string? alarmFilterPrefix);
/// <summary>
/// Whether the consumer's most recent fetch hit the per-fetch cap, so
/// the set <see cref="QueryActive"/> draws from may omit active alarms.
/// Stamped on the QueryActiveAlarms reply payload — the only carrier
/// once a prefix filter (or an empty galaxy) leaves zero records to
/// carry the per-record flag. <see langword="false"/> when there is no
/// active subscription: no fetch has happened, so nothing is capped.
/// </summary>
bool LastSnapshotTruncated { get; }
/// <summary> /// <summary>
/// Drives a single poll of the underlying alarm consumer on the /// Drives a single poll of the underlying alarm consumer on the
/// caller's thread. This is a no-op when there is no active /// caller's thread. This is a no-op when there is no active
@@ -36,6 +36,20 @@ public interface IMxAccessAlarmConsumer : IDisposable
/// </summary> /// </summary>
event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted; event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <summary>
/// Whether the most recent fetch that reached the retained snapshot came
/// back holding the per-fetch cap. While this is <see langword="true"/>
/// the snapshot returned by <see cref="SnapshotActiveAlarms"/> is
/// authoritative about presence only: the provider may hold actives it
/// had no room to report, and the consumer has suspended the
/// absence-implies-Clear inference. Not latched — the first sub-cap fetch
/// after a run of capped ones clears it, because that fetch is complete
/// and the snapshot it produced is again authoritative about absence.
/// Consumers with no per-fetch cap (the subtag fallback, which is
/// event-driven) always report <see langword="false"/>.
/// </summary>
bool LastSnapshotTruncated { get; }
/// <summary> /// <summary>
/// Initializes the AVEVA alarm-client connection, registers as a /// Initializes the AVEVA alarm-client connection, registers as a
/// consumer, and subscribes to the supplied alarm-provider expression. /// consumer, and subscribes to the supplied alarm-provider expression.
@@ -977,6 +977,10 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix); command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix);
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload(); QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
payload.Snapshots.AddRange(snapshots); payload.Snapshots.AddRange(snapshots);
// Set-level degraded status: the snapshot may omit actives because
// the provider fetch hit its cap. The records carry the same flag,
// but a prefix filter can leave none, so the payload states it too.
payload.SnapshotTruncated = alarmCommandHandler.LastSnapshotTruncated;
MxCommandReply reply = CreateOkReply(command); MxCommandReply reply = CreateOkReply(command);
reply.QueryActiveAlarms = payload; reply.QueryActiveAlarms = payload;
return reply; return reply;
@@ -45,6 +45,16 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
/// <summary>Fires once per synthesized alarm-state transition.</summary> /// <summary>Fires once per synthesized alarm-state transition.</summary>
public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted; public event EventHandler<MxAlarmTransitionEvent>? AlarmTransitionEmitted;
/// <inheritdoc />
/// <remarks>
/// Always <see langword="false"/>. Subtag mode is advise-driven over a
/// fixed watch list — there is no bulk fetch and therefore no per-fetch
/// cap to hit. Subtag snapshots are lower-fidelity in other ways, which
/// <c>MxAlarmSnapshotRecord.Degraded</c> already reports; truncation is
/// not one of them.
/// </remarks>
public bool LastSnapshotTruncated => false;
/// <summary> /// <summary>
/// Initializes the consumer over a subtag source and a watch list of /// Initializes the consumer over a subtag source and a watch list of
/// alarm targets. /// alarm targets.
@@ -93,6 +93,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds; private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds;
private long truncatedFetchCount; private long truncatedFetchCount;
private bool lastSnapshotTruncated;
private wwAlarmConsumerClass? client; private wwAlarmConsumerClass? client;
private wwAlarmConsumerClass? ackClient; private wwAlarmConsumerClass? ackClient;
private bool subscribed; private bool subscribed;
@@ -125,6 +126,23 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
: DefaultMaxAlarmsPerFetch; : DefaultMaxAlarmsPerFetch;
} }
/// <summary>
/// COM-free construction, for exercising the retained-snapshot state
/// machine (<see cref="FoldFetch"/> / <see cref="LastSnapshotTruncated"/>
/// / <see cref="SnapshotActiveAlarms"/>) on a machine without AVEVA
/// installed. <see cref="Subscribe"/> throws and <see cref="PollOnce"/>
/// no-ops on an instance built this way — both need the wnwrap coclass,
/// which cannot be instantiated on the macOS/Linux test matrix. Internal
/// rather than public so it cannot be reached from production wiring.
/// </summary>
/// <param name="maxAlarmsPerFetch">Maximum alarms per fetch call.</param>
internal WnWrapAlarmConsumer(int maxAlarmsPerFetch)
{
this.maxAlarmsPerFetch = maxAlarmsPerFetch > 0
? maxAlarmsPerFetch
: DefaultMaxAlarmsPerFetch;
}
/// <summary> /// <summary>
/// Resolves the per-fetch cap from the launcher-provided environment /// Resolves the per-fetch cap from the launcher-provided environment
/// variable. A missing, unparseable, or out-of-range value falls back /// variable. A missing, unparseable, or out-of-range value falls back
@@ -373,6 +391,18 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
} }
} }
/// <inheritdoc />
/// <remarks>
/// Read without the disposed guard <see cref="SnapshotActiveAlarms"/>
/// carries: this is degraded-status metadata a reply builder stamps
/// alongside a snapshot, and throwing from it would fail a query whose
/// snapshot half succeeded.
/// </remarks>
public bool LastSnapshotTruncated
{
get { lock (syncRoot) { return lastSnapshotTruncated; } }
}
/// <summary> /// <summary>
/// Sink for the rate-limited truncated-fetch warning. Defaults to /// Sink for the rate-limited truncated-fetch warning. Defaults to
/// <see cref="Console.Error"/>, the stream /// <see cref="Console.Error"/>, the stream
@@ -413,14 +443,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
// docs/AlarmProbeFindings.md.) // docs/AlarmProbeFindings.md.)
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch); bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
IReadOnlyList<MxAlarmTransitionEvent> transitions; IReadOnlyList<MxAlarmTransitionEvent> transitions =
int retainedCount; FoldFetch(next, truncated, out int retainedCount);
lock (syncRoot)
{
transitions = ComputeTransitions(latestSnapshot, next);
ApplySnapshotUpdate(latestSnapshot, next, truncated);
retainedCount = latestSnapshot.Count;
}
if (truncated) if (truncated)
{ {
@@ -436,6 +460,37 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
} }
} }
/// <summary>
/// Folds one fetch into the retained state under a single lock: the
/// transition diff, the snapshot merge/replace, and the truncation
/// verdict move together. Splitting them would let a concurrent
/// <see cref="SnapshotActiveAlarms"/> / <see cref="LastSnapshotTruncated"/>
/// pair read a capped snapshot alongside the previous poll's "complete"
/// verdict — precisely the false all-clear the signal exists to prevent.
/// The verdict is replaced, never latched: a sub-cap fetch is complete
/// and restores absence authority, so leaving the flag set would strand
/// the operator banner on after a single burst.
/// </summary>
/// <param name="next">The snapshot just parsed from the fetch.</param>
/// <param name="truncated">Whether the fetch hit the per-fetch cap.</param>
/// <param name="retainedCount">Size of the retained snapshot after the fold.</param>
/// <returns>The transitions the fetch implies.</returns>
internal IReadOnlyList<MxAlarmTransitionEvent> FoldFetch(
Dictionary<Guid, MxAlarmSnapshotRecord> next,
bool truncated,
out int retainedCount)
{
lock (syncRoot)
{
IReadOnlyList<MxAlarmTransitionEvent> transitions =
ComputeTransitions(latestSnapshot, next);
ApplySnapshotUpdate(latestSnapshot, next, truncated);
lastSnapshotTruncated = truncated;
retainedCount = latestSnapshot.Count;
return transitions;
}
}
/// <summary> /// <summary>
/// Decides whether a fetch that came back holding /// Decides whether a fetch that came back holding
/// <paramref name="fetchedRecordCount"/> records hit the cap. /// <paramref name="fetchedRecordCount"/> records hit the cap.