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:
@@ -285,248 +285,249 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
"ASgJEhcKD214YWNjZXNzX3Byb2dpZBgDIAEoCRIWCg5teGFjY2Vzc19jbHNp",
|
||||
"ZBgEIAEoCSJAChBEcmFpbkV2ZW50c1JlcGx5EiwKBmV2ZW50cxgBIAMoCzIc",
|
||||
"Lm14YWNjZXNzX2dhdGV3YXkudjEuTXhFdmVudCI1ChxBY2tub3dsZWRnZUFs",
|
||||
"YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUiXAodUXVl",
|
||||
"YXJtUmVwbHlQYXlsb2FkEhUKDW5hdGl2ZV9zdGF0dXMYASABKAUieAodUXVl",
|
||||
"cnlBY3RpdmVBbGFybXNSZXBseVBheWxvYWQSOwoJc25hcHNob3RzGAEgAygL",
|
||||
"MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90Io8I",
|
||||
"CgdNeEV2ZW50EjIKBmZhbWlseRgBIAEoDjIiLm14YWNjZXNzX2dhdGV3YXku",
|
||||
"djEuTXhFdmVudEZhbWlseRISCgpzZXNzaW9uX2lkGAIgASgJEhUKDXNlcnZl",
|
||||
"cl9oYW5kbGUYAyABKAUSEwoLaXRlbV9oYW5kbGUYBCABKAUSKwoFdmFsdWUY",
|
||||
"BSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSDwoHcXVhbGl0",
|
||||
"eRgGIAEoBRI0ChBzb3VyY2VfdGltZXN0YW1wGAcgASgLMhouZ29vZ2xlLnBy",
|
||||
"b3RvYnVmLlRpbWVzdGFtcBI0CghzdGF0dXNlcxgIIAMoCzIiLm14YWNjZXNz",
|
||||
"X2dhdGV3YXkudjEuTXhTdGF0dXNQcm94eRIXCg93b3JrZXJfc2VxdWVuY2UY",
|
||||
"CSABKAQSNAoQd29ya2VyX3RpbWVzdGFtcBgKIAEoCzIaLmdvb2dsZS5wcm90",
|
||||
"b2J1Zi5UaW1lc3RhbXASPQoZZ2F0ZXdheV9yZWNlaXZlX3RpbWVzdGFtcBgL",
|
||||
"IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFAoHaHJlc3VsdBgM",
|
||||
"IAEoBUgBiAEBEhIKCnJhd19zdGF0dXMYDSABKAkSNwoKcmVwbGF5X2dhcBgO",
|
||||
"IAEoCzIeLm14YWNjZXNzX2dhdGV3YXkudjEuUmVwbGF5R2FwSAKIAQESQAoO",
|
||||
"b25fZGF0YV9jaGFuZ2UYFCABKAsyJi5teGFjY2Vzc19nYXRld2F5LnYxLk9u",
|
||||
"RGF0YUNoYW5nZUV2ZW50SAASRgoRb25fd3JpdGVfY29tcGxldGUYFSABKAsy",
|
||||
"KS5teGFjY2Vzc19nYXRld2F5LnYxLk9uV3JpdGVDb21wbGV0ZUV2ZW50SAAS",
|
||||
"SQoSb3BlcmF0aW9uX2NvbXBsZXRlGBYgASgLMisubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5PcGVyYXRpb25Db21wbGV0ZUV2ZW50SAASUQoXb25fYnVmZmVyZWRf",
|
||||
"ZGF0YV9jaGFuZ2UYFyABKAsyLi5teGFjY2Vzc19nYXRld2F5LnYxLk9uQnVm",
|
||||
"ZmVyZWREYXRhQ2hhbmdlRXZlbnRIABJKChNvbl9hbGFybV90cmFuc2l0aW9u",
|
||||
"GBggASgLMisubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtVHJhbnNpdGlv",
|
||||
"bkV2ZW50SAASXgoeb25fYWxhcm1fcHJvdmlkZXJfbW9kZV9jaGFuZ2VkGBkg",
|
||||
"ASgLMjQubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkFsYXJtUHJvdmlkZXJNb2Rl",
|
||||
"Q2hhbmdlZEV2ZW50SABCBgoEYm9keUIKCghfaHJlc3VsdEINCgtfcmVwbGF5",
|
||||
"X2dhcCJQCglSZXBsYXlHYXASIAoYcmVxdWVzdGVkX2FmdGVyX3NlcXVlbmNl",
|
||||
"GAEgASgEEiEKGW9sZGVzdF9hdmFpbGFibGVfc2VxdWVuY2UYAiABKAQiEwoR",
|
||||
"T25EYXRhQ2hhbmdlRXZlbnQiFgoUT25Xcml0ZUNvbXBsZXRlRXZlbnQiGAoW",
|
||||
"T3BlcmF0aW9uQ29tcGxldGVFdmVudCLUAQoZT25CdWZmZXJlZERhdGFDaGFu",
|
||||
"Z2VFdmVudBIyCglkYXRhX3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5",
|
||||
"LnYxLk14RGF0YVR5cGUSNAoOcXVhbGl0eV92YWx1ZXMYAiABKAsyHC5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLk14QXJyYXkSNgoQdGltZXN0YW1wX3ZhbHVlcxgD",
|
||||
"IAEoCzIcLm14YWNjZXNzX2dhdGV3YXkudjEuTXhBcnJheRIVCg1yYXdfZGF0",
|
||||
"YV90eXBlGAQgASgFItAEChZPbkFsYXJtVHJhbnNpdGlvbkV2ZW50EhwKFGFs",
|
||||
"YXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8KF3NvdXJjZV9vYmplY3RfcmVm",
|
||||
"ZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVfbmFtZRgDIAEoCRJBCg90cmFu",
|
||||
"c2l0aW9uX2tpbmQYBCABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYxLkFsYXJt",
|
||||
"VHJhbnNpdGlvbktpbmQSEAoIc2V2ZXJpdHkYBSABKAUSPAoYb3JpZ2luYWxf",
|
||||
"cmFpc2VfdGltZXN0YW1wGAYgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz",
|
||||
"dGFtcBI4ChR0cmFuc2l0aW9uX3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5w",
|
||||
"cm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3JfdXNlchgIIAEoCRIYChBv",
|
||||
"cGVyYXRvcl9jb21tZW50GAkgASgJEhAKCGNhdGVnb3J5GAogASgJEhMKC2Rl",
|
||||
"c2NyaXB0aW9uGAsgASgJEjMKDWN1cnJlbnRfdmFsdWUYDCABKAsyHC5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLk14VmFsdWUSMQoLbGltaXRfdmFsdWUYDSABKAsy",
|
||||
"HC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUSEAoIZGVncmFkZWQYDiAB",
|
||||
"KAgSPwoPc291cmNlX3Byb3ZpZGVyGA8gASgOMiYubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5BbGFybVByb3ZpZGVyTW9kZSKgAQofT25BbGFybVByb3ZpZGVyTW9k",
|
||||
"ZUNoYW5nZWRFdmVudBI0CgRtb2RlGAEgASgOMiYubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5BbGFybVByb3ZpZGVyTW9kZRIOCgZyZWFzb24YAiABKAkSDwoHaHJl",
|
||||
"c3VsdBgDIAEoBRImCgJhdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1l",
|
||||
"c3RhbXAi0AQKE0FjdGl2ZUFsYXJtU25hcHNob3QSHAoUYWxhcm1fZnVsbF9y",
|
||||
"ZWZlcmVuY2UYASABKAkSHwoXc291cmNlX29iamVjdF9yZWZlcmVuY2UYAiAB",
|
||||
"KAkSFwoPYWxhcm1fdHlwZV9uYW1lGAMgASgJEhAKCHNldmVyaXR5GAQgASgF",
|
||||
"EjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgFIAEoCzIaLmdvb2dsZS5w",
|
||||
"cm90b2J1Zi5UaW1lc3RhbXASPwoNY3VycmVudF9zdGF0ZRgGIAEoDjIoLm14",
|
||||
"YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Db25kaXRpb25TdGF0ZRIQCghjYXRl",
|
||||
"Z29yeRgHIAEoCRITCgtkZXNjcmlwdGlvbhgIIAEoCRI9ChlsYXN0X3RyYW5z",
|
||||
"aXRpb25fdGltZXN0YW1wGAkgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVz",
|
||||
"dGFtcBIVCg1vcGVyYXRvcl91c2VyGAogASgJEhgKEG9wZXJhdG9yX2NvbW1l",
|
||||
"bnQYCyABKAkSMwoNY3VycmVudF92YWx1ZRgMIAEoCzIcLm14YWNjZXNzX2dh",
|
||||
"dGV3YXkudjEuTXhWYWx1ZRIxCgtsaW1pdF92YWx1ZRgNIAEoCzIcLm14YWNj",
|
||||
"ZXNzX2dhdGV3YXkudjEuTXhWYWx1ZRIQCghkZWdyYWRlZBgOIAEoCBI/Cg9z",
|
||||
"b3VyY2VfcHJvdmlkZXIYDyABKA4yJi5teGFjY2Vzc19nYXRld2F5LnYxLkFs",
|
||||
"YXJtUHJvdmlkZXJNb2RlIpABChdBY2tub3dsZWRnZUFsYXJtUmVxdWVzdBId",
|
||||
"ChVjbGllbnRfY29ycmVsYXRpb25faWQYAiABKAkSHAoUYWxhcm1fZnVsbF9y",
|
||||
"ZWZlcmVuY2UYAyABKAkSDwoHY29tbWVudBgEIAEoCRIVCg1vcGVyYXRvcl91",
|
||||
"c2VyGAUgASgJSgQIARACUgpzZXNzaW9uX2lkIvEBChVBY2tub3dsZWRnZUFs",
|
||||
"YXJtUmVwbHkSFgoOY29ycmVsYXRpb25faWQYAiABKAkSPAoPcHJvdG9jb2xf",
|
||||
"c3RhdHVzGAMgASgLMiMubXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0",
|
||||
"YXR1cxIUCgdocmVzdWx0GAQgASgFSACIAQESMgoGc3RhdHVzGAUgASgLMiIu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1c1Byb3h5EhoKEmRpYWdub3N0",
|
||||
"aWNfbWVzc2FnZRgGIAEoCUIKCghfaHJlc3VsdEoECAEQAlIKc2Vzc2lvbl9p",
|
||||
"ZCJRChNTdHJlYW1BbGFybXNSZXF1ZXN0Eh0KFWNsaWVudF9jb3JyZWxhdGlv",
|
||||
"bl9pZBgBIAEoCRIbChNhbGFybV9maWx0ZXJfcHJlZml4GAIgASgJIoQCChBB",
|
||||
"bGFybUZlZWRNZXNzYWdlEkAKDGFjdGl2ZV9hbGFybRgBIAEoCzIoLm14YWNj",
|
||||
"ZXNzX2dhdGV3YXkudjEuQWN0aXZlQWxhcm1TbmFwc2hvdEgAEhsKEXNuYXBz",
|
||||
"aG90X2NvbXBsZXRlGAIgASgISAASQQoKdHJhbnNpdGlvbhgDIAEoCzIrLm14",
|
||||
"YWNjZXNzX2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEkMK",
|
||||
"D3Byb3ZpZGVyX3N0YXR1cxgEIAEoCzIoLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"QWxhcm1Qcm92aWRlclN0YXR1c0gAQgkKB3BheWxvYWQimAEKE0FsYXJtUHJv",
|
||||
"dmlkZXJTdGF0dXMSNAoEbW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXku",
|
||||
"djEuQWxhcm1Qcm92aWRlck1vZGUSEAoIZGVncmFkZWQYAiABKAgSDgoGcmVh",
|
||||
"c29uGAMgASgJEikKBXNpbmNlGAQgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRp",
|
||||
"bWVzdGFtcCLrAQoNTXhTdGF0dXNQcm94eRIPCgdzdWNjZXNzGAEgASgFEjcK",
|
||||
"CGNhdGVnb3J5GAIgASgOMiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeFN0YXR1",
|
||||
"c0NhdGVnb3J5EjgKC2RldGVjdGVkX2J5GAMgASgOMiMubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5NeFN0YXR1c1NvdXJjZRIOCgZkZXRhaWwYBCABKAUSFAoMcmF3",
|
||||
"X2NhdGVnb3J5GAUgASgFEhcKD3Jhd19kZXRlY3RlZF9ieRgGIAEoBRIXCg9k",
|
||||
"aWFnbm9zdGljX3RleHQYByABKAki6QMKB014VmFsdWUSMgoJZGF0YV90eXBl",
|
||||
"GAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEhQKDHZh",
|
||||
"cmlhbnRfdHlwZRgCIAEoCRIPCgdpc19udWxsGAMgASgIEhYKDnJhd19kaWFn",
|
||||
"bm9zdGljGAQgASgJEhUKDXJhd19kYXRhX3R5cGUYBSABKAUSFAoKYm9vbF92",
|
||||
"YWx1ZRgKIAEoCEgAEhUKC2ludDMyX3ZhbHVlGAsgASgFSAASFQoLaW50NjRf",
|
||||
"dmFsdWUYDCABKANIABIVCgtmbG9hdF92YWx1ZRgNIAEoAkgAEhYKDGRvdWJs",
|
||||
"ZV92YWx1ZRgOIAEoAUgAEhYKDHN0cmluZ192YWx1ZRgPIAEoCUgAEjUKD3Rp",
|
||||
"bWVzdGFtcF92YWx1ZRgQIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3Rh",
|
||||
"bXBIABIzCgthcnJheV92YWx1ZRgRIAEoCzIcLm14YWNjZXNzX2dhdGV3YXku",
|
||||
"djEuTXhBcnJheUgAEhMKCXJhd192YWx1ZRgSIAEoDEgAEkAKEnNwYXJzZV9h",
|
||||
"cnJheV92YWx1ZRgTIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTcGFy",
|
||||
"c2VBcnJheUgAQgYKBGtpbmQi/gQKB014QXJyYXkSOgoRZWxlbWVudF9kYXRh",
|
||||
"X3R5cGUYASABKA4yHy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUS",
|
||||
"FAoMdmFyaWFudF90eXBlGAIgASgJEhIKCmRpbWVuc2lvbnMYAyADKA0SFgoO",
|
||||
"cmF3X2RpYWdub3N0aWMYBCABKAkSHQoVcmF3X2VsZW1lbnRfZGF0YV90eXBl",
|
||||
"GAUgASgFEjUKC2Jvb2xfdmFsdWVzGAogASgLMh4ubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5Cb29sQXJyYXlIABI3CgxpbnQzMl92YWx1ZXMYCyABKAsyHy5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLkludDMyQXJyYXlIABI3CgxpbnQ2NF92YWx1ZXMY",
|
||||
"DCABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkludDY0QXJyYXlIABI3Cgxm",
|
||||
"bG9hdF92YWx1ZXMYDSABKAsyHy5teGFjY2Vzc19nYXRld2F5LnYxLkZsb2F0",
|
||||
"QXJyYXlIABI5Cg1kb3VibGVfdmFsdWVzGA4gASgLMiAubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5Eb3VibGVBcnJheUgAEjkKDXN0cmluZ192YWx1ZXMYDyABKAsy",
|
||||
"IC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmluZ0FycmF5SAASPwoQdGltZXN0",
|
||||
"YW1wX3ZhbHVlcxgQIAEoCzIjLm14YWNjZXNzX2dhdGV3YXkudjEuVGltZXN0",
|
||||
"YW1wQXJyYXlIABIzCgpyYXdfdmFsdWVzGBEgASgLMh0ubXhhY2Nlc3NfZ2F0",
|
||||
"ZXdheS52MS5SYXdBcnJheUgAQggKBnZhbHVlcyKZAQoNTXhTcGFyc2VBcnJh",
|
||||
"eRI6ChFlbGVtZW50X2RhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuTXhEYXRhVHlwZRIUCgx0b3RhbF9sZW5ndGgYAiABKA0SNgoIZWxl",
|
||||
"bWVudHMYAyADKAsyJC5teGFjY2Vzc19nYXRld2F5LnYxLk14U3BhcnNlRWxl",
|
||||
"bWVudCJNCg9NeFNwYXJzZUVsZW1lbnQSDQoFaW5kZXgYASABKA0SKwoFdmFs",
|
||||
"dWUYAiABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUiGwoJQm9v",
|
||||
"bEFycmF5Eg4KBnZhbHVlcxgBIAMoCCIcCgpJbnQzMkFycmF5Eg4KBnZhbHVl",
|
||||
"cxgBIAMoBSIcCgpJbnQ2NEFycmF5Eg4KBnZhbHVlcxgBIAMoAyIcCgpGbG9h",
|
||||
"dEFycmF5Eg4KBnZhbHVlcxgBIAMoAiIdCgtEb3VibGVBcnJheRIOCgZ2YWx1",
|
||||
"ZXMYASADKAEiHQoLU3RyaW5nQXJyYXkSDgoGdmFsdWVzGAEgAygJIjwKDlRp",
|
||||
"bWVzdGFtcEFycmF5EioKBnZhbHVlcxgBIAMoCzIaLmdvb2dsZS5wcm90b2J1",
|
||||
"Zi5UaW1lc3RhbXAiGgoIUmF3QXJyYXkSDgoGdmFsdWVzGAEgAygMIlgKDlBy",
|
||||
"b3RvY29sU3RhdHVzEjUKBGNvZGUYASABKA4yJy5teGFjY2Vzc19nYXRld2F5",
|
||||
"LnYxLlByb3RvY29sU3RhdHVzQ29kZRIPCgdtZXNzYWdlGAIgASgJKp8LCg1N",
|
||||
"eENvbW1hbmRLaW5kEh8KG01YX0NPTU1BTkRfS0lORF9VTlNQRUNJRklFRBAA",
|
||||
"EhwKGE1YX0NPTU1BTkRfS0lORF9SRUdJU1RFUhABEh4KGk1YX0NPTU1BTkRf",
|
||||
"S0lORF9VTlJFR0lTVEVSEAISHAoYTVhfQ09NTUFORF9LSU5EX0FERF9JVEVN",
|
||||
"EAMSHQoZTVhfQ09NTUFORF9LSU5EX0FERF9JVEVNMhAEEh8KG01YX0NPTU1B",
|
||||
"TkRfS0lORF9SRU1PVkVfSVRFTRAFEhoKFk1YX0NPTU1BTkRfS0lORF9BRFZJ",
|
||||
"U0UQBhIdChlNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFEAcSJgoiTVhfQ09N",
|
||||
"TUFORF9LSU5EX0FEVklTRV9TVVBFUlZJU09SWRAIEiUKIU1YX0NPTU1BTkRf",
|
||||
"S0lORF9BRERfQlVGRkVSRURfSVRFTRAJEjAKLE1YX0NPTU1BTkRfS0lORF9T",
|
||||
"RVRfQlVGRkVSRURfVVBEQVRFX0lOVEVSVkFMEAoSGwoXTVhfQ09NTUFORF9L",
|
||||
"SU5EX1NVU1BFTkQQCxIcChhNWF9DT01NQU5EX0tJTkRfQUNUSVZBVEUQDBIZ",
|
||||
"ChVNWF9DT01NQU5EX0tJTkRfV1JJVEUQDRIaChZNWF9DT01NQU5EX0tJTkRf",
|
||||
"V1JJVEUyEA4SIQodTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VSRUQQDxIi",
|
||||
"Ch5NWF9DT01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDIQEBIlCiFNWF9DT01N",
|
||||
"QU5EX0tJTkRfQVVUSEVOVElDQVRFX1VTRVIQERIoCiRNWF9DT01NQU5EX0tJ",
|
||||
"TkRfQVJDSEVTVFJBX1VTRVJfVE9fSUQQEhIhCh1NWF9DT01NQU5EX0tJTkRf",
|
||||
"QUREX0lURU1fQlVMSxATEiQKIE1YX0NPTU1BTkRfS0lORF9BRFZJU0VfSVRF",
|
||||
"TV9CVUxLEBQSJAogTVhfQ09NTUFORF9LSU5EX1JFTU9WRV9JVEVNX0JVTEsQ",
|
||||
"FRInCiNNWF9DT01NQU5EX0tJTkRfVU5fQURWSVNFX0lURU1fQlVMSxAWEiIK",
|
||||
"Hk1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQlVMSxAXEiQKIE1YX0NPTU1B",
|
||||
"TkRfS0lORF9VTlNVQlNDUklCRV9CVUxLEBgSJAogTVhfQ09NTUFORF9LSU5E",
|
||||
"X1NVQlNDUklCRV9BTEFSTVMQGRImCiJNWF9DT01NQU5EX0tJTkRfVU5TVUJT",
|
||||
"Q1JJQkVfQUxBUk1TEBoSJQohTVhfQ09NTUFORF9LSU5EX0FDS05PV0xFREdF",
|
||||
"X0FMQVJNEBsSJwojTVhfQ09NTUFORF9LSU5EX1FVRVJZX0FDVElWRV9BTEFS",
|
||||
"TVMQHBItCilNWF9DT01NQU5EX0tJTkRfQUNLTk9XTEVER0VfQUxBUk1fQllf",
|
||||
"TkFNRRAdEh4KGk1YX0NPTU1BTkRfS0lORF9XUklURV9CVUxLEB4SHwobTVhf",
|
||||
"Q09NTUFORF9LSU5EX1dSSVRFMl9CVUxLEB8SJgoiTVhfQ09NTUFORF9LSU5E",
|
||||
"X1dSSVRFX1NFQ1VSRURfQlVMSxAgEicKI01YX0NPTU1BTkRfS0lORF9XUklU",
|
||||
"RV9TRUNVUkVEMl9CVUxLECESHQoZTVhfQ09NTUFORF9LSU5EX1JFQURfQlVM",
|
||||
"SxAiEhgKFE1YX0NPTU1BTkRfS0lORF9QSU5HEGQSJQohTVhfQ09NTUFORF9L",
|
||||
"SU5EX0dFVF9TRVNTSU9OX1NUQVRFEGUSIwofTVhfQ09NTUFORF9LSU5EX0dF",
|
||||
"VF9XT1JLRVJfSU5GTxBmEiAKHE1YX0NPTU1BTkRfS0lORF9EUkFJTl9FVkVO",
|
||||
"VFMQZxIjCh9NWF9DT01NQU5EX0tJTkRfU0hVVERPV05fV09SS0VSEGgqegoR",
|
||||
"QWxhcm1Qcm92aWRlck1vZGUSIwofQUxBUk1fUFJPVklERVJfTU9ERV9VTlNQ",
|
||||
"RUNJRklFRBAAEiAKHEFMQVJNX1BST1ZJREVSX01PREVfQUxBUk1NR1IQARIe",
|
||||
"ChpBTEFSTV9QUk9WSURFUl9NT0RFX1NVQlRBRxACKq0CCg1NeEV2ZW50RmFt",
|
||||
"aWx5Eh8KG01YX0VWRU5UX0ZBTUlMWV9VTlNQRUNJRklFRBAAEiIKHk1YX0VW",
|
||||
"RU5UX0ZBTUlMWV9PTl9EQVRBX0NIQU5HRRABEiUKIU1YX0VWRU5UX0ZBTUlM",
|
||||
"WV9PTl9XUklURV9DT01QTEVURRACEiYKIk1YX0VWRU5UX0ZBTUlMWV9PUEVS",
|
||||
"QVRJT05fQ09NUExFVEUQAxIrCidNWF9FVkVOVF9GQU1JTFlfT05fQlVGRkVS",
|
||||
"RURfREFUQV9DSEFOR0UQBBInCiNNWF9FVkVOVF9GQU1JTFlfT05fQUxBUk1f",
|
||||
"VFJBTlNJVElPThAFEjIKLk1YX0VWRU5UX0ZBTUlMWV9PTl9BTEFSTV9QUk9W",
|
||||
"SURFUl9NT0RFX0NIQU5HRUQQBirKAQoTQWxhcm1UcmFuc2l0aW9uS2luZBIl",
|
||||
"CiFBTEFSTV9UUkFOU0lUSU9OX0tJTkRfVU5TUEVDSUZJRUQQABIfChtBTEFS",
|
||||
"TV9UUkFOU0lUSU9OX0tJTkRfUkFJU0UQARIlCiFBTEFSTV9UUkFOU0lUSU9O",
|
||||
"X0tJTkRfQUNLTk9XTEVER0UQAhIfChtBTEFSTV9UUkFOU0lUSU9OX0tJTkRf",
|
||||
"Q0xFQVIQAxIjCh9BTEFSTV9UUkFOU0lUSU9OX0tJTkRfUkVUUklHR0VSEAQq",
|
||||
"qgEKE0FsYXJtQ29uZGl0aW9uU3RhdGUSJQohQUxBUk1fQ09ORElUSU9OX1NU",
|
||||
"QVRFX1VOU1BFQ0lGSUVEEAASIAocQUxBUk1fQ09ORElUSU9OX1NUQVRFX0FD",
|
||||
"VElWRRABEiYKIkFMQVJNX0NPTkRJVElPTl9TVEFURV9BQ1RJVkVfQUNLRUQQ",
|
||||
"AhIiCh5BTEFSTV9DT05ESVRJT05fU1RBVEVfSU5BQ1RJVkUQAyqlAwoQTXhT",
|
||||
"dGF0dXNDYXRlZ29yeRIiCh5NWF9TVEFUVVNfQ0FURUdPUllfVU5TUEVDSUZJ",
|
||||
"RUQQABIeChpNWF9TVEFUVVNfQ0FURUdPUllfVU5LTk9XThABEhkKFU1YX1NU",
|
||||
"QVRVU19DQVRFR09SWV9PSxACEh4KGk1YX1NUQVRVU19DQVRFR09SWV9QRU5E",
|
||||
"SU5HEAMSHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1dBUk5JTkcQBBIqCiZNWF9T",
|
||||
"VEFUVVNfQ0FURUdPUllfQ09NTVVOSUNBVElPTl9FUlJPUhAFEioKJk1YX1NU",
|
||||
"QVRVU19DQVRFR09SWV9DT05GSUdVUkFUSU9OX0VSUk9SEAYSKAokTVhfU1RB",
|
||||
"VFVTX0NBVEVHT1JZX09QRVJBVElPTkFMX0VSUk9SEAcSJQohTVhfU1RBVFVT",
|
||||
"X0NBVEVHT1JZX1NFQ1VSSVRZX0VSUk9SEAgSJQohTVhfU1RBVFVTX0NBVEVH",
|
||||
"T1JZX1NPRlRXQVJFX0VSUk9SEAkSIgoeTVhfU1RBVFVTX0NBVEVHT1JZX09U",
|
||||
"SEVSX0VSUk9SEAoqygIKDk14U3RhdHVzU291cmNlEiAKHE1YX1NUQVRVU19T",
|
||||
"T1VSQ0VfVU5TUEVDSUZJRUQQABIcChhNWF9TVEFUVVNfU09VUkNFX1VOS05P",
|
||||
"V04QARIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTE1YEAISIwof",
|
||||
"TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX0xNWBADEiMKH01YX1NUQVRV",
|
||||
"U19TT1VSQ0VfUkVRVUVTVElOR19OTVgQBBIjCh9NWF9TVEFUVVNfU09VUkNF",
|
||||
"X1JFU1BPTkRJTkdfTk1YEAUSMQotTVhfU1RBVFVTX1NPVVJDRV9SRVFVRVNU",
|
||||
"SU5HX0FVVE9NQVRJT05fT0JKRUNUEAYSMQotTVhfU1RBVFVTX1NPVVJDRV9S",
|
||||
"RVNQT05ESU5HX0FVVE9NQVRJT05fT0JKRUNUEAcq3QQKCk14RGF0YVR5cGUS",
|
||||
"HAoYTVhfREFUQV9UWVBFX1VOU1BFQ0lGSUVEEAASGAoUTVhfREFUQV9UWVBF",
|
||||
"X1VOS05PV04QARIYChRNWF9EQVRBX1RZUEVfTk9fREFUQRACEhgKFE1YX0RB",
|
||||
"VEFfVFlQRV9CT09MRUFOEAMSGAoUTVhfREFUQV9UWVBFX0lOVEVHRVIQBBIW",
|
||||
"ChJNWF9EQVRBX1RZUEVfRkxPQVQQBRIXChNNWF9EQVRBX1RZUEVfRE9VQkxF",
|
||||
"EAYSFwoTTVhfREFUQV9UWVBFX1NUUklORxAHEhUKEU1YX0RBVEFfVFlQRV9U",
|
||||
"SU1FEAgSHQoZTVhfREFUQV9UWVBFX0VMQVBTRURfVElNRRAJEh8KG01YX0RB",
|
||||
"VEFfVFlQRV9SRUZFUkVOQ0VfVFlQRRAKEhwKGE1YX0RBVEFfVFlQRV9TVEFU",
|
||||
"VVNfVFlQRRALEhUKEU1YX0RBVEFfVFlQRV9FTlVNEAwSLQopTVhfREFUQV9U",
|
||||
"WVBFX1NFQ1VSSVRZX0NMQVNTSUZJQ0FUSU9OX0VOVU0QDRIiCh5NWF9EQVRB",
|
||||
"X1RZUEVfREFUQV9RVUFMSVRZX1RZUEUQDhIfChtNWF9EQVRBX1RZUEVfUVVB",
|
||||
"TElGSUVEX0VOVU0QDxIhCh1NWF9EQVRBX1RZUEVfUVVBTElGSUVEX1NUUlVD",
|
||||
"VBAQEikKJU1YX0RBVEFfVFlQRV9JTlRFUk5BVElPTkFMSVpFRF9TVFJJTkcQ",
|
||||
"ERIbChdNWF9EQVRBX1RZUEVfQklHX1NUUklORxASEhQKEE1YX0RBVEFfVFlQ",
|
||||
"RV9FTkQQEyqjAwoSUHJvdG9jb2xTdGF0dXNDb2RlEiQKIFBST1RPQ09MX1NU",
|
||||
"QVRVU19DT0RFX1VOU1BFQ0lGSUVEEAASGwoXUFJPVE9DT0xfU1RBVFVTX0NP",
|
||||
"REVfT0sQARIoCiRQUk9UT0NPTF9TVEFUVVNfQ09ERV9JTlZBTElEX1JFUVVF",
|
||||
"U1QQAhIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9TRVNTSU9OX05PVF9GT1VO",
|
||||
"RBADEioKJlBST1RPQ09MX1NUQVRVU19DT0RFX1NFU1NJT05fTk9UX1JFQURZ",
|
||||
"EAQSKwonUFJPVE9DT0xfU1RBVFVTX0NPREVfV09SS0VSX1VOQVZBSUxBQkxF",
|
||||
"EAUSIAocUFJPVE9DT0xfU1RBVFVTX0NPREVfVElNRU9VVBAGEiEKHVBST1RP",
|
||||
"Q09MX1NUQVRVU19DT0RFX0NBTkNFTEVEEAcSKwonUFJPVE9DT0xfU1RBVFVT",
|
||||
"X0NPREVfUFJPVE9DT0xfVklPTEFUSU9OEAgSKQolUFJPVE9DT0xfU1RBVFVT",
|
||||
"X0NPREVfTVhBQ0NFU1NfRkFJTFVSRRAJKr8CCgxTZXNzaW9uU3RhdGUSHQoZ",
|
||||
"U0VTU0lPTl9TVEFURV9VTlNQRUNJRklFRBAAEhoKFlNFU1NJT05fU1RBVEVf",
|
||||
"Q1JFQVRJTkcQARIhCh1TRVNTSU9OX1NUQVRFX1NUQVJUSU5HX1dPUktFUhAC",
|
||||
"EiIKHlNFU1NJT05fU1RBVEVfV0FJVElOR19GT1JfUElQRRADEh0KGVNFU1NJ",
|
||||
"T05fU1RBVEVfSEFORFNIQUtJTkcQBBIlCiFTRVNTSU9OX1NUQVRFX0lOSVRJ",
|
||||
"QUxJWklOR19XT1JLRVIQBRIXChNTRVNTSU9OX1NUQVRFX1JFQURZEAYSGQoV",
|
||||
"U0VTU0lPTl9TVEFURV9DTE9TSU5HEAcSGAoUU0VTU0lPTl9TVEFURV9DTE9T",
|
||||
"RUQQCBIZChVTRVNTSU9OX1NUQVRFX0ZBVUxURUQQCTLDBQoPTXhBY2Nlc3NH",
|
||||
"YXRld2F5El0KC09wZW5TZXNzaW9uEicubXhhY2Nlc3NfZ2F0ZXdheS52MS5P",
|
||||
"cGVuU2Vzc2lvblJlcXVlc3QaJS5teGFjY2Vzc19nYXRld2F5LnYxLk9wZW5T",
|
||||
"ZXNzaW9uUmVwbHkSYAoMQ2xvc2VTZXNzaW9uEigubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5DbG9zZVNlc3Npb25SZXF1ZXN0GiYubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5DbG9zZVNlc3Npb25SZXBseRJUCgZJbnZva2USJS5teGFjY2Vzc19nYXRl",
|
||||
"d2F5LnYxLk14Q29tbWFuZFJlcXVlc3QaIy5teGFjY2Vzc19nYXRld2F5LnYx",
|
||||
"Lk14Q29tbWFuZFJlcGx5ElgKDFN0cmVhbUV2ZW50cxIoLm14YWNjZXNzX2dh",
|
||||
"dGV3YXkudjEuU3RyZWFtRXZlbnRzUmVxdWVzdBocLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuTXhFdmVudDABEmwKEEFja25vd2xlZGdlQWxhcm0SLC5teGFjY2Vz",
|
||||
"c19nYXRld2F5LnYxLkFja25vd2xlZGdlQWxhcm1SZXF1ZXN0GioubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJtUmVwbHkSYQoMU3RyZWFt",
|
||||
"QWxhcm1zEigubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJlYW1BbGFybXNSZXF1",
|
||||
"ZXN0GiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybUZlZWRNZXNzYWdlMAES",
|
||||
"bgoRUXVlcnlBY3RpdmVBbGFybXMSLS5teGFjY2Vzc19nYXRld2F5LnYxLlF1",
|
||||
"ZXJ5QWN0aXZlQWxhcm1zUmVxdWVzdBooLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"QWN0aXZlQWxhcm1TbmFwc2hvdDABQiaqAiNaQi5NT00uV1cuTXhHYXRld2F5",
|
||||
"LkNvbnRyYWN0cy5Qcm90b2IGcHJvdG8z"));
|
||||
"MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY3RpdmVBbGFybVNuYXBzaG90EhoK",
|
||||
"EnNuYXBzaG90X3RydW5jYXRlZBgCIAEoCCKPCAoHTXhFdmVudBIyCgZmYW1p",
|
||||
"bHkYASABKA4yIi5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnRGYW1pbHkS",
|
||||
"EgoKc2Vzc2lvbl9pZBgCIAEoCRIVCg1zZXJ2ZXJfaGFuZGxlGAMgASgFEhMK",
|
||||
"C2l0ZW1faGFuZGxlGAQgASgFEisKBXZhbHVlGAUgASgLMhwubXhhY2Nlc3Nf",
|
||||
"Z2F0ZXdheS52MS5NeFZhbHVlEg8KB3F1YWxpdHkYBiABKAUSNAoQc291cmNl",
|
||||
"X3RpbWVzdGFtcBgHIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAS",
|
||||
"NAoIc3RhdHVzZXMYCCADKAsyIi5teGFjY2Vzc19nYXRld2F5LnYxLk14U3Rh",
|
||||
"dHVzUHJveHkSFwoPd29ya2VyX3NlcXVlbmNlGAkgASgEEjQKEHdvcmtlcl90",
|
||||
"aW1lc3RhbXAYCiABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEj0K",
|
||||
"GWdhdGV3YXlfcmVjZWl2ZV90aW1lc3RhbXAYCyABKAsyGi5nb29nbGUucHJv",
|
||||
"dG9idWYuVGltZXN0YW1wEhQKB2hyZXN1bHQYDCABKAVIAYgBARISCgpyYXdf",
|
||||
"c3RhdHVzGA0gASgJEjcKCnJlcGxheV9nYXAYDiABKAsyHi5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLlJlcGxheUdhcEgCiAEBEkAKDm9uX2RhdGFfY2hhbmdlGBQg",
|
||||
"ASgLMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkRhdGFDaGFuZ2VFdmVudEgA",
|
||||
"EkYKEW9uX3dyaXRlX2NvbXBsZXRlGBUgASgLMikubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5PbldyaXRlQ29tcGxldGVFdmVudEgAEkkKEm9wZXJhdGlvbl9jb21w",
|
||||
"bGV0ZRgWIAEoCzIrLm14YWNjZXNzX2dhdGV3YXkudjEuT3BlcmF0aW9uQ29t",
|
||||
"cGxldGVFdmVudEgAElEKF29uX2J1ZmZlcmVkX2RhdGFfY2hhbmdlGBcgASgL",
|
||||
"Mi4ubXhhY2Nlc3NfZ2F0ZXdheS52MS5PbkJ1ZmZlcmVkRGF0YUNoYW5nZUV2",
|
||||
"ZW50SAASSgoTb25fYWxhcm1fdHJhbnNpdGlvbhgYIAEoCzIrLm14YWNjZXNz",
|
||||
"X2dhdGV3YXkudjEuT25BbGFybVRyYW5zaXRpb25FdmVudEgAEl4KHm9uX2Fs",
|
||||
"YXJtX3Byb3ZpZGVyX21vZGVfY2hhbmdlZBgZIAEoCzI0Lm14YWNjZXNzX2dh",
|
||||
"dGV3YXkudjEuT25BbGFybVByb3ZpZGVyTW9kZUNoYW5nZWRFdmVudEgAQgYK",
|
||||
"BGJvZHlCCgoIX2hyZXN1bHRCDQoLX3JlcGxheV9nYXAiUAoJUmVwbGF5R2Fw",
|
||||
"EiAKGHJlcXVlc3RlZF9hZnRlcl9zZXF1ZW5jZRgBIAEoBBIhChlvbGRlc3Rf",
|
||||
"YXZhaWxhYmxlX3NlcXVlbmNlGAIgASgEIhMKEU9uRGF0YUNoYW5nZUV2ZW50",
|
||||
"IhYKFE9uV3JpdGVDb21wbGV0ZUV2ZW50IhgKFk9wZXJhdGlvbkNvbXBsZXRl",
|
||||
"RXZlbnQi1AEKGU9uQnVmZmVyZWREYXRhQ2hhbmdlRXZlbnQSMgoJZGF0YV90",
|
||||
"eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeERhdGFUeXBlEjQK",
|
||||
"DnF1YWxpdHlfdmFsdWVzGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N",
|
||||
"eEFycmF5EjYKEHRpbWVzdGFtcF92YWx1ZXMYAyABKAsyHC5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLk14QXJyYXkSFQoNcmF3X2RhdGFfdHlwZRgEIAEoBSLQBAoW",
|
||||
"T25BbGFybVRyYW5zaXRpb25FdmVudBIcChRhbGFybV9mdWxsX3JlZmVyZW5j",
|
||||
"ZRgBIAEoCRIfChdzb3VyY2Vfb2JqZWN0X3JlZmVyZW5jZRgCIAEoCRIXCg9h",
|
||||
"bGFybV90eXBlX25hbWUYAyABKAkSQQoPdHJhbnNpdGlvbl9raW5kGAQgASgO",
|
||||
"MigubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVRyYW5zaXRpb25LaW5kEhAK",
|
||||
"CHNldmVyaXR5GAUgASgFEjwKGG9yaWdpbmFsX3JhaXNlX3RpbWVzdGFtcBgG",
|
||||
"IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASOAoUdHJhbnNpdGlv",
|
||||
"bl90aW1lc3RhbXAYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
|
||||
"EhUKDW9wZXJhdG9yX3VzZXIYCCABKAkSGAoQb3BlcmF0b3JfY29tbWVudBgJ",
|
||||
"IAEoCRIQCghjYXRlZ29yeRgKIAEoCRITCgtkZXNjcmlwdGlvbhgLIAEoCRIz",
|
||||
"Cg1jdXJyZW50X3ZhbHVlGAwgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52MS5N",
|
||||
"eFZhbHVlEjEKC2xpbWl0X3ZhbHVlGA0gASgLMhwubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5NeFZhbHVlEhAKCGRlZ3JhZGVkGA4gASgIEj8KD3NvdXJjZV9wcm92",
|
||||
"aWRlchgPIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl",
|
||||
"ck1vZGUioAEKH09uQWxhcm1Qcm92aWRlck1vZGVDaGFuZ2VkRXZlbnQSNAoE",
|
||||
"bW9kZRgBIAEoDjImLm14YWNjZXNzX2dhdGV3YXkudjEuQWxhcm1Qcm92aWRl",
|
||||
"ck1vZGUSDgoGcmVhc29uGAIgASgJEg8KB2hyZXN1bHQYAyABKAUSJgoCYXQY",
|
||||
"BCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIvEEChNBY3RpdmVB",
|
||||
"bGFybVNuYXBzaG90EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAEgASgJEh8K",
|
||||
"F3NvdXJjZV9vYmplY3RfcmVmZXJlbmNlGAIgASgJEhcKD2FsYXJtX3R5cGVf",
|
||||
"bmFtZRgDIAEoCRIQCghzZXZlcml0eRgEIAEoBRI8ChhvcmlnaW5hbF9yYWlz",
|
||||
"ZV90aW1lc3RhbXAYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1w",
|
||||
"Ej8KDWN1cnJlbnRfc3RhdGUYBiABKA4yKC5teGFjY2Vzc19nYXRld2F5LnYx",
|
||||
"LkFsYXJtQ29uZGl0aW9uU3RhdGUSEAoIY2F0ZWdvcnkYByABKAkSEwoLZGVz",
|
||||
"Y3JpcHRpb24YCCABKAkSPQoZbGFzdF90cmFuc2l0aW9uX3RpbWVzdGFtcBgJ",
|
||||
"IAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASFQoNb3BlcmF0b3Jf",
|
||||
"dXNlchgKIAEoCRIYChBvcGVyYXRvcl9jb21tZW50GAsgASgJEjMKDWN1cnJl",
|
||||
"bnRfdmFsdWUYDCABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14VmFsdWUS",
|
||||
"MQoLbGltaXRfdmFsdWUYDSABKAsyHC5teGFjY2Vzc19nYXRld2F5LnYxLk14",
|
||||
"VmFsdWUSEAoIZGVncmFkZWQYDiABKAgSPwoPc291cmNlX3Byb3ZpZGVyGA8g",
|
||||
"ASgOMiYubXhhY2Nlc3NfZ2F0ZXdheS52MS5BbGFybVByb3ZpZGVyTW9kZRIf",
|
||||
"Chdmcm9tX3RydW5jYXRlZF9zbmFwc2hvdBgQIAEoCCKQAQoXQWNrbm93bGVk",
|
||||
"Z2VBbGFybVJlcXVlc3QSHQoVY2xpZW50X2NvcnJlbGF0aW9uX2lkGAIgASgJ",
|
||||
"EhwKFGFsYXJtX2Z1bGxfcmVmZXJlbmNlGAMgASgJEg8KB2NvbW1lbnQYBCAB",
|
||||
"KAkSFQoNb3BlcmF0b3JfdXNlchgFIAEoCUoECAEQAlIKc2Vzc2lvbl9pZCLx",
|
||||
"AQoVQWNrbm93bGVkZ2VBbGFybVJlcGx5EhYKDmNvcnJlbGF0aW9uX2lkGAIg",
|
||||
"ASgJEjwKD3Byb3RvY29sX3N0YXR1cxgDIAEoCzIjLm14YWNjZXNzX2dhdGV3",
|
||||
"YXkudjEuUHJvdG9jb2xTdGF0dXMSFAoHaHJlc3VsdBgEIAEoBUgAiAEBEjIK",
|
||||
"BnN0YXR1cxgFIAEoCzIiLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNQ",
|
||||
"cm94eRIaChJkaWFnbm9zdGljX21lc3NhZ2UYBiABKAlCCgoIX2hyZXN1bHRK",
|
||||
"BAgBEAJSCnNlc3Npb25faWQiUQoTU3RyZWFtQWxhcm1zUmVxdWVzdBIdChVj",
|
||||
"bGllbnRfY29ycmVsYXRpb25faWQYASABKAkSGwoTYWxhcm1fZmlsdGVyX3By",
|
||||
"ZWZpeBgCIAEoCSKEAgoQQWxhcm1GZWVkTWVzc2FnZRJACgxhY3RpdmVfYWxh",
|
||||
"cm0YASABKAsyKC5teGFjY2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25h",
|
||||
"cHNob3RIABIbChFzbmFwc2hvdF9jb21wbGV0ZRgCIAEoCEgAEkEKCnRyYW5z",
|
||||
"aXRpb24YAyABKAsyKy5teGFjY2Vzc19nYXRld2F5LnYxLk9uQWxhcm1UcmFu",
|
||||
"c2l0aW9uRXZlbnRIABJDCg9wcm92aWRlcl9zdGF0dXMYBCABKAsyKC5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJTdGF0dXNIAEIJCgdwYXls",
|
||||
"b2FkIpgBChNBbGFybVByb3ZpZGVyU3RhdHVzEjQKBG1vZGUYASABKA4yJi5t",
|
||||
"eGFjY2Vzc19nYXRld2F5LnYxLkFsYXJtUHJvdmlkZXJNb2RlEhAKCGRlZ3Jh",
|
||||
"ZGVkGAIgASgIEg4KBnJlYXNvbhgDIAEoCRIpCgVzaW5jZRgEIAEoCzIaLmdv",
|
||||
"b2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAi6wEKDU14U3RhdHVzUHJveHkSDwoH",
|
||||
"c3VjY2VzcxgBIAEoBRI3CghjYXRlZ29yeRgCIAEoDjIlLm14YWNjZXNzX2dh",
|
||||
"dGV3YXkudjEuTXhTdGF0dXNDYXRlZ29yeRI4CgtkZXRlY3RlZF9ieRgDIAEo",
|
||||
"DjIjLm14YWNjZXNzX2dhdGV3YXkudjEuTXhTdGF0dXNTb3VyY2USDgoGZGV0",
|
||||
"YWlsGAQgASgFEhQKDHJhd19jYXRlZ29yeRgFIAEoBRIXCg9yYXdfZGV0ZWN0",
|
||||
"ZWRfYnkYBiABKAUSFwoPZGlhZ25vc3RpY190ZXh0GAcgASgJIukDCgdNeFZh",
|
||||
"bHVlEjIKCWRhdGFfdHlwZRgBIAEoDjIfLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"TXhEYXRhVHlwZRIUCgx2YXJpYW50X3R5cGUYAiABKAkSDwoHaXNfbnVsbBgD",
|
||||
"IAEoCBIWCg5yYXdfZGlhZ25vc3RpYxgEIAEoCRIVCg1yYXdfZGF0YV90eXBl",
|
||||
"GAUgASgFEhQKCmJvb2xfdmFsdWUYCiABKAhIABIVCgtpbnQzMl92YWx1ZRgL",
|
||||
"IAEoBUgAEhUKC2ludDY0X3ZhbHVlGAwgASgDSAASFQoLZmxvYXRfdmFsdWUY",
|
||||
"DSABKAJIABIWCgxkb3VibGVfdmFsdWUYDiABKAFIABIWCgxzdHJpbmdfdmFs",
|
||||
"dWUYDyABKAlIABI1Cg90aW1lc3RhbXBfdmFsdWUYECABKAsyGi5nb29nbGUu",
|
||||
"cHJvdG9idWYuVGltZXN0YW1wSAASMwoLYXJyYXlfdmFsdWUYESABKAsyHC5t",
|
||||
"eGFjY2Vzc19nYXRld2F5LnYxLk14QXJyYXlIABITCglyYXdfdmFsdWUYEiAB",
|
||||
"KAxIABJAChJzcGFyc2VfYXJyYXlfdmFsdWUYEyABKAsyIi5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLk14U3BhcnNlQXJyYXlIAEIGCgRraW5kIv4ECgdNeEFycmF5",
|
||||
"EjoKEWVsZW1lbnRfZGF0YV90eXBlGAEgASgOMh8ubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5NeERhdGFUeXBlEhQKDHZhcmlhbnRfdHlwZRgCIAEoCRISCgpkaW1l",
|
||||
"bnNpb25zGAMgAygNEhYKDnJhd19kaWFnbm9zdGljGAQgASgJEh0KFXJhd19l",
|
||||
"bGVtZW50X2RhdGFfdHlwZRgFIAEoBRI1Cgtib29sX3ZhbHVlcxgKIAEoCzIe",
|
||||
"Lm14YWNjZXNzX2dhdGV3YXkudjEuQm9vbEFycmF5SAASNwoMaW50MzJfdmFs",
|
||||
"dWVzGAsgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5JbnQzMkFycmF5SAAS",
|
||||
"NwoMaW50NjRfdmFsdWVzGAwgASgLMh8ubXhhY2Nlc3NfZ2F0ZXdheS52MS5J",
|
||||
"bnQ2NEFycmF5SAASNwoMZmxvYXRfdmFsdWVzGA0gASgLMh8ubXhhY2Nlc3Nf",
|
||||
"Z2F0ZXdheS52MS5GbG9hdEFycmF5SAASOQoNZG91YmxlX3ZhbHVlcxgOIAEo",
|
||||
"CzIgLm14YWNjZXNzX2dhdGV3YXkudjEuRG91YmxlQXJyYXlIABI5Cg1zdHJp",
|
||||
"bmdfdmFsdWVzGA8gASgLMiAubXhhY2Nlc3NfZ2F0ZXdheS52MS5TdHJpbmdB",
|
||||
"cnJheUgAEj8KEHRpbWVzdGFtcF92YWx1ZXMYECABKAsyIy5teGFjY2Vzc19n",
|
||||
"YXRld2F5LnYxLlRpbWVzdGFtcEFycmF5SAASMwoKcmF3X3ZhbHVlcxgRIAEo",
|
||||
"CzIdLm14YWNjZXNzX2dhdGV3YXkudjEuUmF3QXJyYXlIAEIICgZ2YWx1ZXMi",
|
||||
"mQEKDU14U3BhcnNlQXJyYXkSOgoRZWxlbWVudF9kYXRhX3R5cGUYASABKA4y",
|
||||
"Hy5teGFjY2Vzc19nYXRld2F5LnYxLk14RGF0YVR5cGUSFAoMdG90YWxfbGVu",
|
||||
"Z3RoGAIgASgNEjYKCGVsZW1lbnRzGAMgAygLMiQubXhhY2Nlc3NfZ2F0ZXdh",
|
||||
"eS52MS5NeFNwYXJzZUVsZW1lbnQiTQoPTXhTcGFyc2VFbGVtZW50Eg0KBWlu",
|
||||
"ZGV4GAEgASgNEisKBXZhbHVlGAIgASgLMhwubXhhY2Nlc3NfZ2F0ZXdheS52",
|
||||
"MS5NeFZhbHVlIhsKCUJvb2xBcnJheRIOCgZ2YWx1ZXMYASADKAgiHAoKSW50",
|
||||
"MzJBcnJheRIOCgZ2YWx1ZXMYASADKAUiHAoKSW50NjRBcnJheRIOCgZ2YWx1",
|
||||
"ZXMYASADKAMiHAoKRmxvYXRBcnJheRIOCgZ2YWx1ZXMYASADKAIiHQoLRG91",
|
||||
"YmxlQXJyYXkSDgoGdmFsdWVzGAEgAygBIh0KC1N0cmluZ0FycmF5Eg4KBnZh",
|
||||
"bHVlcxgBIAMoCSI8Cg5UaW1lc3RhbXBBcnJheRIqCgZ2YWx1ZXMYASADKAsy",
|
||||
"Gi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wIhoKCFJhd0FycmF5Eg4KBnZh",
|
||||
"bHVlcxgBIAMoDCJYCg5Qcm90b2NvbFN0YXR1cxI1CgRjb2RlGAEgASgOMicu",
|
||||
"bXhhY2Nlc3NfZ2F0ZXdheS52MS5Qcm90b2NvbFN0YXR1c0NvZGUSDwoHbWVz",
|
||||
"c2FnZRgCIAEoCSqfCwoNTXhDb21tYW5kS2luZBIfChtNWF9DT01NQU5EX0tJ",
|
||||
"TkRfVU5TUEVDSUZJRUQQABIcChhNWF9DT01NQU5EX0tJTkRfUkVHSVNURVIQ",
|
||||
"ARIeChpNWF9DT01NQU5EX0tJTkRfVU5SRUdJU1RFUhACEhwKGE1YX0NPTU1B",
|
||||
"TkRfS0lORF9BRERfSVRFTRADEh0KGU1YX0NPTU1BTkRfS0lORF9BRERfSVRF",
|
||||
"TTIQBBIfChtNWF9DT01NQU5EX0tJTkRfUkVNT1ZFX0lURU0QBRIaChZNWF9D",
|
||||
"T01NQU5EX0tJTkRfQURWSVNFEAYSHQoZTVhfQ09NTUFORF9LSU5EX1VOX0FE",
|
||||
"VklTRRAHEiYKIk1YX0NPTU1BTkRfS0lORF9BRFZJU0VfU1VQRVJWSVNPUlkQ",
|
||||
"CBIlCiFNWF9DT01NQU5EX0tJTkRfQUREX0JVRkZFUkVEX0lURU0QCRIwCixN",
|
||||
"WF9DT01NQU5EX0tJTkRfU0VUX0JVRkZFUkVEX1VQREFURV9JTlRFUlZBTBAK",
|
||||
"EhsKF01YX0NPTU1BTkRfS0lORF9TVVNQRU5EEAsSHAoYTVhfQ09NTUFORF9L",
|
||||
"SU5EX0FDVElWQVRFEAwSGQoVTVhfQ09NTUFORF9LSU5EX1dSSVRFEA0SGgoW",
|
||||
"TVhfQ09NTUFORF9LSU5EX1dSSVRFMhAOEiEKHU1YX0NPTU1BTkRfS0lORF9X",
|
||||
"UklURV9TRUNVUkVEEA8SIgoeTVhfQ09NTUFORF9LSU5EX1dSSVRFX1NFQ1VS",
|
||||
"RUQyEBASJQohTVhfQ09NTUFORF9LSU5EX0FVVEhFTlRJQ0FURV9VU0VSEBES",
|
||||
"KAokTVhfQ09NTUFORF9LSU5EX0FSQ0hFU1RSQV9VU0VSX1RPX0lEEBISIQod",
|
||||
"TVhfQ09NTUFORF9LSU5EX0FERF9JVEVNX0JVTEsQExIkCiBNWF9DT01NQU5E",
|
||||
"X0tJTkRfQURWSVNFX0lURU1fQlVMSxAUEiQKIE1YX0NPTU1BTkRfS0lORF9S",
|
||||
"RU1PVkVfSVRFTV9CVUxLEBUSJwojTVhfQ09NTUFORF9LSU5EX1VOX0FEVklT",
|
||||
"RV9JVEVNX0JVTEsQFhIiCh5NWF9DT01NQU5EX0tJTkRfU1VCU0NSSUJFX0JV",
|
||||
"TEsQFxIkCiBNWF9DT01NQU5EX0tJTkRfVU5TVUJTQ1JJQkVfQlVMSxAYEiQK",
|
||||
"IE1YX0NPTU1BTkRfS0lORF9TVUJTQ1JJQkVfQUxBUk1TEBkSJgoiTVhfQ09N",
|
||||
"TUFORF9LSU5EX1VOU1VCU0NSSUJFX0FMQVJNUxAaEiUKIU1YX0NPTU1BTkRf",
|
||||
"S0lORF9BQ0tOT1dMRURHRV9BTEFSTRAbEicKI01YX0NPTU1BTkRfS0lORF9R",
|
||||
"VUVSWV9BQ1RJVkVfQUxBUk1TEBwSLQopTVhfQ09NTUFORF9LSU5EX0FDS05P",
|
||||
"V0xFREdFX0FMQVJNX0JZX05BTUUQHRIeChpNWF9DT01NQU5EX0tJTkRfV1JJ",
|
||||
"VEVfQlVMSxAeEh8KG01YX0NPTU1BTkRfS0lORF9XUklURTJfQlVMSxAfEiYK",
|
||||
"Ik1YX0NPTU1BTkRfS0lORF9XUklURV9TRUNVUkVEX0JVTEsQIBInCiNNWF9D",
|
||||
"T01NQU5EX0tJTkRfV1JJVEVfU0VDVVJFRDJfQlVMSxAhEh0KGU1YX0NPTU1B",
|
||||
"TkRfS0lORF9SRUFEX0JVTEsQIhIYChRNWF9DT01NQU5EX0tJTkRfUElORxBk",
|
||||
"EiUKIU1YX0NPTU1BTkRfS0lORF9HRVRfU0VTU0lPTl9TVEFURRBlEiMKH01Y",
|
||||
"X0NPTU1BTkRfS0lORF9HRVRfV09SS0VSX0lORk8QZhIgChxNWF9DT01NQU5E",
|
||||
"X0tJTkRfRFJBSU5fRVZFTlRTEGcSIwofTVhfQ09NTUFORF9LSU5EX1NIVVRE",
|
||||
"T1dOX1dPUktFUhBoKnoKEUFsYXJtUHJvdmlkZXJNb2RlEiMKH0FMQVJNX1BS",
|
||||
"T1ZJREVSX01PREVfVU5TUEVDSUZJRUQQABIgChxBTEFSTV9QUk9WSURFUl9N",
|
||||
"T0RFX0FMQVJNTUdSEAESHgoaQUxBUk1fUFJPVklERVJfTU9ERV9TVUJUQUcQ",
|
||||
"AiqtAgoNTXhFdmVudEZhbWlseRIfChtNWF9FVkVOVF9GQU1JTFlfVU5TUEVD",
|
||||
"SUZJRUQQABIiCh5NWF9FVkVOVF9GQU1JTFlfT05fREFUQV9DSEFOR0UQARIl",
|
||||
"CiFNWF9FVkVOVF9GQU1JTFlfT05fV1JJVEVfQ09NUExFVEUQAhImCiJNWF9F",
|
||||
"VkVOVF9GQU1JTFlfT1BFUkFUSU9OX0NPTVBMRVRFEAMSKwonTVhfRVZFTlRf",
|
||||
"RkFNSUxZX09OX0JVRkZFUkVEX0RBVEFfQ0hBTkdFEAQSJwojTVhfRVZFTlRf",
|
||||
"RkFNSUxZX09OX0FMQVJNX1RSQU5TSVRJT04QBRIyCi5NWF9FVkVOVF9GQU1J",
|
||||
"TFlfT05fQUxBUk1fUFJPVklERVJfTU9ERV9DSEFOR0VEEAYqygEKE0FsYXJt",
|
||||
"VHJhbnNpdGlvbktpbmQSJQohQUxBUk1fVFJBTlNJVElPTl9LSU5EX1VOU1BF",
|
||||
"Q0lGSUVEEAASHwobQUxBUk1fVFJBTlNJVElPTl9LSU5EX1JBSVNFEAESJQoh",
|
||||
"QUxBUk1fVFJBTlNJVElPTl9LSU5EX0FDS05PV0xFREdFEAISHwobQUxBUk1f",
|
||||
"VFJBTlNJVElPTl9LSU5EX0NMRUFSEAMSIwofQUxBUk1fVFJBTlNJVElPTl9L",
|
||||
"SU5EX1JFVFJJR0dFUhAEKqoBChNBbGFybUNvbmRpdGlvblN0YXRlEiUKIUFM",
|
||||
"QVJNX0NPTkRJVElPTl9TVEFURV9VTlNQRUNJRklFRBAAEiAKHEFMQVJNX0NP",
|
||||
"TkRJVElPTl9TVEFURV9BQ1RJVkUQARImCiJBTEFSTV9DT05ESVRJT05fU1RB",
|
||||
"VEVfQUNUSVZFX0FDS0VEEAISIgoeQUxBUk1fQ09ORElUSU9OX1NUQVRFX0lO",
|
||||
"QUNUSVZFEAMqpQMKEE14U3RhdHVzQ2F0ZWdvcnkSIgoeTVhfU1RBVFVTX0NB",
|
||||
"VEVHT1JZX1VOU1BFQ0lGSUVEEAASHgoaTVhfU1RBVFVTX0NBVEVHT1JZX1VO",
|
||||
"S05PV04QARIZChVNWF9TVEFUVVNfQ0FURUdPUllfT0sQAhIeChpNWF9TVEFU",
|
||||
"VVNfQ0FURUdPUllfUEVORElORxADEh4KGk1YX1NUQVRVU19DQVRFR09SWV9X",
|
||||
"QVJOSU5HEAQSKgomTVhfU1RBVFVTX0NBVEVHT1JZX0NPTU1VTklDQVRJT05f",
|
||||
"RVJST1IQBRIqCiZNWF9TVEFUVVNfQ0FURUdPUllfQ09ORklHVVJBVElPTl9F",
|
||||
"UlJPUhAGEigKJE1YX1NUQVRVU19DQVRFR09SWV9PUEVSQVRJT05BTF9FUlJP",
|
||||
"UhAHEiUKIU1YX1NUQVRVU19DQVRFR09SWV9TRUNVUklUWV9FUlJPUhAIEiUK",
|
||||
"IU1YX1NUQVRVU19DQVRFR09SWV9TT0ZUV0FSRV9FUlJPUhAJEiIKHk1YX1NU",
|
||||
"QVRVU19DQVRFR09SWV9PVEhFUl9FUlJPUhAKKsoCCg5NeFN0YXR1c1NvdXJj",
|
||||
"ZRIgChxNWF9TVEFUVVNfU09VUkNFX1VOU1BFQ0lGSUVEEAASHAoYTVhfU1RB",
|
||||
"VFVTX1NPVVJDRV9VTktOT1dOEAESIwofTVhfU1RBVFVTX1NPVVJDRV9SRVFV",
|
||||
"RVNUSU5HX0xNWBACEiMKH01YX1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19M",
|
||||
"TVgQAxIjCh9NWF9TVEFUVVNfU09VUkNFX1JFUVVFU1RJTkdfTk1YEAQSIwof",
|
||||
"TVhfU1RBVFVTX1NPVVJDRV9SRVNQT05ESU5HX05NWBAFEjEKLU1YX1NUQVRV",
|
||||
"U19TT1VSQ0VfUkVRVUVTVElOR19BVVRPTUFUSU9OX09CSkVDVBAGEjEKLU1Y",
|
||||
"X1NUQVRVU19TT1VSQ0VfUkVTUE9ORElOR19BVVRPTUFUSU9OX09CSkVDVBAH",
|
||||
"Kt0ECgpNeERhdGFUeXBlEhwKGE1YX0RBVEFfVFlQRV9VTlNQRUNJRklFRBAA",
|
||||
"EhgKFE1YX0RBVEFfVFlQRV9VTktOT1dOEAESGAoUTVhfREFUQV9UWVBFX05P",
|
||||
"X0RBVEEQAhIYChRNWF9EQVRBX1RZUEVfQk9PTEVBThADEhgKFE1YX0RBVEFf",
|
||||
"VFlQRV9JTlRFR0VSEAQSFgoSTVhfREFUQV9UWVBFX0ZMT0FUEAUSFwoTTVhf",
|
||||
"REFUQV9UWVBFX0RPVUJMRRAGEhcKE01YX0RBVEFfVFlQRV9TVFJJTkcQBxIV",
|
||||
"ChFNWF9EQVRBX1RZUEVfVElNRRAIEh0KGU1YX0RBVEFfVFlQRV9FTEFQU0VE",
|
||||
"X1RJTUUQCRIfChtNWF9EQVRBX1RZUEVfUkVGRVJFTkNFX1RZUEUQChIcChhN",
|
||||
"WF9EQVRBX1RZUEVfU1RBVFVTX1RZUEUQCxIVChFNWF9EQVRBX1RZUEVfRU5V",
|
||||
"TRAMEi0KKU1YX0RBVEFfVFlQRV9TRUNVUklUWV9DTEFTU0lGSUNBVElPTl9F",
|
||||
"TlVNEA0SIgoeTVhfREFUQV9UWVBFX0RBVEFfUVVBTElUWV9UWVBFEA4SHwob",
|
||||
"TVhfREFUQV9UWVBFX1FVQUxJRklFRF9FTlVNEA8SIQodTVhfREFUQV9UWVBF",
|
||||
"X1FVQUxJRklFRF9TVFJVQ1QQEBIpCiVNWF9EQVRBX1RZUEVfSU5URVJOQVRJ",
|
||||
"T05BTElaRURfU1RSSU5HEBESGwoXTVhfREFUQV9UWVBFX0JJR19TVFJJTkcQ",
|
||||
"EhIUChBNWF9EQVRBX1RZUEVfRU5EEBMqowMKElByb3RvY29sU3RhdHVzQ29k",
|
||||
"ZRIkCiBQUk9UT0NPTF9TVEFUVVNfQ09ERV9VTlNQRUNJRklFRBAAEhsKF1BS",
|
||||
"T1RPQ09MX1NUQVRVU19DT0RFX09LEAESKAokUFJPVE9DT0xfU1RBVFVTX0NP",
|
||||
"REVfSU5WQUxJRF9SRVFVRVNUEAISKgomUFJPVE9DT0xfU1RBVFVTX0NPREVf",
|
||||
"U0VTU0lPTl9OT1RfRk9VTkQQAxIqCiZQUk9UT0NPTF9TVEFUVVNfQ09ERV9T",
|
||||
"RVNTSU9OX05PVF9SRUFEWRAEEisKJ1BST1RPQ09MX1NUQVRVU19DT0RFX1dP",
|
||||
"UktFUl9VTkFWQUlMQUJMRRAFEiAKHFBST1RPQ09MX1NUQVRVU19DT0RFX1RJ",
|
||||
"TUVPVVQQBhIhCh1QUk9UT0NPTF9TVEFUVVNfQ09ERV9DQU5DRUxFRBAHEisK",
|
||||
"J1BST1RPQ09MX1NUQVRVU19DT0RFX1BST1RPQ09MX1ZJT0xBVElPThAIEikK",
|
||||
"JVBST1RPQ09MX1NUQVRVU19DT0RFX01YQUNDRVNTX0ZBSUxVUkUQCSq/AgoM",
|
||||
"U2Vzc2lvblN0YXRlEh0KGVNFU1NJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIa",
|
||||
"ChZTRVNTSU9OX1NUQVRFX0NSRUFUSU5HEAESIQodU0VTU0lPTl9TVEFURV9T",
|
||||
"VEFSVElOR19XT1JLRVIQAhIiCh5TRVNTSU9OX1NUQVRFX1dBSVRJTkdfRk9S",
|
||||
"X1BJUEUQAxIdChlTRVNTSU9OX1NUQVRFX0hBTkRTSEFLSU5HEAQSJQohU0VT",
|
||||
"U0lPTl9TVEFURV9JTklUSUFMSVpJTkdfV09SS0VSEAUSFwoTU0VTU0lPTl9T",
|
||||
"VEFURV9SRUFEWRAGEhkKFVNFU1NJT05fU1RBVEVfQ0xPU0lORxAHEhgKFFNF",
|
||||
"U1NJT05fU1RBVEVfQ0xPU0VEEAgSGQoVU0VTU0lPTl9TVEFURV9GQVVMVEVE",
|
||||
"EAkywwUKD014QWNjZXNzR2F0ZXdheRJdCgtPcGVuU2Vzc2lvbhInLm14YWNj",
|
||||
"ZXNzX2dhdGV3YXkudjEuT3BlblNlc3Npb25SZXF1ZXN0GiUubXhhY2Nlc3Nf",
|
||||
"Z2F0ZXdheS52MS5PcGVuU2Vzc2lvblJlcGx5EmAKDENsb3NlU2Vzc2lvbhIo",
|
||||
"Lm14YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVxdWVzdBomLm14",
|
||||
"YWNjZXNzX2dhdGV3YXkudjEuQ2xvc2VTZXNzaW9uUmVwbHkSVAoGSW52b2tl",
|
||||
"EiUubXhhY2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXF1ZXN0GiMubXhh",
|
||||
"Y2Nlc3NfZ2F0ZXdheS52MS5NeENvbW1hbmRSZXBseRJYCgxTdHJlYW1FdmVu",
|
||||
"dHMSKC5teGFjY2Vzc19nYXRld2F5LnYxLlN0cmVhbUV2ZW50c1JlcXVlc3Qa",
|
||||
"HC5teGFjY2Vzc19nYXRld2F5LnYxLk14RXZlbnQwARJsChBBY2tub3dsZWRn",
|
||||
"ZUFsYXJtEiwubXhhY2Nlc3NfZ2F0ZXdheS52MS5BY2tub3dsZWRnZUFsYXJt",
|
||||
"UmVxdWVzdBoqLm14YWNjZXNzX2dhdGV3YXkudjEuQWNrbm93bGVkZ2VBbGFy",
|
||||
"bVJlcGx5EmEKDFN0cmVhbUFsYXJtcxIoLm14YWNjZXNzX2dhdGV3YXkudjEu",
|
||||
"U3RyZWFtQWxhcm1zUmVxdWVzdBolLm14YWNjZXNzX2dhdGV3YXkudjEuQWxh",
|
||||
"cm1GZWVkTWVzc2FnZTABEm4KEVF1ZXJ5QWN0aXZlQWxhcm1zEi0ubXhhY2Nl",
|
||||
"c3NfZ2F0ZXdheS52MS5RdWVyeUFjdGl2ZUFsYXJtc1JlcXVlc3QaKC5teGFj",
|
||||
"Y2Vzc19nYXRld2F5LnYxLkFjdGl2ZUFsYXJtU25hcHNob3QwAUImqgIjWkIu",
|
||||
"TU9NLldXLk14R2F0ZXdheS5Db250cmFjdHMuUHJvdG9iBnByb3RvMw=="));
|
||||
descriptor = pbr::FileDescriptor.FromGeneratedCode(descriptorData,
|
||||
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[] {
|
||||
@@ -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.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.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.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),
|
||||
@@ -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.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.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.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),
|
||||
@@ -23224,6 +23225,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public QueryActiveAlarmsReplyPayload(QueryActiveAlarmsReplyPayload other) : this() {
|
||||
snapshots_ = other.snapshots_.Clone();
|
||||
snapshotTruncated_ = other.snapshotTruncated_;
|
||||
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -23244,6 +23246,26 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
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.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
public override bool Equals(object other) {
|
||||
@@ -23260,6 +23282,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
return true;
|
||||
}
|
||||
if(!snapshots_.Equals(other.snapshots_)) return false;
|
||||
if (SnapshotTruncated != other.SnapshotTruncated) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -23268,6 +23291,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
public override int GetHashCode() {
|
||||
int hash = 1;
|
||||
hash ^= snapshots_.GetHashCode();
|
||||
if (SnapshotTruncated != false) hash ^= SnapshotTruncated.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -23287,6 +23311,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
output.WriteRawMessage(this);
|
||||
#else
|
||||
snapshots_.WriteTo(output, _repeated_snapshots_codec);
|
||||
if (SnapshotTruncated != false) {
|
||||
output.WriteRawTag(16);
|
||||
output.WriteBool(SnapshotTruncated);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -23298,6 +23326,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
void pb::IBufferMessage.InternalWriteTo(ref pb::WriteContext output) {
|
||||
snapshots_.WriteTo(ref output, _repeated_snapshots_codec);
|
||||
if (SnapshotTruncated != false) {
|
||||
output.WriteRawTag(16);
|
||||
output.WriteBool(SnapshotTruncated);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(ref output);
|
||||
}
|
||||
@@ -23309,6 +23341,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
public int CalculateSize() {
|
||||
int size = 0;
|
||||
size += snapshots_.CalculateSize(_repeated_snapshots_codec);
|
||||
if (SnapshotTruncated != false) {
|
||||
size += 1 + 1;
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
size += _unknownFields.CalculateSize();
|
||||
}
|
||||
@@ -23322,6 +23357,9 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
return;
|
||||
}
|
||||
snapshots_.Add(other.snapshots_);
|
||||
if (other.SnapshotTruncated != false) {
|
||||
SnapshotTruncated = other.SnapshotTruncated;
|
||||
}
|
||||
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -23345,6 +23383,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
snapshots_.AddEntriesFrom(input, _repeated_snapshots_codec);
|
||||
break;
|
||||
}
|
||||
case 16: {
|
||||
SnapshotTruncated = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -23368,6 +23410,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
snapshots_.AddEntriesFrom(ref input, _repeated_snapshots_codec);
|
||||
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;
|
||||
degraded_ = other.degraded_;
|
||||
sourceProvider_ = other.sourceProvider_;
|
||||
fromTruncatedSnapshot_ = other.fromTruncatedSnapshot_;
|
||||
_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.CodeDom.Compiler.GeneratedCode("protoc", null)]
|
||||
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 (Degraded != other.Degraded) return false;
|
||||
if (SourceProvider != other.SourceProvider) return false;
|
||||
if (FromTruncatedSnapshot != other.FromTruncatedSnapshot) return false;
|
||||
return Equals(_unknownFields, other._unknownFields);
|
||||
}
|
||||
|
||||
@@ -26996,6 +27067,7 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
if (limitValue_ != null) hash ^= LimitValue.GetHashCode();
|
||||
if (Degraded != false) hash ^= Degraded.GetHashCode();
|
||||
if (SourceProvider != global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode.Unspecified) hash ^= SourceProvider.GetHashCode();
|
||||
if (FromTruncatedSnapshot != false) hash ^= FromTruncatedSnapshot.GetHashCode();
|
||||
if (_unknownFields != null) {
|
||||
hash ^= _unknownFields.GetHashCode();
|
||||
}
|
||||
@@ -27074,6 +27146,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
output.WriteRawTag(120);
|
||||
output.WriteEnum((int) SourceProvider);
|
||||
}
|
||||
if (FromTruncatedSnapshot != false) {
|
||||
output.WriteRawTag(128, 1);
|
||||
output.WriteBool(FromTruncatedSnapshot);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_unknownFields.WriteTo(output);
|
||||
}
|
||||
@@ -27144,6 +27220,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
output.WriteRawTag(120);
|
||||
output.WriteEnum((int) SourceProvider);
|
||||
}
|
||||
if (FromTruncatedSnapshot != false) {
|
||||
output.WriteRawTag(128, 1);
|
||||
output.WriteBool(FromTruncatedSnapshot);
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
_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) {
|
||||
size += 1 + pb::CodedOutputStream.ComputeEnumSize((int) SourceProvider);
|
||||
}
|
||||
if (FromTruncatedSnapshot != false) {
|
||||
size += 2 + 1;
|
||||
}
|
||||
if (_unknownFields != null) {
|
||||
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) {
|
||||
SourceProvider = other.SourceProvider;
|
||||
}
|
||||
if (other.FromTruncatedSnapshot != false) {
|
||||
FromTruncatedSnapshot = other.FromTruncatedSnapshot;
|
||||
}
|
||||
_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();
|
||||
break;
|
||||
}
|
||||
case 128: {
|
||||
FromTruncatedSnapshot = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -27450,6 +27540,10 @@ namespace ZB.MOM.WW.MxGateway.Contracts.Proto {
|
||||
SourceProvider = (global::ZB.MOM.WW.MxGateway.Contracts.Proto.AlarmProviderMode) input.ReadEnum();
|
||||
break;
|
||||
}
|
||||
case 128: {
|
||||
FromTruncatedSnapshot = input.ReadBool();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -726,6 +726,13 @@ message AcknowledgeAlarmReplyPayload {
|
||||
// stream.
|
||||
message QueryActiveAlarmsReplyPayload {
|
||||
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 {
|
||||
@@ -932,6 +939,16 @@ message ActiveAlarmSnapshot {
|
||||
// OnAlarmTransitionEvent.source_provider; always ALARMMGR or SUBTAG on the
|
||||
// wire (never UNSPECIFIED).
|
||||
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 {
|
||||
|
||||
@@ -170,6 +170,7 @@ public sealed class DashboardLdapLiveTests
|
||||
return new DashboardAuthenticator(
|
||||
new LdapAuthService(ldapOptions),
|
||||
new DashboardGroupRoleMapper(Options.Create(gatewayOptions)),
|
||||
Options.Create(gatewayOptions),
|
||||
NullLogger<DashboardAuthenticator>.Instance);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,11 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
private string _providerReason = string.Empty;
|
||||
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 string? _lastError;
|
||||
private GatewaySession? _session;
|
||||
@@ -110,6 +115,12 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SnapshotTruncated
|
||||
{
|
||||
get { lock (_sync) { return _snapshotTruncated; } }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
|
||||
{
|
||||
@@ -416,7 +427,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
QueryActiveAlarmsReplyPayload? payload = reply.Reply.QueryActiveAlarms;
|
||||
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
|
||||
// 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.
|
||||
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);
|
||||
foreach (ActiveAlarmSnapshot snapshot in snapshots)
|
||||
@@ -669,6 +686,7 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
_alarms[incoming.Key] = incoming.Value;
|
||||
}
|
||||
|
||||
_snapshotTruncated = snapshotTruncated;
|
||||
_currentAlarmsProjection = null;
|
||||
}
|
||||
}
|
||||
@@ -716,6 +734,10 @@ public sealed class GatewayAlarmMonitor : BackgroundService, IGatewayAlarmServic
|
||||
lock (_sync)
|
||||
{
|
||||
_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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,16 @@ public interface IGatewayAlarmService
|
||||
/// <summary>A point-in-time copy of the current active-alarm set.</summary>
|
||||
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>
|
||||
/// Attaches to the central alarm feed. The returned stream yields one
|
||||
/// <see cref="AlarmFeedMessage"/> per currently-active alarm, then a
|
||||
|
||||
@@ -34,6 +34,17 @@
|
||||
<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">
|
||||
<MetricCard Label="Active (unacked)" Value="@_unackedCount.ToString("N0")" />
|
||||
<MetricCard Label="Acknowledged" Value="@_ackedCount.ToString("N0")" />
|
||||
@@ -156,6 +167,7 @@
|
||||
@code {
|
||||
private readonly List<DashboardActiveAlarm> _alarms = [];
|
||||
private string? _queryError;
|
||||
private bool _snapshotTruncated;
|
||||
private int? _workerPid;
|
||||
private DateTimeOffset? _lastRefresh;
|
||||
private int _unackedCount;
|
||||
@@ -386,6 +398,7 @@
|
||||
{
|
||||
DashboardAlarmQueryResult result = await LiveData.QueryAlarmsAsync(_cts.Token);
|
||||
_queryError = result.Error;
|
||||
_snapshotTruncated = result.SnapshotTruncated;
|
||||
_workerPid = result.WorkerProcessId;
|
||||
_lastRefresh = DateTimeOffset.UtcNow;
|
||||
_alarms.Clear();
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
@inject AuthenticationStateProvider AuthenticationStateProvider
|
||||
@inject IDashboardSessionAdminService SessionAdminService
|
||||
@inject IDashboardSessionEventSubscriber EventSubscriber
|
||||
@inject IDashboardSessionAcl SessionAcl
|
||||
|
||||
<PageTitle>Dashboard Session</PageTitle>
|
||||
|
||||
@@ -114,7 +115,11 @@ else
|
||||
<span>@(_eventsConnected ? "live" : "offline")</span>
|
||||
</span>
|
||||
</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">
|
||||
Waiting for events. The dashboard subscribes to this session's events directly, so
|
||||
@@ -175,6 +180,10 @@ else
|
||||
private CancellationTokenSource? _eventPumpCancellation;
|
||||
private Task? _eventPumpTask;
|
||||
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 readonly LinkedList<MxEvent> _recentEvents = new();
|
||||
|
||||
@@ -203,7 +212,7 @@ else
|
||||
// renderer's dispatcher so the new subscription is published to
|
||||
// _eventSubscription from the same thread the pump's guard reads it on.
|
||||
await DetachEventsAsync();
|
||||
AttachEvents();
|
||||
await AttachEventsAsync();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -288,19 +297,34 @@ else
|
||||
// IDashboardEventBroadcaster, and the subscription registers with
|
||||
// EventsHubViewerRegistry, so the "nobody is watching" gate keeps working for
|
||||
// both audiences.
|
||||
// ACL posture is unchanged from the hub path: any dashboard Viewer may watch
|
||||
// any session (SEC-25 tracks the per-session ACL for both seams).
|
||||
private void AttachEvents()
|
||||
// ACL posture matches the hub path exactly: IDashboardSessionAcl gates this seam with the
|
||||
// same decision EventsHub.SubscribeSession applies (SEC-25 / TST-15). The gate wraps only
|
||||
// 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))
|
||||
{
|
||||
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);
|
||||
_eventPumpCancellation = new CancellationTokenSource();
|
||||
_eventsConnected = true;
|
||||
_subscribedSessionId = SessionId;
|
||||
|
||||
// Deliberately not awaited: the pump runs for as long as the page watches this
|
||||
// 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="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="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(
|
||||
IReadOnlyList<DashboardActiveAlarm> Alarms,
|
||||
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 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>
|
||||
/// 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"/>)
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using System.Security.Claims;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Ldap;
|
||||
using ZB.MOM.WW.Auth.Abstractions.Roles;
|
||||
using ZB.MOM.WW.Auth.AspNetCore;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
@@ -17,10 +19,15 @@ namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
/// </summary>
|
||||
/// <param name="ldapAuthService">Shared LDAP bind-then-search provider.</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>
|
||||
public sealed class DashboardAuthenticator(
|
||||
ILdapAuthService ldapAuthService,
|
||||
IGroupRoleMapper<string> roleMapper,
|
||||
IOptions<GatewayOptions> options,
|
||||
ILogger<DashboardAuthenticator> logger) : IDashboardAuthenticator
|
||||
{
|
||||
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.DisplayName,
|
||||
ldapResult.Groups,
|
||||
roles));
|
||||
roles,
|
||||
options.Value.Dashboard.GroupToTag));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -97,12 +105,23 @@ public sealed class DashboardAuthenticator(
|
||||
/// is role-based), so the shape change is non-breaking for dashboard consumers.
|
||||
/// </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(
|
||||
string username,
|
||||
string displayName,
|
||||
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 =
|
||||
[
|
||||
// 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
|
||||
// this claim value is the short group name, not the original DN.
|
||||
// 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,
|
||||
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(
|
||||
claims,
|
||||
|
||||
@@ -127,7 +127,11 @@ public sealed class DashboardLiveDataService : IDashboardLiveDataService, IAsync
|
||||
? null
|
||||
: _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
|
||||
|
||||
@@ -45,6 +45,10 @@ public static class DashboardServiceCollectionExtensions
|
||||
services.AddSingleton<DashboardApiKeyAuthorization>();
|
||||
services.AddSingleton<IDashboardApiKeyManagementService, DashboardApiKeyManagementService>();
|
||||
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
|
||||
// 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.
|
||||
|
||||
@@ -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.Text.Json;
|
||||
using Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
/// <summary>
|
||||
/// 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
|
||||
/// role claims. Validity is enforced by the data-protection time-limited
|
||||
/// protector; no separate signing keys are configured.
|
||||
/// The token is a data-protected JSON payload containing the user's name, role
|
||||
/// claims, and granted dashboard visibility tags. Validity is enforced by the
|
||||
/// data-protection time-limited protector; no separate signing keys are configured.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// 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
|
||||
// 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
|
||||
// bounds how long a stale role set survives a role change. 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 is deliberately deferred until per-session hub ACLs land, when tokens gain
|
||||
// session binding.
|
||||
// bounds how long a stale role set survives a role change. It now bounds a stale *tag* grant
|
||||
// the same way (SEC-25): the token carries the tags resolved from the caller's LDAP groups at
|
||||
// mint time, so revoking a GroupToTag entry takes effect for token-authenticated hub
|
||||
// connections within one lifetime — the natural place the deferred "tokens gain session
|
||||
// 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);
|
||||
|
||||
private readonly ITimeLimitedDataProtector _protector;
|
||||
private readonly IOptions<GatewayOptions> _options;
|
||||
|
||||
/// <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>
|
||||
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(options);
|
||||
_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>
|
||||
/// <returns>The data-protected bearer token string.</returns>
|
||||
public string Issue(ClaimsPrincipal user) => Issue(user, TokenLifetime);
|
||||
@@ -65,10 +76,20 @@ public sealed class HubTokenService
|
||||
internal string Issue(ClaimsPrincipal user, TimeSpan lifetime)
|
||||
{
|
||||
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(
|
||||
user.Identity?.Name,
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -107,6 +128,12 @@ public sealed class HubTokenService
|
||||
}
|
||||
|
||||
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(
|
||||
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.
|
||||
/// </remarks>
|
||||
/// <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)]
|
||||
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>
|
||||
public const string EventMessage = "MxEvent";
|
||||
@@ -33,27 +36,21 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
|
||||
/// client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// In v1 the hub-level <see cref="AuthorizeAttribute"/>
|
||||
/// (<c>HubClientsPolicy</c>) only checks that the caller carries one of
|
||||
/// the dashboard roles (Admin or Viewer); both roles may subscribe to
|
||||
/// any session id they choose. This is acceptable today because (a) the
|
||||
/// dashboard's per-session views show non-secret session metadata that
|
||||
/// any authenticated dashboard user can already see, and (b) tag values
|
||||
/// are stripped from the mirrored events by
|
||||
/// <see cref="DashboardEventBroadcaster"/> when
|
||||
/// <c>MxGateway:Dashboard:ShowTagValues</c> is false (the default), so the
|
||||
/// most sensitive payload cannot leak through this seam regardless of the
|
||||
/// still-missing ACL. The per-session ACL that gates the gRPC
|
||||
/// <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.
|
||||
/// The hub-level <see cref="AuthorizeAttribute"/> (<c>HubClientsPolicy</c>)
|
||||
/// only checks that the caller carries one of the dashboard roles, which by
|
||||
/// itself would let any Viewer subscribe to any session id they name. The
|
||||
/// per-session decision is <see cref="IDashboardSessionAcl"/>'s
|
||||
/// (SEC-25 / TST-15): Administrators see every session, a Viewer sees a
|
||||
/// session only when its tags intersect their granted tags, and an unknown
|
||||
/// session id is denied. A denied caller is not joined to the group and is
|
||||
/// not registered with <see cref="EventsHubViewerRegistry"/>, so the mirror
|
||||
/// stays off for a session nobody is legitimately watching. The same ACL
|
||||
/// gates the in-process seam used by the session-details page, so neither
|
||||
/// path is the weaker one.
|
||||
/// </remarks>
|
||||
/// <param name="sessionId">Session id to subscribe the caller to.</param>
|
||||
/// <returns>A task representing the subscription operation.</returns>
|
||||
/// <exception cref="HubException">The caller may not observe this session.</exception>
|
||||
public Task SubscribeSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId))
|
||||
@@ -61,6 +58,13 @@ public sealed class EventsHub(EventsHubViewerRegistry viewerRegistry) : Hub
|
||||
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
|
||||
// in which this connection is a group member but the broadcaster's gate still
|
||||
// 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(
|
||||
ldapAuthService,
|
||||
roleMapper,
|
||||
Options.Create(options),
|
||||
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 Microsoft.AspNetCore.DataProtection;
|
||||
using Microsoft.Extensions.Options;
|
||||
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
||||
using ZB.MOM.WW.MxGateway.Server.Dashboard;
|
||||
|
||||
namespace ZB.MOM.WW.MxGateway.Tests.Gateway.Dashboard;
|
||||
@@ -19,7 +21,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithNullNameAndNullNameIdentifier_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
// Issue from a principal with NO Name claim and NO NameIdentifier
|
||||
// claim. The Issue method's payload will then carry
|
||||
@@ -43,7 +45,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithName_ReturnsAuthenticatedPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
@@ -72,7 +74,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_TokenWithOnlyNameIdentifier_ReturnsPrincipal()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
@@ -93,7 +95,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_NullToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate(null));
|
||||
}
|
||||
@@ -102,7 +104,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_EmptyToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate(string.Empty));
|
||||
}
|
||||
@@ -111,7 +113,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_GarbageToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
|
||||
Assert.Null(service.Validate("this-is-not-a-protected-payload"));
|
||||
}
|
||||
@@ -123,7 +125,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void IssueThenValidate_FreshToken_RoundTripsIdentityAndRoles()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
ClaimsIdentity identity = new(
|
||||
[
|
||||
new Claim(ClaimTypes.Name, "bob"),
|
||||
@@ -163,7 +165,7 @@ public sealed class HubTokenServiceTests
|
||||
[Fact]
|
||||
public void Validate_ExpiredToken_ReturnsNull()
|
||||
{
|
||||
HubTokenService service = new(new EphemeralDataProtectionProvider());
|
||||
HubTokenService service = CreateService();
|
||||
ClaimsIdentity identity = new(
|
||||
[new Claim(ClaimTypes.Name, "carol")],
|
||||
authenticationType: "test");
|
||||
@@ -174,4 +176,85 @@ public sealed class HubTokenServiceTests
|
||||
|
||||
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 />
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> CurrentAlarms { get; set; } = [];
|
||||
|
||||
/// <inheritdoc />
|
||||
public bool SnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public async IAsyncEnumerable<AlarmFeedMessage> StreamAsync(
|
||||
string? alarmFilterPrefix,
|
||||
|
||||
@@ -371,6 +371,9 @@ public sealed class AlarmCommandExecutorTests
|
||||
/// <summary>Gets the last alarm filter prefix.</summary>
|
||||
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 />
|
||||
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
|
||||
{
|
||||
|
||||
@@ -473,6 +473,9 @@ public sealed class AlarmCommandHandlerTests
|
||||
/// <inheritdoc />
|
||||
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>
|
||||
public int PollCount { get; private set; }
|
||||
|
||||
|
||||
@@ -438,6 +438,9 @@ public sealed class AlarmDispatcherTests
|
||||
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>
|
||||
public int PollCount { get; private set; }
|
||||
|
||||
|
||||
@@ -80,6 +80,9 @@ public sealed class FailoverAlarmConsumerTests
|
||||
/// <inheritdoc />
|
||||
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 />
|
||||
public void Dispose() { }
|
||||
|
||||
@@ -132,6 +135,9 @@ public sealed class FailoverAlarmConsumerTests
|
||||
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 />
|
||||
public void Dispose() { }
|
||||
|
||||
|
||||
@@ -643,6 +643,9 @@ public sealed class MxAccessStaSessionTests
|
||||
get { lock (gate) return lastPollThreadId; }
|
||||
}
|
||||
|
||||
/// <summary>Gets or sets the truncation verdict reported for the last fetch.</summary>
|
||||
public bool LastSnapshotTruncated { get; set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Subscribe(SubscribeAlarmsCommand command, string sessionId)
|
||||
{
|
||||
|
||||
@@ -688,6 +688,88 @@ public sealed class WnWrapAlarmConsumerXmlTests
|
||||
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)
|
||||
{
|
||||
return new MxAlarmSnapshotRecord
|
||||
|
||||
@@ -342,6 +342,25 @@ public sealed class AlarmCommandHandler : IAlarmCommandHandler
|
||||
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 />
|
||||
public void PollOnce()
|
||||
{
|
||||
|
||||
@@ -158,16 +158,28 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
public IReadOnlyList<ActiveAlarmSnapshot> SnapshotActiveAlarms()
|
||||
{
|
||||
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();
|
||||
if (records.Count == 0) return Array.Empty<ActiveAlarmSnapshot>();
|
||||
List<ActiveAlarmSnapshot> snapshots = new List<ActiveAlarmSnapshot>(records.Count);
|
||||
foreach (MxAlarmSnapshotRecord record in records)
|
||||
{
|
||||
snapshots.Add(MapToSnapshot(record));
|
||||
snapshots.Add(MapToSnapshot(record, truncated));
|
||||
}
|
||||
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)
|
||||
{
|
||||
if (disposed) return;
|
||||
@@ -196,7 +208,7 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
degraded: record.Degraded);
|
||||
}
|
||||
|
||||
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record)
|
||||
private static ActiveAlarmSnapshot MapToSnapshot(MxAlarmSnapshotRecord record, bool truncated)
|
||||
{
|
||||
ActiveAlarmSnapshot snapshot = new ActiveAlarmSnapshot
|
||||
{
|
||||
@@ -212,6 +224,12 @@ public sealed class AlarmDispatcher : IDisposable
|
||||
Description = string.Empty,
|
||||
Degraded = record.Degraded,
|
||||
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)
|
||||
{
|
||||
|
||||
@@ -266,6 +266,17 @@ public sealed class FailoverAlarmConsumer : IMxAccessAlarmConsumer
|
||||
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;
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -74,6 +74,16 @@ public interface IAlarmCommandHandler : IDisposable
|
||||
/// <returns>The currently active alarms matching the filter.</returns>
|
||||
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>
|
||||
/// Drives a single poll of the underlying alarm consumer on the
|
||||
/// caller's thread. This is a no-op when there is no active
|
||||
|
||||
@@ -36,6 +36,20 @@ public interface IMxAccessAlarmConsumer : IDisposable
|
||||
/// </summary>
|
||||
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>
|
||||
/// Initializes the AVEVA alarm-client connection, registers as a
|
||||
/// consumer, and subscribes to the supplied alarm-provider expression.
|
||||
|
||||
@@ -977,6 +977,10 @@ public sealed class MxAccessCommandExecutor : IStaCommandExecutor
|
||||
command.Command.QueryActiveAlarmsCommand.AlarmFilterPrefix);
|
||||
QueryActiveAlarmsReplyPayload payload = new QueryActiveAlarmsReplyPayload();
|
||||
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);
|
||||
reply.QueryActiveAlarms = payload;
|
||||
return reply;
|
||||
|
||||
@@ -45,6 +45,16 @@ public sealed class SubtagAlarmConsumer : IMxAccessAlarmConsumer
|
||||
/// <summary>Fires once per synthesized alarm-state transition.</summary>
|
||||
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>
|
||||
/// Initializes the consumer over a subtag source and a watch list of
|
||||
/// alarm targets.
|
||||
|
||||
@@ -93,6 +93,7 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
|
||||
private long lastTruncationWarningMilliseconds = -TruncationWarningIntervalMilliseconds;
|
||||
private long truncatedFetchCount;
|
||||
private bool lastSnapshotTruncated;
|
||||
private wwAlarmConsumerClass? client;
|
||||
private wwAlarmConsumerClass? ackClient;
|
||||
private bool subscribed;
|
||||
@@ -125,6 +126,23 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
: 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>
|
||||
/// Resolves the per-fetch cap from the launcher-provided environment
|
||||
/// 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>
|
||||
/// Sink for the rate-limited truncated-fetch warning. Defaults to
|
||||
/// <see cref="Console.Error"/>, the stream
|
||||
@@ -413,14 +443,8 @@ public sealed class WnWrapAlarmConsumer : IMxAccessAlarmConsumer
|
||||
// docs/AlarmProbeFindings.md.)
|
||||
bool truncated = IsTruncatedFetch(fetchedRecordCount, maxAlarmsPerFetch);
|
||||
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions;
|
||||
int retainedCount;
|
||||
lock (syncRoot)
|
||||
{
|
||||
transitions = ComputeTransitions(latestSnapshot, next);
|
||||
ApplySnapshotUpdate(latestSnapshot, next, truncated);
|
||||
retainedCount = latestSnapshot.Count;
|
||||
}
|
||||
IReadOnlyList<MxAlarmTransitionEvent> transitions =
|
||||
FoldFetch(next, truncated, out int retainedCount);
|
||||
|
||||
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>
|
||||
/// Decides whether a fetch that came back holding
|
||||
/// <paramref name="fetchedRecordCount"/> records hit the cap.
|
||||
|
||||
Reference in New Issue
Block a user