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:
+42
-4
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user