diff --git a/client/src/protoFleet/api/generated/alerts/v1/alerts_pb.ts b/client/src/protoFleet/api/generated/alerts/v1/alerts_pb.ts index 8ee837c993..fdce1cd1e1 100644 --- a/client/src/protoFleet/api/generated/alerts/v1/alerts_pb.ts +++ b/client/src/protoFleet/api/generated/alerts/v1/alerts_pb.ts @@ -15,7 +15,7 @@ import type { Message } from "@bufbuild/protobuf"; export const file_alerts_v1_alerts: GenFile = /*@__PURE__*/ fileDesc( - "ChZhbGVydHMvdjEvYWxlcnRzLnByb3RvEglhbGVydHMudjEiUAoNV2ViaG9va0NvbmZpZxILCgN1cmwYASABKAkSFQoNYmVhcmVyX2hlYWRlchgCIAEoCRIbChNjbGVhcl9iZWFyZXJfaGVhZGVyGAMgASgIImYKClNtdHBDb25maWcSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgFEhAKCHVzZXJuYW1lGAMgASgJEgwKBGZyb20YBCABKAkSCgoCdG8YBSADKAkSEAoIcGFzc3dvcmQYBiABKAkiIgoLU2xhY2tDb25maWcSEwoLd2ViaG9va191cmwYASABKAkizwMKB0NoYW5uZWwSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgDEgwKBG5hbWUYAyABKAkSJAoEa2luZBgEIAEoDjIWLmFsZXJ0cy52MS5DaGFubmVsS2luZBIpCgd3ZWJob29rGAUgASgLMhguYWxlcnRzLnYxLldlYmhvb2tDb25maWcSIwoEc210cBgGIAEoCzIVLmFsZXJ0cy52MS5TbXRwQ29uZmlnEi4KCmNyZWF0ZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDHZhbGlkYXRlZF9hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoQdmFsaWRhdGlvbl9zdGF0ZRgKIAEoDjIaLmFsZXJ0cy52MS5WYWxpZGF0aW9uU3RhdGUSGAoQdmFsaWRhdGlvbl9lcnJvchgLIAEoCRISCgpoYXNfc2VjcmV0GAwgASgIEiUKBXNsYWNrGA0gASgLMhYuYWxlcnRzLnYxLlNsYWNrQ29uZmlnIhUKE0xpc3RDaGFubmVsc1JlcXVlc3QiPAoUTGlzdENoYW5uZWxzUmVzcG9uc2USJAoIY2hhbm5lbHMYASADKAsyEi5hbGVydHMudjEuQ2hhbm5lbCLNAQoUQ3JlYXRlQ2hhbm5lbFJlcXVlc3QSGAoEbmFtZRgBIAEoCUIKukgHcgUQARj/ARIkCgRraW5kGAIgASgOMhYuYWxlcnRzLnYxLkNoYW5uZWxLaW5kEikKB3dlYmhvb2sYAyABKAsyGC5hbGVydHMudjEuV2ViaG9va0NvbmZpZxIjCgRzbXRwGAQgASgLMhUuYWxlcnRzLnYxLlNtdHBDb25maWcSJQoFc2xhY2sYBSABKAsyFi5hbGVydHMudjEuU2xhY2tDb25maWciPAoVQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEiMKB2NoYW5uZWwYASABKAsyEi5hbGVydHMudjEuQ2hhbm5lbCLiAQoUVXBkYXRlQ2hhbm5lbFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIkCgRraW5kGAMgASgOMhYuYWxlcnRzLnYxLkNoYW5uZWxLaW5kEikKB3dlYmhvb2sYBCABKAsyGC5hbGVydHMudjEuV2ViaG9va0NvbmZpZxIjCgRzbXRwGAUgASgLMhUuYWxlcnRzLnYxLlNtdHBDb25maWcSJQoFc2xhY2sYBiABKAsyFi5hbGVydHMudjEuU2xhY2tDb25maWciPAoVVXBkYXRlQ2hhbm5lbFJlc3BvbnNlEiMKB2NoYW5uZWwYASABKAsyEi5hbGVydHMudjEuQ2hhbm5lbCIrChREZWxldGVDaGFubmVsUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIXChVEZWxldGVDaGFubmVsUmVzcG9uc2UivQEKElRlc3RDaGFubmVsUmVxdWVzdBIKCgJpZBgBIAEoCRIkCgRraW5kGAIgASgOMhYuYWxlcnRzLnYxLkNoYW5uZWxLaW5kEikKB3dlYmhvb2sYAyABKAsyGC5hbGVydHMudjEuV2ViaG9va0NvbmZpZxIjCgRzbXRwGAQgASgLMhUuYWxlcnRzLnYxLlNtdHBDb25maWcSJQoFc2xhY2sYBSABKAsyFi5hbGVydHMudjEuU2xhY2tDb25maWciRwoTVGVzdENoYW5uZWxSZXNwb25zZRIKCgJvaxgBIAEoCBINCgVlcnJvchgCIAEoCRIVCg1yZXNwb25zZV9jb2RlGAMgASgFIg8KDU9mZmxpbmVDb25maWcifQoOSGFzaHJhdGVDb25maWcSJQoEbW9kZRgBIAEoDjIXLmFsZXJ0cy52MS5IYXNocmF0ZU1vZGUSHQoFdmFsdWUYAiABKAFCDrpICxIJIQAAAAAAAAAAEiUKBHVuaXQYAyABKA4yFy5hbGVydHMudjEuSGFzaHJhdGVVbml0IkEKEVRlbXBlcmF0dXJlQ29uZmlnEiwKC21heF9jZWxzaXVzGAEgASgBQhe6SBQSEhkAAAAAAMBiQCEAAAAAAAAAACKCAgoJUnVsZVNjb3BlEiAKCHNpdGVfaWRzGAEgAygDQg66SAuSAQgQZCIEIgIgABImCgpkZXZpY2VfaWRzGAIgAygJQhK6SA+SAQwQ9AMiB3IFEAEY/wESJAoMYnVpbGRpbmdfaWRzGAMgAygDQg66SAuSAQgQZCIEIgIgABIgCghyYWNrX2lkcxgEIAMoA0IOukgLkgEIEGQiBCICIAASIQoJZ3JvdXBfaWRzGAUgAygDQg66SAuSAQgQZCIEIgIgABIRCglhbGxfc2l0ZXMYBiABKAgSGwoTZGV2aWNlX2lkc19yZWRhY3RlZBgHIAEoCBIQCghvcmdfd2lkZRgIIAEoCCKdAgoKUnVsZUNvbmZpZxIYCgRuYW1lGAEgASgJQgq6SAdyBRABGL4BEiUKEGR1cmF0aW9uX3NlY29uZHMYAiABKAVCC7pICBoGGICjBSg8EisKB29mZmxpbmUYAyABKAsyGC5hbGVydHMudjEuT2ZmbGluZUNvbmZpZ0gAEi0KCGhhc2hyYXRlGAQgASgLMhkuYWxlcnRzLnYxLkhhc2hyYXRlQ29uZmlnSAASMwoLdGVtcGVyYXR1cmUYBSABKAsyHC5hbGVydHMudjEuVGVtcGVyYXR1cmVDb25maWdIABIjCgVzY29wZRgGIAEoCzIULmFsZXJ0cy52MS5SdWxlU2NvcGVCGAoPdGVtcGxhdGVfY29uZmlnEgW6SAIIASJaCgtSdWxlUm91dGluZxIkCgRtb2RlGAEgASgOMhYuYWxlcnRzLnYxLlJvdXRpbmdNb2RlEiUKC2NoYW5uZWxfaWRzGAIgAygJQhC6SA2SAQoQZCIGcgQQARgUIoEDCgRSdWxlEgoKAmlkGAEgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgCIAEoAxIMCgRuYW1lGAMgASgJEikKCHRlbXBsYXRlGAQgASgOMhcuYWxlcnRzLnYxLlJ1bGVUZW1wbGF0ZRINCgVncm91cBgFIAEoCRIQCghzZXZlcml0eRgGIAEoCRIPCgdzdW1tYXJ5GAcgASgJEhMKC2Rlc2NyaXB0aW9uGAggASgJEhgKEGR1cmF0aW9uX3NlY29uZHMYCSABKAUSDwoHZW5hYmxlZBgKIAEoCBIlCgZvcmlnaW4YCyABKA4yFS5hbGVydHMudjEuUnVsZU9yaWdpbhIlCgZjb25maWcYDCABKAsyFS5hbGVydHMudjEuUnVsZUNvbmZpZxInCgdyb3V0aW5nGA0gASgLMhYuYWxlcnRzLnYxLlJ1bGVSb3V0aW5nEhoKEmNvbmZpZ19vdXRfb2Zfc3luYxgOIAEoCBIWCg5jb25maWdfdW5rbm93bhgPIAEoCCISChBMaXN0UnVsZXNSZXF1ZXN0IjMKEUxpc3RSdWxlc1Jlc3BvbnNlEh4KBXJ1bGVzGAEgAygLMg8uYWxlcnRzLnYxLlJ1bGUiawoRQ3JlYXRlUnVsZVJlcXVlc3QSLQoGY29uZmlnGAEgASgLMhUuYWxlcnRzLnYxLlJ1bGVDb25maWdCBrpIA8gBARInCgdyb3V0aW5nGAIgASgLMhYuYWxlcnRzLnYxLlJ1bGVSb3V0aW5nIjMKEkNyZWF0ZVJ1bGVSZXNwb25zZRIdCgRydWxlGAEgASgLMg8uYWxlcnRzLnYxLlJ1bGUiVwoRVXBkYXRlUnVsZVJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESLQoGY29uZmlnGAIgASgLMhUuYWxlcnRzLnYxLlJ1bGVDb25maWdCBrpIA8gBASIzChJVcGRhdGVSdWxlUmVzcG9uc2USHQoEcnVsZRgBIAEoCzIPLmFsZXJ0cy52MS5SdWxlIigKEURlbGV0ZVJ1bGVSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIhQKEkRlbGV0ZVJ1bGVSZXNwb25zZSJiChVTZXRSdWxlUm91dGluZ1JlcXVlc3QSGAoHcnVsZV9pZBgBIAEoCUIHukgEcgIQARIvCgdyb3V0aW5nGAIgASgLMhYuYWxlcnRzLnYxLlJ1bGVSb3V0aW5nQga6SAPIAQEiNwoWU2V0UnVsZVJvdXRpbmdSZXNwb25zZRIdCgRydWxlGAEgASgLMg8uYWxlcnRzLnYxLlJ1bGUiJwoQUGF1c2VSdWxlUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIyChFQYXVzZVJ1bGVSZXNwb25zZRIdCgRydWxlGAEgASgLMg8uYWxlcnRzLnYxLlJ1bGUiKAoRUmVzdW1lUnVsZVJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiMwoSUmVzdW1lUnVsZVJlc3BvbnNlEh0KBHJ1bGUYASABKAsyDy5hbGVydHMudjEuUnVsZSKVAQoWTWFpbnRlbmFuY2VXaW5kb3dTY29wZRIzCgRraW5kGAEgASgOMiUuYWxlcnRzLnYxLk1haW50ZW5hbmNlV2luZG93U2NvcGVLaW5kEg8KB3J1bGVfaWQYAiABKAkSEAoIZ3JvdXBfaWQYAyABKAkSDwoHc2l0ZV9pZBgEIAEoCRISCgpkZXZpY2VfaWRzGAUgAygJIqsCChFNYWludGVuYW5jZVdpbmRvdxIKCgJpZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAMSMAoFc2NvcGUYAyABKAsyIS5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3dTY29wZRItCglzdGFydHNfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEisKB2VuZHNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg8KB2NvbW1lbnQYBiABKAkSEgoKY3JlYXRlZF9ieRgHIAEoCRIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIOCgZhY3RpdmUYCSABKAgiHwodTGlzdE1haW50ZW5hbmNlV2luZG93c1JlcXVlc3QiWwoeTGlzdE1haW50ZW5hbmNlV2luZG93c1Jlc3BvbnNlEjkKE21haW50ZW5hbmNlX3dpbmRvd3MYASADKAsyHC5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3ciyQEKHkNyZWF0ZU1haW50ZW5hbmNlV2luZG93UmVxdWVzdBIwCgVzY29wZRgBIAEoCzIhLmFsZXJ0cy52MS5NYWludGVuYW5jZVdpbmRvd1Njb3BlEi0KCXN0YXJ0c19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASKwoHZW5kc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoHY29tbWVudBgEIAEoCUIIukgFcgMYgAgiWwofQ3JlYXRlTWFpbnRlbmFuY2VXaW5kb3dSZXNwb25zZRI4ChJtYWludGVuYW5jZV93aW5kb3cYASABKAsyHC5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3ci3gEKHlVwZGF0ZU1haW50ZW5hbmNlV2luZG93UmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIwCgVzY29wZRgCIAEoCzIhLmFsZXJ0cy52MS5NYWludGVuYW5jZVdpbmRvd1Njb3BlEi0KCXN0YXJ0c19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASKwoHZW5kc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoHY29tbWVudBgFIAEoCUIIukgFcgMYgAgiWwofVXBkYXRlTWFpbnRlbmFuY2VXaW5kb3dSZXNwb25zZRI4ChJtYWludGVuYW5jZV93aW5kb3cYASABKAsyHC5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3ciNQoeRGVsZXRlTWFpbnRlbmFuY2VXaW5kb3dSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIiEKH0RlbGV0ZU1haW50ZW5hbmNlV2luZG93UmVzcG9uc2Ui6gIKEUFsZXJ0SGlzdG9yeUVudHJ5EgoKAmlkGAEgASgJEi8KC3JlY2VpdmVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgphbGVydF9uYW1lGAMgASgJEg4KBnN0YXR1cxgEIAEoCRIQCghzZXZlcml0eRgFIAEoCRISCgpydWxlX2dyb3VwGAYgASgJEhMKC2ZpbmdlcnByaW50GAcgASgJEhEKCWRldmljZV9pZBgIIAEoCRITCgtkZXZpY2VfbmFtZRgJIAEoCRISCgpkZXZpY2VfbWFjGAogASgJEhAKCHRlbXBsYXRlGAsgASgJEg8KB3N1bW1hcnkYDCABKAkSLQoJc3RhcnRzX2F0GA0gASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIrCgdlbmRzX2F0GA4gASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKcAQoRTGlzdEFsZXJ0c1JlcXVlc3QSGgoJYmVmb3JlX2lkGAEgASgJQge6SARyAhhAEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABITCgthY3RpdmVfb25seRgDIAEoCBIcCgphbGVydF9uYW1lGAQgASgJQgi6SAVyAxi+ARIcCgpydWxlX2dyb3VwGAUgASgJQgi6SAVyAxi+ASJpChJMaXN0QWxlcnRzUmVzcG9uc2USLAoGYWxlcnRzGAEgAygLMhwuYWxlcnRzLnYxLkFsZXJ0SGlzdG9yeUVudHJ5EhAKCGhhc19tb3JlGAIgASgIEhMKC25leHRfY3Vyc29yGAMgASgJIpsBChBBY3RpdmVBbGVydEdyb3VwEhIKCmFsZXJ0X25hbWUYASABKAkSEgoKcnVsZV9ncm91cBgCIAEoCRIUCgxkZXZpY2VfY291bnQYAyABKAMSEwoLYWxlcnRfY291bnQYBCABKAMSNAoQZmlyc3Rfc3RhcnRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXAiHgocTGlzdEFjdGl2ZUFsZXJ0R3JvdXBzUmVxdWVzdCJeCh1MaXN0QWN0aXZlQWxlcnRHcm91cHNSZXNwb25zZRIrCgZncm91cHMYASADKAsyGy5hbGVydHMudjEuQWN0aXZlQWxlcnRHcm91cBIQCghoYXNfbW9yZRgCIAEoCCp0CgtDaGFubmVsS2luZBIcChhDSEFOTkVMX0tJTkRfVU5TUEVDSUZJRUQQABIYChRDSEFOTkVMX0tJTkRfV0VCSE9PSxABEhUKEUNIQU5ORUxfS0lORF9TTVRQEAISFgoSQ0hBTk5FTF9LSU5EX1NMQUNLEAMqhwEKD1ZhbGlkYXRpb25TdGF0ZRIgChxWQUxJREFUSU9OX1NUQVRFX1VOU1BFQ0lGSUVEEAASHAoYVkFMSURBVElPTl9TVEFURV9QRU5ESU5HEAESFwoTVkFMSURBVElPTl9TVEFURV9PSxACEhsKF1ZBTElEQVRJT05fU1RBVEVfRkFJTEVEEAMqqQIKDFJ1bGVUZW1wbGF0ZRIdChlSVUxFX1RFTVBMQVRFX1VOU1BFQ0lGSUVEEAASGQoVUlVMRV9URU1QTEFURV9PRkZMSU5FEAESGgoWUlVMRV9URU1QTEFURV9IQVNIUkFURRACEh0KGVJVTEVfVEVNUExBVEVfVEVNUEVSQVRVUkUQAxIWChJSVUxFX1RFTVBMQVRFX1BPT0wQBBIhCh1SVUxFX1RFTVBMQVRFX0NPTU1BTkRfRkFJTFVSRRAFEiAKHFJVTEVfVEVNUExBVEVfVEVMRU1FVFJZX1BPTEwQBhIiCh5SVUxFX1RFTVBMQVRFX01RVFRfQ1VSVEFJTE1FTlQQBxIjCh9SVUxFX1RFTVBMQVRFX01RVFRfRElTQ09OTkVDVEVEEAgqXAoKUnVsZU9yaWdpbhIbChdSVUxFX09SSUdJTl9VTlNQRUNJRklFRBAAEhsKF1JVTEVfT1JJR0lOX1BST1ZJU0lPTkVEEAESFAoQUlVMRV9PUklHSU5fVVNFUhACKmkKDEhhc2hyYXRlTW9kZRIdChlIQVNIUkFURV9NT0RFX1VOU1BFQ0lGSUVEEAASHgoaSEFTSFJBVEVfTU9ERV9QQ1RfRVhQRUNURUQQARIaChZIQVNIUkFURV9NT0RFX0FCU09MVVRFEAIqZQoMSGFzaHJhdGVVbml0Eh0KGUhBU0hSQVRFX1VOSVRfVU5TUEVDSUZJRUQQABIaChZIQVNIUkFURV9VTklUX1RFUkFIQVNIEAESGgoWSEFTSFJBVEVfVU5JVF9QRVRBSEFTSBACKnUKC1JvdXRpbmdNb2RlEhwKGFJPVVRJTkdfTU9ERV9VTlNQRUNJRklFRBAAEhgKFFJPVVRJTkdfTU9ERV9ERUZBVUxUEAESFwoTUk9VVElOR19NT0RFX0NVU1RPTRACEhUKEVJPVVRJTkdfTU9ERV9OT05FEAMq7gEKGk1haW50ZW5hbmNlV2luZG93U2NvcGVLaW5kEi0KKU1BSU5URU5BTkNFX1dJTkRPV19TQ09QRV9LSU5EX1VOU1BFQ0lGSUVEEAASJgoiTUFJTlRFTkFOQ0VfV0lORE9XX1NDT1BFX0tJTkRfUlVMRRABEicKI01BSU5URU5BTkNFX1dJTkRPV19TQ09QRV9LSU5EX0dST1VQEAISJgoiTUFJTlRFTkFOQ0VfV0lORE9XX1NDT1BFX0tJTkRfU0lURRADEigKJE1BSU5URU5BTkNFX1dJTkRPV19TQ09QRV9LSU5EX0RFVklDRRAEMqsDCg5DaGFubmVsU2VydmljZRJPCgxMaXN0Q2hhbm5lbHMSHi5hbGVydHMudjEuTGlzdENoYW5uZWxzUmVxdWVzdBofLmFsZXJ0cy52MS5MaXN0Q2hhbm5lbHNSZXNwb25zZRJSCg1DcmVhdGVDaGFubmVsEh8uYWxlcnRzLnYxLkNyZWF0ZUNoYW5uZWxSZXF1ZXN0GiAuYWxlcnRzLnYxLkNyZWF0ZUNoYW5uZWxSZXNwb25zZRJSCg1VcGRhdGVDaGFubmVsEh8uYWxlcnRzLnYxLlVwZGF0ZUNoYW5uZWxSZXF1ZXN0GiAuYWxlcnRzLnYxLlVwZGF0ZUNoYW5uZWxSZXNwb25zZRJSCg1EZWxldGVDaGFubmVsEh8uYWxlcnRzLnYxLkRlbGV0ZUNoYW5uZWxSZXF1ZXN0GiAuYWxlcnRzLnYxLkRlbGV0ZUNoYW5uZWxSZXNwb25zZRJMCgtUZXN0Q2hhbm5lbBIdLmFsZXJ0cy52MS5UZXN0Q2hhbm5lbFJlcXVlc3QaHi5hbGVydHMudjEuVGVzdENoYW5uZWxSZXNwb25zZTKgBAoLUnVsZVNlcnZpY2USRgoJTGlzdFJ1bGVzEhsuYWxlcnRzLnYxLkxpc3RSdWxlc1JlcXVlc3QaHC5hbGVydHMudjEuTGlzdFJ1bGVzUmVzcG9uc2USRgoJUGF1c2VSdWxlEhsuYWxlcnRzLnYxLlBhdXNlUnVsZVJlcXVlc3QaHC5hbGVydHMudjEuUGF1c2VSdWxlUmVzcG9uc2USSQoKUmVzdW1lUnVsZRIcLmFsZXJ0cy52MS5SZXN1bWVSdWxlUmVxdWVzdBodLmFsZXJ0cy52MS5SZXN1bWVSdWxlUmVzcG9uc2USSQoKQ3JlYXRlUnVsZRIcLmFsZXJ0cy52MS5DcmVhdGVSdWxlUmVxdWVzdBodLmFsZXJ0cy52MS5DcmVhdGVSdWxlUmVzcG9uc2USSQoKVXBkYXRlUnVsZRIcLmFsZXJ0cy52MS5VcGRhdGVSdWxlUmVxdWVzdBodLmFsZXJ0cy52MS5VcGRhdGVSdWxlUmVzcG9uc2USSQoKRGVsZXRlUnVsZRIcLmFsZXJ0cy52MS5EZWxldGVSdWxlUmVxdWVzdBodLmFsZXJ0cy52MS5EZWxldGVSdWxlUmVzcG9uc2USVQoOU2V0UnVsZVJvdXRpbmcSIC5hbGVydHMudjEuU2V0UnVsZVJvdXRpbmdSZXF1ZXN0GiEuYWxlcnRzLnYxLlNldFJ1bGVSb3V0aW5nUmVzcG9uc2Uy3wMKGE1haW50ZW5hbmNlV2luZG93U2VydmljZRJtChZMaXN0TWFpbnRlbmFuY2VXaW5kb3dzEiguYWxlcnRzLnYxLkxpc3RNYWludGVuYW5jZVdpbmRvd3NSZXF1ZXN0GikuYWxlcnRzLnYxLkxpc3RNYWludGVuYW5jZVdpbmRvd3NSZXNwb25zZRJwChdDcmVhdGVNYWludGVuYW5jZVdpbmRvdxIpLmFsZXJ0cy52MS5DcmVhdGVNYWludGVuYW5jZVdpbmRvd1JlcXVlc3QaKi5hbGVydHMudjEuQ3JlYXRlTWFpbnRlbmFuY2VXaW5kb3dSZXNwb25zZRJwChdVcGRhdGVNYWludGVuYW5jZVdpbmRvdxIpLmFsZXJ0cy52MS5VcGRhdGVNYWludGVuYW5jZVdpbmRvd1JlcXVlc3QaKi5hbGVydHMudjEuVXBkYXRlTWFpbnRlbmFuY2VXaW5kb3dSZXNwb25zZRJwChdEZWxldGVNYWludGVuYW5jZVdpbmRvdxIpLmFsZXJ0cy52MS5EZWxldGVNYWludGVuYW5jZVdpbmRvd1JlcXVlc3QaKi5hbGVydHMudjEuRGVsZXRlTWFpbnRlbmFuY2VXaW5kb3dSZXNwb25zZTLHAQoOSGlzdG9yeVNlcnZpY2USSQoKTGlzdEFsZXJ0cxIcLmFsZXJ0cy52MS5MaXN0QWxlcnRzUmVxdWVzdBodLmFsZXJ0cy52MS5MaXN0QWxlcnRzUmVzcG9uc2USagoVTGlzdEFjdGl2ZUFsZXJ0R3JvdXBzEicuYWxlcnRzLnYxLkxpc3RBY3RpdmVBbGVydEdyb3Vwc1JlcXVlc3QaKC5hbGVydHMudjEuTGlzdEFjdGl2ZUFsZXJ0R3JvdXBzUmVzcG9uc2VCqAEKDWNvbS5hbGVydHMudjFCC0FsZXJ0c1Byb3RvUAFaRWdpdGh1Yi5jb20vYmxvY2svcHJvdG8tZmxlZXQvc2VydmVyL2dlbmVyYXRlZC9ncnBjL2FsZXJ0cy92MTthbGVydHN2MaICA0FYWKoCCUFsZXJ0cy5WMcoCCUFsZXJ0c1xWMeICFUFsZXJ0c1xWMVxHUEJNZXRhZGF0YeoCCkFsZXJ0czo6VjFiBnByb3RvMw", + "ChZhbGVydHMvdjEvYWxlcnRzLnByb3RvEglhbGVydHMudjEiUAoNV2ViaG9va0NvbmZpZxILCgN1cmwYASABKAkSFQoNYmVhcmVyX2hlYWRlchgCIAEoCRIbChNjbGVhcl9iZWFyZXJfaGVhZGVyGAMgASgIImYKClNtdHBDb25maWcSDAoEaG9zdBgBIAEoCRIMCgRwb3J0GAIgASgFEhAKCHVzZXJuYW1lGAMgASgJEgwKBGZyb20YBCABKAkSCgoCdG8YBSADKAkSEAoIcGFzc3dvcmQYBiABKAkiIgoLU2xhY2tDb25maWcSEwoLd2ViaG9va191cmwYASABKAkizwMKB0NoYW5uZWwSCgoCaWQYASABKAkSFwoPb3JnYW5pemF0aW9uX2lkGAIgASgDEgwKBG5hbWUYAyABKAkSJAoEa2luZBgEIAEoDjIWLmFsZXJ0cy52MS5DaGFubmVsS2luZBIpCgd3ZWJob29rGAUgASgLMhguYWxlcnRzLnYxLldlYmhvb2tDb25maWcSIwoEc210cBgGIAEoCzIVLmFsZXJ0cy52MS5TbXRwQ29uZmlnEi4KCmNyZWF0ZWRfYXQYByABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEi4KCnVwZGF0ZWRfYXQYCCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEjAKDHZhbGlkYXRlZF9hdBgJIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASNAoQdmFsaWRhdGlvbl9zdGF0ZRgKIAEoDjIaLmFsZXJ0cy52MS5WYWxpZGF0aW9uU3RhdGUSGAoQdmFsaWRhdGlvbl9lcnJvchgLIAEoCRISCgpoYXNfc2VjcmV0GAwgASgIEiUKBXNsYWNrGA0gASgLMhYuYWxlcnRzLnYxLlNsYWNrQ29uZmlnIhUKE0xpc3RDaGFubmVsc1JlcXVlc3QiPAoUTGlzdENoYW5uZWxzUmVzcG9uc2USJAoIY2hhbm5lbHMYASADKAsyEi5hbGVydHMudjEuQ2hhbm5lbCLNAQoUQ3JlYXRlQ2hhbm5lbFJlcXVlc3QSGAoEbmFtZRgBIAEoCUIKukgHcgUQARj/ARIkCgRraW5kGAIgASgOMhYuYWxlcnRzLnYxLkNoYW5uZWxLaW5kEikKB3dlYmhvb2sYAyABKAsyGC5hbGVydHMudjEuV2ViaG9va0NvbmZpZxIjCgRzbXRwGAQgASgLMhUuYWxlcnRzLnYxLlNtdHBDb25maWcSJQoFc2xhY2sYBSABKAsyFi5hbGVydHMudjEuU2xhY2tDb25maWciPAoVQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlEiMKB2NoYW5uZWwYASABKAsyEi5hbGVydHMudjEuQ2hhbm5lbCLiAQoUVXBkYXRlQ2hhbm5lbFJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESGAoEbmFtZRgCIAEoCUIKukgHcgUQARj/ARIkCgRraW5kGAMgASgOMhYuYWxlcnRzLnYxLkNoYW5uZWxLaW5kEikKB3dlYmhvb2sYBCABKAsyGC5hbGVydHMudjEuV2ViaG9va0NvbmZpZxIjCgRzbXRwGAUgASgLMhUuYWxlcnRzLnYxLlNtdHBDb25maWcSJQoFc2xhY2sYBiABKAsyFi5hbGVydHMudjEuU2xhY2tDb25maWciPAoVVXBkYXRlQ2hhbm5lbFJlc3BvbnNlEiMKB2NoYW5uZWwYASABKAsyEi5hbGVydHMudjEuQ2hhbm5lbCIrChREZWxldGVDaGFubmVsUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIXChVEZWxldGVDaGFubmVsUmVzcG9uc2UivQEKElRlc3RDaGFubmVsUmVxdWVzdBIKCgJpZBgBIAEoCRIkCgRraW5kGAIgASgOMhYuYWxlcnRzLnYxLkNoYW5uZWxLaW5kEikKB3dlYmhvb2sYAyABKAsyGC5hbGVydHMudjEuV2ViaG9va0NvbmZpZxIjCgRzbXRwGAQgASgLMhUuYWxlcnRzLnYxLlNtdHBDb25maWcSJQoFc2xhY2sYBSABKAsyFi5hbGVydHMudjEuU2xhY2tDb25maWciRwoTVGVzdENoYW5uZWxSZXNwb25zZRIKCgJvaxgBIAEoCBINCgVlcnJvchgCIAEoCRIVCg1yZXNwb25zZV9jb2RlGAMgASgFIg8KDU9mZmxpbmVDb25maWcifQoOSGFzaHJhdGVDb25maWcSJQoEbW9kZRgBIAEoDjIXLmFsZXJ0cy52MS5IYXNocmF0ZU1vZGUSHQoFdmFsdWUYAiABKAFCDrpICxIJIQAAAAAAAAAAEiUKBHVuaXQYAyABKA4yFy5hbGVydHMudjEuSGFzaHJhdGVVbml0IkEKEVRlbXBlcmF0dXJlQ29uZmlnEiwKC21heF9jZWxzaXVzGAEgASgBQhe6SBQSEhkAAAAAAMBiQCEAAAAAAAAAACKCAgoJUnVsZVNjb3BlEiAKCHNpdGVfaWRzGAEgAygDQg66SAuSAQgQZCIEIgIgABImCgpkZXZpY2VfaWRzGAIgAygJQhK6SA+SAQwQ9AMiB3IFEAEY/wESJAoMYnVpbGRpbmdfaWRzGAMgAygDQg66SAuSAQgQZCIEIgIgABIgCghyYWNrX2lkcxgEIAMoA0IOukgLkgEIEGQiBCICIAASIQoJZ3JvdXBfaWRzGAUgAygDQg66SAuSAQgQZCIEIgIgABIRCglhbGxfc2l0ZXMYBiABKAgSGwoTZGV2aWNlX2lkc19yZWRhY3RlZBgHIAEoCBIQCghvcmdfd2lkZRgIIAEoCCKdAgoKUnVsZUNvbmZpZxIYCgRuYW1lGAEgASgJQgq6SAdyBRABGL4BEiUKEGR1cmF0aW9uX3NlY29uZHMYAiABKAVCC7pICBoGGICjBSg8EisKB29mZmxpbmUYAyABKAsyGC5hbGVydHMudjEuT2ZmbGluZUNvbmZpZ0gAEi0KCGhhc2hyYXRlGAQgASgLMhkuYWxlcnRzLnYxLkhhc2hyYXRlQ29uZmlnSAASMwoLdGVtcGVyYXR1cmUYBSABKAsyHC5hbGVydHMudjEuVGVtcGVyYXR1cmVDb25maWdIABIjCgVzY29wZRgGIAEoCzIULmFsZXJ0cy52MS5SdWxlU2NvcGVCGAoPdGVtcGxhdGVfY29uZmlnEgW6SAIIASJaCgtSdWxlUm91dGluZxIkCgRtb2RlGAEgASgOMhYuYWxlcnRzLnYxLlJvdXRpbmdNb2RlEiUKC2NoYW5uZWxfaWRzGAIgAygJQhC6SA2SAQoQZCIGcgQQARgUIoEDCgRSdWxlEgoKAmlkGAEgASgJEhcKD29yZ2FuaXphdGlvbl9pZBgCIAEoAxIMCgRuYW1lGAMgASgJEikKCHRlbXBsYXRlGAQgASgOMhcuYWxlcnRzLnYxLlJ1bGVUZW1wbGF0ZRINCgVncm91cBgFIAEoCRIQCghzZXZlcml0eRgGIAEoCRIPCgdzdW1tYXJ5GAcgASgJEhMKC2Rlc2NyaXB0aW9uGAggASgJEhgKEGR1cmF0aW9uX3NlY29uZHMYCSABKAUSDwoHZW5hYmxlZBgKIAEoCBIlCgZvcmlnaW4YCyABKA4yFS5hbGVydHMudjEuUnVsZU9yaWdpbhIlCgZjb25maWcYDCABKAsyFS5hbGVydHMudjEuUnVsZUNvbmZpZxInCgdyb3V0aW5nGA0gASgLMhYuYWxlcnRzLnYxLlJ1bGVSb3V0aW5nEhoKEmNvbmZpZ19vdXRfb2Zfc3luYxgOIAEoCBIWCg5jb25maWdfdW5rbm93bhgPIAEoCCISChBMaXN0UnVsZXNSZXF1ZXN0IjMKEUxpc3RSdWxlc1Jlc3BvbnNlEh4KBXJ1bGVzGAEgAygLMg8uYWxlcnRzLnYxLlJ1bGUiawoRQ3JlYXRlUnVsZVJlcXVlc3QSLQoGY29uZmlnGAEgASgLMhUuYWxlcnRzLnYxLlJ1bGVDb25maWdCBrpIA8gBARInCgdyb3V0aW5nGAIgASgLMhYuYWxlcnRzLnYxLlJ1bGVSb3V0aW5nIjMKEkNyZWF0ZVJ1bGVSZXNwb25zZRIdCgRydWxlGAEgASgLMg8uYWxlcnRzLnYxLlJ1bGUiVwoRVXBkYXRlUnVsZVJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAESLQoGY29uZmlnGAIgASgLMhUuYWxlcnRzLnYxLlJ1bGVDb25maWdCBrpIA8gBASIzChJVcGRhdGVSdWxlUmVzcG9uc2USHQoEcnVsZRgBIAEoCzIPLmFsZXJ0cy52MS5SdWxlIigKEURlbGV0ZVJ1bGVSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIhQKEkRlbGV0ZVJ1bGVSZXNwb25zZSJiChVTZXRSdWxlUm91dGluZ1JlcXVlc3QSGAoHcnVsZV9pZBgBIAEoCUIHukgEcgIQARIvCgdyb3V0aW5nGAIgASgLMhYuYWxlcnRzLnYxLlJ1bGVSb3V0aW5nQga6SAPIAQEiNwoWU2V0UnVsZVJvdXRpbmdSZXNwb25zZRIdCgRydWxlGAEgASgLMg8uYWxlcnRzLnYxLlJ1bGUiJwoQUGF1c2VSdWxlUmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQASIyChFQYXVzZVJ1bGVSZXNwb25zZRIdCgRydWxlGAEgASgLMg8uYWxlcnRzLnYxLlJ1bGUiKAoRUmVzdW1lUnVsZVJlcXVlc3QSEwoCaWQYASABKAlCB7pIBHICEAEiMwoSUmVzdW1lUnVsZVJlc3BvbnNlEh0KBHJ1bGUYASABKAsyDy5hbGVydHMudjEuUnVsZSKVAQoWTWFpbnRlbmFuY2VXaW5kb3dTY29wZRIzCgRraW5kGAEgASgOMiUuYWxlcnRzLnYxLk1haW50ZW5hbmNlV2luZG93U2NvcGVLaW5kEg8KB3J1bGVfaWQYAiABKAkSEAoIZ3JvdXBfaWQYAyABKAkSDwoHc2l0ZV9pZBgEIAEoCRISCgpkZXZpY2VfaWRzGAUgAygJIqsCChFNYWludGVuYW5jZVdpbmRvdxIKCgJpZBgBIAEoCRIXCg9vcmdhbml6YXRpb25faWQYAiABKAMSMAoFc2NvcGUYAyABKAsyIS5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3dTY29wZRItCglzdGFydHNfYXQYBCABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEisKB2VuZHNfYXQYBSABKAsyGi5nb29nbGUucHJvdG9idWYuVGltZXN0YW1wEg8KB2NvbW1lbnQYBiABKAkSEgoKY3JlYXRlZF9ieRgHIAEoCRIuCgpjcmVhdGVkX2F0GAggASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIOCgZhY3RpdmUYCSABKAgiHwodTGlzdE1haW50ZW5hbmNlV2luZG93c1JlcXVlc3QiWwoeTGlzdE1haW50ZW5hbmNlV2luZG93c1Jlc3BvbnNlEjkKE21haW50ZW5hbmNlX3dpbmRvd3MYASADKAsyHC5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3ciyQEKHkNyZWF0ZU1haW50ZW5hbmNlV2luZG93UmVxdWVzdBIwCgVzY29wZRgBIAEoCzIhLmFsZXJ0cy52MS5NYWludGVuYW5jZVdpbmRvd1Njb3BlEi0KCXN0YXJ0c19hdBgCIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASKwoHZW5kc19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoHY29tbWVudBgEIAEoCUIIukgFcgMYgAgiWwofQ3JlYXRlTWFpbnRlbmFuY2VXaW5kb3dSZXNwb25zZRI4ChJtYWludGVuYW5jZV93aW5kb3cYASABKAsyHC5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3ci3gEKHlVwZGF0ZU1haW50ZW5hbmNlV2luZG93UmVxdWVzdBITCgJpZBgBIAEoCUIHukgEcgIQARIwCgVzY29wZRgCIAEoCzIhLmFsZXJ0cy52MS5NYWludGVuYW5jZVdpbmRvd1Njb3BlEi0KCXN0YXJ0c19hdBgDIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASKwoHZW5kc19hdBgEIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASGQoHY29tbWVudBgFIAEoCUIIukgFcgMYgAgiWwofVXBkYXRlTWFpbnRlbmFuY2VXaW5kb3dSZXNwb25zZRI4ChJtYWludGVuYW5jZV93aW5kb3cYASABKAsyHC5hbGVydHMudjEuTWFpbnRlbmFuY2VXaW5kb3ciNQoeRGVsZXRlTWFpbnRlbmFuY2VXaW5kb3dSZXF1ZXN0EhMKAmlkGAEgASgJQge6SARyAhABIiEKH0RlbGV0ZU1haW50ZW5hbmNlV2luZG93UmVzcG9uc2Ui6gIKEUFsZXJ0SGlzdG9yeUVudHJ5EgoKAmlkGAEgASgJEi8KC3JlY2VpdmVkX2F0GAIgASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBISCgphbGVydF9uYW1lGAMgASgJEg4KBnN0YXR1cxgEIAEoCRIQCghzZXZlcml0eRgFIAEoCRISCgpydWxlX2dyb3VwGAYgASgJEhMKC2ZpbmdlcnByaW50GAcgASgJEhEKCWRldmljZV9pZBgIIAEoCRITCgtkZXZpY2VfbmFtZRgJIAEoCRISCgpkZXZpY2VfbWFjGAogASgJEhAKCHRlbXBsYXRlGAsgASgJEg8KB3N1bW1hcnkYDCABKAkSLQoJc3RhcnRzX2F0GA0gASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcBIrCgdlbmRzX2F0GA4gASgLMhouZ29vZ2xlLnByb3RvYnVmLlRpbWVzdGFtcCKcAQoRTGlzdEFsZXJ0c1JlcXVlc3QSGgoJYmVmb3JlX2lkGAEgASgJQge6SARyAhhAEhoKCXBhZ2Vfc2l6ZRgCIAEoBUIHukgEGgIoABITCgthY3RpdmVfb25seRgDIAEoCBIcCgphbGVydF9uYW1lGAQgASgJQgi6SAVyAxi+ARIcCgpydWxlX2dyb3VwGAUgASgJQgi6SAVyAxi+ASJpChJMaXN0QWxlcnRzUmVzcG9uc2USLAoGYWxlcnRzGAEgAygLMhwuYWxlcnRzLnYxLkFsZXJ0SGlzdG9yeUVudHJ5EhAKCGhhc19tb3JlGAIgASgIEhMKC25leHRfY3Vyc29yGAMgASgJIqwBChBBY3RpdmVBbGVydEdyb3VwEhIKCmFsZXJ0X25hbWUYASABKAkSEgoKcnVsZV9ncm91cBgCIAEoCRIUCgxkZXZpY2VfY291bnQYAyABKAMSEwoLYWxlcnRfY291bnQYBCABKAMSNAoQZmlyc3Rfc3RhcnRlZF9hdBgFIAEoCzIaLmdvb2dsZS5wcm90b2J1Zi5UaW1lc3RhbXASDwoHc3VtbWFyeRgGIAEoCSIeChxMaXN0QWN0aXZlQWxlcnRHcm91cHNSZXF1ZXN0Il4KHUxpc3RBY3RpdmVBbGVydEdyb3Vwc1Jlc3BvbnNlEisKBmdyb3VwcxgBIAMoCzIbLmFsZXJ0cy52MS5BY3RpdmVBbGVydEdyb3VwEhAKCGhhc19tb3JlGAIgASgIKnQKC0NoYW5uZWxLaW5kEhwKGENIQU5ORUxfS0lORF9VTlNQRUNJRklFRBAAEhgKFENIQU5ORUxfS0lORF9XRUJIT09LEAESFQoRQ0hBTk5FTF9LSU5EX1NNVFAQAhIWChJDSEFOTkVMX0tJTkRfU0xBQ0sQAyqHAQoPVmFsaWRhdGlvblN0YXRlEiAKHFZBTElEQVRJT05fU1RBVEVfVU5TUEVDSUZJRUQQABIcChhWQUxJREFUSU9OX1NUQVRFX1BFTkRJTkcQARIXChNWQUxJREFUSU9OX1NUQVRFX09LEAISGwoXVkFMSURBVElPTl9TVEFURV9GQUlMRUQQAyqpAgoMUnVsZVRlbXBsYXRlEh0KGVJVTEVfVEVNUExBVEVfVU5TUEVDSUZJRUQQABIZChVSVUxFX1RFTVBMQVRFX09GRkxJTkUQARIaChZSVUxFX1RFTVBMQVRFX0hBU0hSQVRFEAISHQoZUlVMRV9URU1QTEFURV9URU1QRVJBVFVSRRADEhYKElJVTEVfVEVNUExBVEVfUE9PTBAEEiEKHVJVTEVfVEVNUExBVEVfQ09NTUFORF9GQUlMVVJFEAUSIAocUlVMRV9URU1QTEFURV9URUxFTUVUUllfUE9MTBAGEiIKHlJVTEVfVEVNUExBVEVfTVFUVF9DVVJUQUlMTUVOVBAHEiMKH1JVTEVfVEVNUExBVEVfTVFUVF9ESVNDT05ORUNURUQQCCpcCgpSdWxlT3JpZ2luEhsKF1JVTEVfT1JJR0lOX1VOU1BFQ0lGSUVEEAASGwoXUlVMRV9PUklHSU5fUFJPVklTSU9ORUQQARIUChBSVUxFX09SSUdJTl9VU0VSEAIqaQoMSGFzaHJhdGVNb2RlEh0KGUhBU0hSQVRFX01PREVfVU5TUEVDSUZJRUQQABIeChpIQVNIUkFURV9NT0RFX1BDVF9FWFBFQ1RFRBABEhoKFkhBU0hSQVRFX01PREVfQUJTT0xVVEUQAiplCgxIYXNocmF0ZVVuaXQSHQoZSEFTSFJBVEVfVU5JVF9VTlNQRUNJRklFRBAAEhoKFkhBU0hSQVRFX1VOSVRfVEVSQUhBU0gQARIaChZIQVNIUkFURV9VTklUX1BFVEFIQVNIEAIqdQoLUm91dGluZ01vZGUSHAoYUk9VVElOR19NT0RFX1VOU1BFQ0lGSUVEEAASGAoUUk9VVElOR19NT0RFX0RFRkFVTFQQARIXChNST1VUSU5HX01PREVfQ1VTVE9NEAISFQoRUk9VVElOR19NT0RFX05PTkUQAyruAQoaTWFpbnRlbmFuY2VXaW5kb3dTY29wZUtpbmQSLQopTUFJTlRFTkFOQ0VfV0lORE9XX1NDT1BFX0tJTkRfVU5TUEVDSUZJRUQQABImCiJNQUlOVEVOQU5DRV9XSU5ET1dfU0NPUEVfS0lORF9SVUxFEAESJwojTUFJTlRFTkFOQ0VfV0lORE9XX1NDT1BFX0tJTkRfR1JPVVAQAhImCiJNQUlOVEVOQU5DRV9XSU5ET1dfU0NPUEVfS0lORF9TSVRFEAMSKAokTUFJTlRFTkFOQ0VfV0lORE9XX1NDT1BFX0tJTkRfREVWSUNFEAQyqwMKDkNoYW5uZWxTZXJ2aWNlEk8KDExpc3RDaGFubmVscxIeLmFsZXJ0cy52MS5MaXN0Q2hhbm5lbHNSZXF1ZXN0Gh8uYWxlcnRzLnYxLkxpc3RDaGFubmVsc1Jlc3BvbnNlElIKDUNyZWF0ZUNoYW5uZWwSHy5hbGVydHMudjEuQ3JlYXRlQ2hhbm5lbFJlcXVlc3QaIC5hbGVydHMudjEuQ3JlYXRlQ2hhbm5lbFJlc3BvbnNlElIKDVVwZGF0ZUNoYW5uZWwSHy5hbGVydHMudjEuVXBkYXRlQ2hhbm5lbFJlcXVlc3QaIC5hbGVydHMudjEuVXBkYXRlQ2hhbm5lbFJlc3BvbnNlElIKDURlbGV0ZUNoYW5uZWwSHy5hbGVydHMudjEuRGVsZXRlQ2hhbm5lbFJlcXVlc3QaIC5hbGVydHMudjEuRGVsZXRlQ2hhbm5lbFJlc3BvbnNlEkwKC1Rlc3RDaGFubmVsEh0uYWxlcnRzLnYxLlRlc3RDaGFubmVsUmVxdWVzdBoeLmFsZXJ0cy52MS5UZXN0Q2hhbm5lbFJlc3BvbnNlMqAECgtSdWxlU2VydmljZRJGCglMaXN0UnVsZXMSGy5hbGVydHMudjEuTGlzdFJ1bGVzUmVxdWVzdBocLmFsZXJ0cy52MS5MaXN0UnVsZXNSZXNwb25zZRJGCglQYXVzZVJ1bGUSGy5hbGVydHMudjEuUGF1c2VSdWxlUmVxdWVzdBocLmFsZXJ0cy52MS5QYXVzZVJ1bGVSZXNwb25zZRJJCgpSZXN1bWVSdWxlEhwuYWxlcnRzLnYxLlJlc3VtZVJ1bGVSZXF1ZXN0Gh0uYWxlcnRzLnYxLlJlc3VtZVJ1bGVSZXNwb25zZRJJCgpDcmVhdGVSdWxlEhwuYWxlcnRzLnYxLkNyZWF0ZVJ1bGVSZXF1ZXN0Gh0uYWxlcnRzLnYxLkNyZWF0ZVJ1bGVSZXNwb25zZRJJCgpVcGRhdGVSdWxlEhwuYWxlcnRzLnYxLlVwZGF0ZVJ1bGVSZXF1ZXN0Gh0uYWxlcnRzLnYxLlVwZGF0ZVJ1bGVSZXNwb25zZRJJCgpEZWxldGVSdWxlEhwuYWxlcnRzLnYxLkRlbGV0ZVJ1bGVSZXF1ZXN0Gh0uYWxlcnRzLnYxLkRlbGV0ZVJ1bGVSZXNwb25zZRJVCg5TZXRSdWxlUm91dGluZxIgLmFsZXJ0cy52MS5TZXRSdWxlUm91dGluZ1JlcXVlc3QaIS5hbGVydHMudjEuU2V0UnVsZVJvdXRpbmdSZXNwb25zZTLfAwoYTWFpbnRlbmFuY2VXaW5kb3dTZXJ2aWNlEm0KFkxpc3RNYWludGVuYW5jZVdpbmRvd3MSKC5hbGVydHMudjEuTGlzdE1haW50ZW5hbmNlV2luZG93c1JlcXVlc3QaKS5hbGVydHMudjEuTGlzdE1haW50ZW5hbmNlV2luZG93c1Jlc3BvbnNlEnAKF0NyZWF0ZU1haW50ZW5hbmNlV2luZG93EikuYWxlcnRzLnYxLkNyZWF0ZU1haW50ZW5hbmNlV2luZG93UmVxdWVzdBoqLmFsZXJ0cy52MS5DcmVhdGVNYWludGVuYW5jZVdpbmRvd1Jlc3BvbnNlEnAKF1VwZGF0ZU1haW50ZW5hbmNlV2luZG93EikuYWxlcnRzLnYxLlVwZGF0ZU1haW50ZW5hbmNlV2luZG93UmVxdWVzdBoqLmFsZXJ0cy52MS5VcGRhdGVNYWludGVuYW5jZVdpbmRvd1Jlc3BvbnNlEnAKF0RlbGV0ZU1haW50ZW5hbmNlV2luZG93EikuYWxlcnRzLnYxLkRlbGV0ZU1haW50ZW5hbmNlV2luZG93UmVxdWVzdBoqLmFsZXJ0cy52MS5EZWxldGVNYWludGVuYW5jZVdpbmRvd1Jlc3BvbnNlMscBCg5IaXN0b3J5U2VydmljZRJJCgpMaXN0QWxlcnRzEhwuYWxlcnRzLnYxLkxpc3RBbGVydHNSZXF1ZXN0Gh0uYWxlcnRzLnYxLkxpc3RBbGVydHNSZXNwb25zZRJqChVMaXN0QWN0aXZlQWxlcnRHcm91cHMSJy5hbGVydHMudjEuTGlzdEFjdGl2ZUFsZXJ0R3JvdXBzUmVxdWVzdBooLmFsZXJ0cy52MS5MaXN0QWN0aXZlQWxlcnRHcm91cHNSZXNwb25zZUKoAQoNY29tLmFsZXJ0cy52MUILQWxlcnRzUHJvdG9QAVpFZ2l0aHViLmNvbS9ibG9jay9wcm90by1mbGVldC9zZXJ2ZXIvZ2VuZXJhdGVkL2dycGMvYWxlcnRzL3YxO2FsZXJ0c3YxogIDQVhYqgIJQWxlcnRzLlYxygIJQWxlcnRzXFYx4gIVQWxlcnRzXFYxXEdQQk1ldGFkYXRh6gIKQWxlcnRzOjpWMWIGcHJvdG8z", [file_buf_validate_validate, file_google_protobuf_timestamp], ); @@ -1450,6 +1450,14 @@ export type ActiveAlertGroup = Message<"alerts.v1.ActiveAlertGroup"> & { * @generated from field: google.protobuf.Timestamp first_started_at = 5; */ firstStartedAt?: Timestamp | undefined; + + /** + * The newest instance's summary, and only for a group whose device_count is 0: those have no drill-in to + * describe them, and taking it from a device-less instance keeps device identity out of this response. + * + * @generated from field: string summary = 6; + */ + summary: string; }; /** diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx index adb2118aef..58182bd4c5 100644 --- a/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx +++ b/client/src/protoFleet/components/AppLayout/AppLayout.test.tsx @@ -12,6 +12,7 @@ import { useHasPermission } from "@/protoFleet/store"; const mockUseWindowDimensions = vi.fn(); const mockUseReactiveLocalStorage = vi.fn(); const mockUseCurtailmentPillData = vi.fn(); +const mockUseActiveAlertsPillData = vi.fn(); const mockUseSchedulePillData = vi.fn(); const mockUseUpdateIndicator = vi.fn(); @@ -43,6 +44,10 @@ vi.mock("@/protoFleet/components/PageHeader/useCurtailmentPillData", () => ({ useCurtailmentPillData: () => mockUseCurtailmentPillData(), })); +vi.mock("@/protoFleet/components/PageHeader/useActiveAlertsPillData", () => ({ + useActiveAlertsPillData: (options: { enabled?: boolean }) => mockUseActiveAlertsPillData(options), +})); + vi.mock("@/protoFleet/features/updates/useUpdateIndicator", () => ({ useUpdateIndicator: (options: { enabled?: boolean }) => mockUseUpdateIndicator(options), })); @@ -97,12 +102,15 @@ const activeCurtailmentEvent: CurtailmentPillEvent = { describe("AppLayout", () => { beforeEach(() => { + // Without this, a `toHaveBeenCalledWith` assertion matches any earlier test's render instead of its own. + vi.clearAllMocks(); mockUseWindowDimensions.mockReturnValue({ width: 375, isPhone: true, }); mockUseReactiveLocalStorage.mockReturnValue([false, vi.fn()]); mockUseCurtailmentPillData.mockReturnValue({ activeEvent: null }); + mockUseActiveAlertsPillData.mockReturnValue({ groups: [], error: null, hasMore: false, hasVisiblePill: false }); mockUseSchedulePillData.mockReturnValue(createSchedulePillData()); mockUseUpdateIndicator.mockReturnValue(null); vi.mocked(useHasPermission).mockReturnValue(true); @@ -250,6 +258,30 @@ describe("AppLayout", () => { expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+120px)]"); }); + it("uses the four-widget phone content offset when a firing alert makes five widgets visible", () => { + mockUseReactiveLocalStorage.mockReturnValue([true, vi.fn()]); + mockUseActiveAlertsPillData.mockReturnValue({ + groups: [{ key: "miner|offline" }], + error: null, + hasMore: false, + hasVisiblePill: true, + }); + mockUseCurtailmentPillData.mockReturnValue({ activeEvent: activeCurtailmentEvent }); + mockUseSchedulePillData.mockReturnValue(createSchedulePillData({ pillSchedule: createPillSchedule() })); + mockUseUpdateIndicator.mockReturnValue({ version: "v1.3.0", onClick: vi.fn() }); + + render( + + +
Body content
+
+
, + ); + + // The alerts pill takes the inline slot, leaving four stacked pills that the three-row ladder would clip. + expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+160px)]"); + }); + it("disables update polling when the route hides the shell header", () => { render( @@ -262,6 +294,39 @@ describe("AppLayout", () => { expect(mockUseUpdateIndicator).toHaveBeenCalledWith({ enabled: false }); }); + it("disables active-alert polling when the route hides the shell header", () => { + render( + + +
Body content
+
+
, + ); + + expect(mockUseActiveAlertsPillData).toHaveBeenCalledWith({ enabled: false }); + }); + + it("offsets the phone content for a firing alert so the pill has a row", () => { + mockUseActiveAlertsPillData.mockReturnValue({ + groups: [{ key: "miner|offline" }], + error: null, + hasMore: false, + hasVisiblePill: true, + }); + mockUseSchedulePillData.mockReturnValue(createSchedulePillData({ pillSchedule: createPillSchedule() })); + + render( + + +
Body content
+
+
, + ); + + // The alerts pill takes the inline slot, pushing the schedule pill into a row of its own. + expect(screen.getByText("Body content").parentElement).toHaveClass("phone:top-[calc(theme(spacing.1)*12+40px)]"); + }); + it("keeps the base phone content offset when the only curtailment widget fits inline", () => { mockUseCurtailmentPillData.mockReturnValue({ activeEvent: activeCurtailmentEvent }); diff --git a/client/src/protoFleet/components/AppLayout/AppLayout.tsx b/client/src/protoFleet/components/AppLayout/AppLayout.tsx index 524fc6b9e4..235d78bf6d 100644 --- a/client/src/protoFleet/components/AppLayout/AppLayout.tsx +++ b/client/src/protoFleet/components/AppLayout/AppLayout.tsx @@ -13,6 +13,7 @@ import { shouldInlineFirstPhoneHeaderWidget, shouldStackPhoneHeaderWidgets, } from "@/protoFleet/components/PageHeader/headerWidgetLayout"; +import { useActiveAlertsPillData } from "@/protoFleet/components/PageHeader/useActiveAlertsPillData"; import { useCurtailmentPillData } from "@/protoFleet/components/PageHeader/useCurtailmentPillData"; import { useSchedulePillData } from "@/protoFleet/components/PageHeader/useSchedulePillData"; import { primaryNavItems } from "@/protoFleet/config/navItems"; @@ -38,12 +39,15 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => { // Release discovery is an enhancement of the normal Fleet header. Focused // detail routes deliberately skip the poll along with the hidden header. const updatePill = useUpdateIndicator({ enabled: !hideShellHeader }); + // Same deal for the active-alert poll: no header, no pill to feed. + const activeAlertsPillData = useActiveAlertsPillData({ enabled: !hideShellHeader }); const hasDismissedSetup = Boolean(dismissedSetup); const canReadCurtailment = useHasPermission("curtailment:read"); const hasVisibleCurtailmentPill = activeCurtailmentEvent !== null && canReadCurtailment; const hasVisibleUpdatePill = updatePill !== null; const headerWidgetCount = getVisibleHeaderWidgetCount({ hasDismissedSetup, + hasVisibleAlertsPill: activeAlertsPillData.hasVisiblePill, hasVisibleUpdatePill, hasVisibleCurtailmentPill, hasVisibleSchedules: schedulePillData.hasVisibleSchedules, @@ -96,6 +100,7 @@ const AppLayoutContent = ({ children, hideShellHeader = false }: Props) => { className={`fixed top-0 right-0 bottom-[calc(100vh-theme(spacing.1)*12)] left-0 z-40 laptop:bottom-[calc(100vh-theme(spacing.1)*15)] laptop:left-16 desktop:left-50 ${bgClass}`} > setIsMenuOpen(true)} diff --git a/client/src/protoFleet/components/PageHeader/ActiveAlertsPill.test.tsx b/client/src/protoFleet/components/PageHeader/ActiveAlertsPill.test.tsx new file mode 100644 index 0000000000..3d66148226 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/ActiveAlertsPill.test.tsx @@ -0,0 +1,305 @@ +import { useState } from "react"; +import { MemoryRouter } from "react-router-dom"; +import { render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import userEvent from "@testing-library/user-event"; + +import ActiveAlertsPill from "./ActiveAlertsPill"; +import type { PagedAlertsFilter, UsePagedAlertsResult } from "@/protoFleet/features/alerts/api/usePagedAlerts"; +import AlertInstancesModal from "@/protoFleet/features/alerts/components/AlertInstancesModal"; +import type { ActiveAlertGroup, AlertHistoryEntry } from "@/protoFleet/features/alerts/types"; + +const pagedAlertsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/protoFleet/features/alerts/api/usePagedAlerts", () => ({ + usePagedAlerts: (filter: PagedAlertsFilter) => pagedAlertsMock(filter), +})); + +const buildGroup = (overrides: Partial = {}): ActiveAlertGroup => { + const group = { + alert_name: "Alert", + // Only a renamed alert differs from what the server stored, so follow alert_name unless a case says otherwise. + stored_alert_name: overrides.alert_name ?? "Alert", + rule_group: "miner", + device_count: 1, + alert_count: 1, + first_started_at: "2026-07-01T00:00:00Z", + // Only a group with no miners carries one, since only those have no drill-in to describe them. + summary: "", + ...overrides, + }; + // key is derived by the API mapper, not sent by the server, so the fixture derives it too. + return { ...group, key: JSON.stringify([group.rule_group, group.stored_alert_name]) }; +}; + +const buildInstance = (overrides: Partial = {}): AlertHistoryEntry => ({ + id: "alert-1", + received_at: "2026-07-01T00:00:00Z", + alert_name: "Hashrate dropped", + status: "firing", + severity: "warning", + rule_group: "miner", + fingerprint: "fp-1", + device_id: "device-1", + device_name: "Rig 1", + device_mac: "AA:BB:CC:DD:EE:FF", + template: "", + summary: "Hashrate below expected", + starts_at: null, + ends_at: null, + ...overrides, +}); + +const buildInstancesResult = (overrides: Partial = {}): UsePagedAlertsResult => ({ + items: [], + loading: false, + error: null, + hasMore: false, + loadMore: vi.fn(), + ...overrides, +}); + +interface RenderOptions { + groups: ActiveAlertGroup[]; + error?: string | null; + hasMore?: boolean; +} + +// Mirrors PageHeader's gate (useActiveAlertsPillData.hasVisiblePill): the pill goes once nothing is firing and +// the poll is healthy, and the header owns the drill-in so that dropping the pill cannot close it. +const PillHost = ({ groups, error = null, hasMore = false }: RenderOptions) => { + const [selectedGroup, setSelectedGroup] = useState(null); + + return ( + <> + {groups.length > 0 || error !== null ? ( + + ) : null} + {selectedGroup ? setSelectedGroup(null)} /> : null} + + ); +}; + +const renderPill = (options: RenderOptions) => { + const view = render( + + + , + ); + + return { + ...view, + rerenderPill: (next: RenderOptions) => + view.rerender( + + + , + ), + }; +}; + +const openPopover = async () => { + await userEvent.click(screen.getByRole("button", { name: "View active alerts" })); +}; + +describe("ActiveAlertsPill", () => { + beforeEach(() => { + // Without this, a `toHaveBeenCalledWith` assertion matches any earlier test's render instead of its own. + vi.clearAllMocks(); + // Most cases never drill in; the ones that do override this with their own instances. + pagedAlertsMock.mockReturnValue(buildInstancesResult()); + }); + + it("counts the firing alerts on the trigger behind the alert icon", () => { + renderPill({ + groups: [buildGroup({ alert_name: "Miner Offline" }), buildGroup({ alert_name: "Hashrate dropped" })], + }); + + expect(screen.getByText("2 active alerts")).toBeVisible(); + expect(screen.getByTestId("alert-icon")).toBeVisible(); + }); + + it("lists each alert with its affected-miner count", async () => { + renderPill({ + groups: [ + buildGroup({ alert_name: "Miner Offline", device_count: 5000 }), + buildGroup({ alert_name: "Hashrate dropped", device_count: 12 }), + ], + }); + await openPopover(); + + // Popover content asserts on presence: its transition classes leave it "not visible" in jsdom. + expect(screen.getByText("Miner Offline")).toBeInTheDocument(); + // The miner count is the alert's blast radius, not a per-miner row of its own. + expect(screen.getByText("5,000 miners affected")).toBeInTheDocument(); + expect(screen.getByText("12 miners affected")).toBeInTheDocument(); + // Each has miners to list, so each says it can be opened for them. + expect(screen.getAllByTestId("alert-drill-in-chevron")).toHaveLength(2); + }); + + it("lists the affected miners when an alert is clicked", async () => { + const group = buildGroup({ + alert_name: "Hashrate dropped", + stored_alert_name: "HashrateDropped", + device_count: 12, + }); + pagedAlertsMock.mockReturnValue(buildInstancesResult({ items: [buildInstance()] })); + + renderPill({ groups: [group] }); + await openPopover(); + await userEvent.click(screen.getByText("Hashrate dropped")); + + // The drill-in filters on the stored rule title, not the display one a retired-rule rename rewrote. + expect(pagedAlertsMock).toHaveBeenCalledWith( + expect.objectContaining({ active_only: true, alert_name: "HashrateDropped", rule_group: "miner" }), + ); + // Modal content asserts on presence: the overlay's transition classes leave it "not visible" in jsdom. + expect(screen.getByText("Rig 1")).toBeInTheDocument(); + expect(screen.getByText("AA:BB:CC:DD:EE:FF")).toBeInTheDocument(); + expect(screen.getByText("12 miners affected")).toBeInTheDocument(); + }); + + it("keeps the drill-in open when the alert it lists resolves and the pill disappears", async () => { + const group = buildGroup({ alert_name: "Hashrate dropped", device_count: 12 }); + pagedAlertsMock.mockReturnValue(buildInstancesResult({ items: [buildInstance()] })); + + const { rerenderPill } = renderPill({ groups: [group] }); + await openPopover(); + await userEvent.click(screen.getByText("Hashrate dropped")); + + // The poll clears the last firing alert mid-read, so the header drops the pill the modal was opened from. + rerenderPill({ groups: [] }); + + expect(screen.queryByRole("button", { name: "View active alerts" })).not.toBeInTheDocument(); + expect(screen.getByText("Rig 1")).toBeInTheDocument(); + }); + + it("says what a device-less alert reported inline, since it has no per-miner drill-in to open", async () => { + renderPill({ + groups: [ + buildGroup({ + alert_name: "Metric ingest stalled", + rule_group: "proto-fleet-self", + device_count: 0, + summary: "No telemetry received in 5 minutes", + }), + ], + }); + await openPopover(); + + expect(screen.getByText("No telemetry received in 5 minutes")).toBeInTheDocument(); + expect(screen.queryByText("0 miners affected")).not.toBeInTheDocument(); + // Nothing to drill into, so the row is inert and offers no affordance saying otherwise. + expect(screen.getByText("Metric ingest stalled").closest("button")).toBeNull(); + expect(screen.queryByTestId("alert-drill-in-chevron")).not.toBeInTheDocument(); + }); + + it("drills into a lone device-less alert whose summary the server withheld", async () => { + renderPill({ + groups: [ + buildGroup({ + alert_name: "Curtailment Fan Restore Failed", + rule_group: "proto-fleet-curtailment", + device_count: 0, + // Only the MQTT source templates clear the server's redaction, so this row arrives with nothing to say. + summary: "", + }), + ], + }); + await openPopover(); + + // Title and firing-since alone don't diagnose it, so the instance behind the row stays reachable. + expect(screen.getByTestId("alert-drill-in-chevron")).toBeInTheDocument(); + expect(screen.getByText("Curtailment Fan Restore Failed").closest("button")).not.toBeNull(); + }); + + it("drills into a device-less alert firing on more than one dimension, under a plural title", async () => { + renderPill({ + groups: [ + buildGroup({ + alert_name: "Curtailment Source Unreachable", + rule_group: "proto-fleet-curtailment", + device_count: 0, + alert_count: 2, + summary: "maestro-b is unreachable", + }), + ], + }); + await openPopover(); + + // One title names one source, and two are down: the row counts them and the drill-in names the rest. + expect(screen.getByText("Curtailment Sources Unreachable")).toBeInTheDocument(); + expect(screen.getByText("2 instances")).toBeInTheDocument(); + expect(screen.getByTestId("alert-drill-in-chevron")).toBeInTheDocument(); + }); + + it("lists the firing instances of a device-less alert without miner columns", async () => { + const group = buildGroup({ + alert_name: "Curtailment Source Unreachable", + rule_group: "proto-fleet-curtailment", + device_count: 0, + alert_count: 2, + summary: "maestro-b is unreachable", + }); + pagedAlertsMock.mockReturnValue( + buildInstancesResult({ + items: [ + buildInstance({ + id: "src-a", + device_id: "", + device_name: "", + device_mac: "", + summary: "maestro-a is unreachable", + }), + buildInstance({ + id: "src-b", + device_id: "", + device_name: "", + device_mac: "", + summary: "maestro-b is unreachable", + }), + ], + }), + ); + + renderPill({ groups: [group] }); + await openPopover(); + await userEvent.click(screen.getByText("Curtailment Sources Unreachable")); + + // The drill-in is the only place the sources other than the newest are named. + expect(screen.getByText("maestro-a is unreachable")).toBeInTheDocument(); + // No device identity to show, so those columns stay out rather than rendering a column of blanks. + expect(screen.queryByText("Device Name")).not.toBeInTheDocument(); + expect(screen.queryByText("MAC Address")).not.toBeInTheDocument(); + }); + + it("surfaces a failed refresh alongside the alerts it could not update", async () => { + renderPill({ groups: [buildGroup({ alert_name: "Hashrate dropped" })], error: "Failed to load active alerts" }); + + // The count is the last good read, so the icon is what says on the closed pill that it stopped being current. + expect(screen.getByText("1 active alert")).toBeVisible(); + expect(screen.getByTestId("alert-icon")).toHaveClass("text-intent-critical-fill"); + + await openPopover(); + expect(screen.getByText("Failed to load active alerts")).toBeInTheDocument(); + expect(screen.getByText("Hashrate dropped")).toBeInTheDocument(); + }); + + it("reports a poll that failed before it ever read an alert, rather than a fleet with none", async () => { + renderPill({ groups: [], error: "Failed to load active alerts" }); + + // Nothing else reports this now that the dashboard card is gone, so "0 active alerts" would read as all-clear. + expect(screen.getByText("Alerts unavailable")).toBeVisible(); + expect(screen.getByTestId("alert-icon")).toHaveClass("text-intent-critical-fill"); + + await openPopover(); + expect(screen.getByText("Failed to load active alerts")).toBeInTheDocument(); + }); + + it("says the list is truncated when the server has more firing alerts than it returned", async () => { + renderPill({ groups: [buildGroup({ alert_name: "Hashrate dropped" })], hasMore: true }); + await openPopover(); + + expect(screen.getByText(/additional firing alerts are not shown/)).toBeInTheDocument(); + }); +}); diff --git a/client/src/protoFleet/components/PageHeader/ActiveAlertsPill.tsx b/client/src/protoFleet/components/PageHeader/ActiveAlertsPill.tsx new file mode 100644 index 0000000000..be802fe9ed --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/ActiveAlertsPill.tsx @@ -0,0 +1,52 @@ +import ActiveAlertsPopover from "./ActiveAlertsPopover"; +import PageHeaderPopoverPill from "./PageHeaderPopoverPill"; +import { countLabel } from "@/protoFleet/features/alerts/lib/alertCountLabels"; +import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; +import { Alert } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import { popoverSizes } from "@/shared/components/Popover"; + +// The header hides this pill as soon as the last alert resolves, so the drill-in it opens is owned above it. +interface ActiveAlertsPillProps { + groups: ActiveAlertGroup[]; + error: string | null; + hasMore: boolean; + onSelectGroup: (group: ActiveAlertGroup) => void; +} + +const ActiveAlertsPill = ({ groups, error, hasMore, onSelectGroup }: ActiveAlertsPillProps) => { + // The header keeps the pill up through a failed poll, so the trigger has to carry that: whatever count it + // still holds went stale when the poll broke, and with none at all it must not read as a quiet fleet. + const degraded = error !== null; + + return ( + + } + popoverSize={popoverSizes.wide} + triggerClassName="active-alerts-pill-trigger" + triggerLabel={groups.length > 0 ? countLabel(groups.length, "active alert") : "Alerts unavailable"} + > + {({ closePopover }) => ( + { + closePopover(); + onSelectGroup(group); + }} + onNavigateToAlerts={closePopover} + /> + )} + + ); +}; + +export default ActiveAlertsPill; diff --git a/client/src/protoFleet/components/PageHeader/ActiveAlertsPopover.tsx b/client/src/protoFleet/components/PageHeader/ActiveAlertsPopover.tsx new file mode 100644 index 0000000000..4a49679a30 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/ActiveAlertsPopover.tsx @@ -0,0 +1,125 @@ +import { Link } from "react-router-dom"; +import clsx from "clsx"; + +import { activeGroupTitle } from "@/protoFleet/features/alerts/lib/activeGroupTitle"; +import { countLabel } from "@/protoFleet/features/alerts/lib/alertCountLabels"; +import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; +import { ChevronDown } from "@/shared/assets/icons"; +import { iconSizes } from "@/shared/assets/icons/constants"; +import { formatTimestamp, isoToEpochSeconds } from "@/shared/utils/formatTimestamp"; + +interface ActiveAlertsPopoverProps { + groups: ActiveAlertGroup[]; + error: string | null; + hasMore: boolean; + onSelectGroup: (group: ActiveAlertGroup) => void; + onNavigateToAlerts: () => void; +} + +// The blast radius, in whichever unit the rule fires on. A single device-less instance is the one row with +// nothing to count, so it reports the summary the server sent for it instead. +const groupDetail = (group: ActiveAlertGroup) => { + if (group.device_count > 0) { + return `${countLabel(group.device_count, "miner")} affected`; + } + return group.alert_count > 1 ? countLabel(group.alert_count, "instance") : group.summary; +}; + +// A drill-in earns its place when the row can't say the whole thing: several miners, several device-less +// instances, or a lone instance whose summary the server withheld — the rule's own templates decide that, so a +// row cannot assume it has one. Only a single instance that did arrive with its summary is complete as it is. +const isDrillable = (group: ActiveAlertGroup) => + group.device_count > 0 || group.alert_count > 1 || group.summary === ""; + +const ActiveAlertsPopover = ({ + groups, + error, + hasMore, + onSelectGroup, + onNavigateToAlerts, +}: ActiveAlertsPopoverProps) => { + return ( +
+ {error ?
{error}
: null} + + {/* Focusable so a list of non-drillable rows, which hold no tab stop of their own, can still be scrolled. */} +
+ {groups.map((group, groupIndex) => { + const detail = groupDetail(group); + const drillable = isDrillable(group); + // Empty when the server sent no start time; the bare "Firing since" label would read as broken. + const firingSince = formatTimestamp(isoToEpochSeconds(group.first_started_at)); + const content = ( + <> +
+
+ {/* One heading line tall, so the dot centers on the title's first line however the title wraps. */} + + + + {activeGroupTitle(group)} +
+
+ {detail ?
{detail}
: null} + {firingSince ? ( +
Firing since {firingSince}
+ ) : null} +
+
+ {drillable ? ( + + ) : null} + + ); + + // Both row shapes share the separator so the dividers line up down a mixed list. The padding keeps the + // hover highlight clear of the text it wraps and of the dividers above and below it. + const rowClassName = clsx( + "flex items-center justify-between gap-3 px-3 py-3", + groupIndex > 0 && "border-t border-border-5", + ); + + return drillable ? ( + + ) : ( +
+ {content} +
+ ); + })} +
+ + {hasMore ? ( +
+ Showing the first {groups.length} alerts; additional firing alerts are not shown. +
+ ) : null} + +
+ + Configure alerts + +
+
+ ); +}; + +export default ActiveAlertsPopover; diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx index 96ab29b314..5472d25cdb 100644 --- a/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx +++ b/client/src/protoFleet/components/PageHeader/PageHeader.test.tsx @@ -2,10 +2,13 @@ import { MemoryRouter } from "react-router-dom"; import { render, screen, within } from "@testing-library/react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { create } from "@bufbuild/protobuf"; +import userEvent from "@testing-library/user-event"; import PageHeader from "./PageHeader"; +import type { UseActiveAlertsPillDataResult } from "./useActiveAlertsPillData"; import type { UseSchedulePillDataResult } from "./useSchedulePillData"; import { SiteSchema, type SiteWithCounts, SiteWithCountsSchema } from "@/protoFleet/api/generated/sites/v1/sites_pb"; import type { ScheduleListItem } from "@/protoFleet/api/useScheduleApi"; +import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; import { SiteScopeProvider } from "@/protoFleet/routing/siteScope"; import { useHasPermission } from "@/protoFleet/store"; import { DEFAULT_ACTIVE_SITE } from "@/protoFleet/store/types/activeSite"; @@ -28,6 +31,29 @@ vi.mock("./SchedulePill", () => ({ default: ({ pillSchedule }: { pillSchedule: { name: string } }) =>
{pillSchedule.name}
, })); +vi.mock("./ActiveAlertsPill", () => ({ + __esModule: true, + default: ({ + groups, + onSelectGroup, + }: { + groups: ActiveAlertGroup[]; + onSelectGroup: (g: ActiveAlertGroup) => void; + }) => ( +
+ Active alerts pill ({groups.length}) + +
+ ), +})); + +vi.mock("@/protoFleet/features/alerts/components/AlertInstancesModal", () => ({ + __esModule: true, + default: ({ group }: { group: ActiveAlertGroup }) =>
Instances of {group.alert_name}
, +})); + vi.mock("@/protoFleet/api/sites", () => ({ useSites: () => ({ listSites: mockListSites, @@ -103,6 +129,20 @@ const createSchedulePillData = (overrides: Partial = ...overrides, }); +const createActiveAlertsPillData = ( + overrides: Partial = {}, +): UseActiveAlertsPillDataResult => { + const groups = overrides.groups ?? []; + + return { + error: null, + hasMore: false, + ...overrides, + groups, + hasVisiblePill: groups.length > 0, + }; +}; + describe("PageHeader", () => { beforeEach(() => { vi.clearAllMocks(); @@ -228,6 +268,76 @@ describe("PageHeader", () => { expect(updateButton.compareDocumentPosition(setupButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); }); + it("leads the desktop widgets with the active alerts pill", () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + + render( + + + , + ); + + const widgets = screen.getByTestId("page-header-desktop-widgets"); + const alertsPill = within(widgets).getByText("Active alerts pill (1)"); + const schedulePill = within(widgets).getByText("Night reboot"); + + expect(alertsPill.compareDocumentPosition(schedulePill) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy(); + }); + + it("opens the drill-in it owns, so hiding the pill cannot tear the modal down mid-read", async () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + const group = { key: "miner|offline", alert_name: "Miner Offline" } as ActiveAlertGroup; + + const { rerender } = render( + + + , + ); + await userEvent.click(screen.getByRole("button", { name: "Drill in" })); + + expect(screen.getByText("Instances of Miner Offline")).toBeInTheDocument(); + + // The poll clears the last firing alert mid-read, so the header stops rendering the pill it was opened from. + rerender( + + + , + ); + + expect(screen.queryByText(/Active alerts pill/)).not.toBeInTheDocument(); + expect(screen.getByText("Instances of Miner Offline")).toBeInTheDocument(); + }); + + it("keeps the alerts pill out of the header when nothing is firing", () => { + mockUseWindowDimensions.mockReturnValue({ + isPhone: false, + isTablet: false, + }); + + render( + + + , + ); + + expect(screen.queryByText(/Active alerts pill/)).not.toBeInTheDocument(); + }); + it("keeps the phone widget row hidden when neither setup nor schedules need space", () => { render( diff --git a/client/src/protoFleet/components/PageHeader/PageHeader.tsx b/client/src/protoFleet/components/PageHeader/PageHeader.tsx index 486bb79bfc..99610a84ed 100644 --- a/client/src/protoFleet/components/PageHeader/PageHeader.tsx +++ b/client/src/protoFleet/components/PageHeader/PageHeader.tsx @@ -1,7 +1,8 @@ -import { type ReactElement } from "react"; +import { type ReactElement, useState } from "react"; import { useLocation } from "react-router-dom"; import clsx from "clsx"; +import ActiveAlertsPill from "./ActiveAlertsPill"; import CurtailmentPill from "./CurtailmentPill"; import type { CurtailmentPillEvent } from "./curtailmentPillTypes"; import { @@ -13,8 +14,11 @@ import { } from "./headerWidgetLayout"; import SchedulePill from "./SchedulePill"; import SitePicker from "./SitePicker"; +import type { UseActiveAlertsPillDataResult } from "./useActiveAlertsPillData"; import type { UseSchedulePillDataResult } from "./useSchedulePillData"; import { useSitesContext } from "@/protoFleet/api/SitesContext"; +import AlertInstancesModal from "@/protoFleet/features/alerts/components/AlertInstancesModal"; +import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; import { usePageBackground } from "@/protoFleet/hooks/usePageBackground"; import { scopedPath, unscopedScopablePath, useRouteSiteScope } from "@/protoFleet/routing/siteScope"; import { useHasPermission } from "@/protoFleet/store"; @@ -25,6 +29,7 @@ import { useReactiveLocalStorage } from "@/shared/hooks/useReactiveLocalStorage" import { useWindowDimensions } from "@/shared/hooks/useWindowDimensions"; interface PageHeaderProps { + activeAlertsPillData?: UseActiveAlertsPillDataResult; activeCurtailmentEvent?: CurtailmentPillEvent | null; isMenuOpen?: boolean; openMenu?: () => void; @@ -38,12 +43,14 @@ export interface UpdatePillData { } interface HeaderWidgetsProps { + activeAlertsPillData: UseActiveAlertsPillDataResult; activeCurtailmentEvent: CurtailmentPillEvent | null; align?: "start" | "end"; canReadCurtailment: boolean; className?: string; dismissedSetup: boolean; onContinueSetup: () => void; + onSelectAlertGroup: (group: ActiveAlertGroup) => void; schedulePillData: UseSchedulePillDataResult; stacked?: boolean; testId?: string; @@ -52,15 +59,23 @@ interface HeaderWidgetsProps { } const headerWidgetEnabled = true; -type HeaderWidgetKind = "curtailment" | "schedule" | "update" | "setup"; +const noActiveAlerts: UseActiveAlertsPillDataResult = { + groups: [], + error: null, + hasMore: false, + hasVisiblePill: false, +}; +type HeaderWidgetKind = "alerts" | "curtailment" | "schedule" | "update" | "setup"; function HeaderWidgets({ + activeAlertsPillData, activeCurtailmentEvent, align = "start", canReadCurtailment, className, dismissedSetup, onContinueSetup, + onSelectAlertGroup, schedulePillData, stacked = false, testId, @@ -86,6 +101,16 @@ function HeaderWidgets({ > {widgets.map((widget) => { switch (widget) { + case "alerts": + return ( + + ); case "curtailment": return activeCurtailmentEvent && canReadCurtailment ? ( @@ -134,6 +159,7 @@ function HeaderWidgets({ } function PageHeader({ + activeAlertsPillData = noActiveAlerts, activeCurtailmentEvent = null, isMenuOpen, openMenu, @@ -147,6 +173,8 @@ function PageHeader({ const { pathname } = useLocation(); const isDashboard = unscopedScopablePath(pathname) === "/dashboard"; const [dismissedSetup, setDismissedSetup] = useReactiveLocalStorage("completeSetupDismissed"); + // Owned here, not in the pill the header drops once the last alert resolves, so a drill-in survives that. + const [drilledInAlertGroup, setDrilledInAlertGroup] = useState(null); const hasDismissedSetup = Boolean(dismissedSetup); const canReadCurtailment = useHasPermission("curtailment:read"); // ListSites is server-gated on org-scoped site:read; without it we skip the @@ -165,16 +193,20 @@ function PageHeader({ }; const headerWidgetsProps = { + activeAlertsPillData, activeCurtailmentEvent, canReadCurtailment, dismissedSetup: hasDismissedSetup, onContinueSetup: handleCompleteSetup, + onSelectAlertGroup: setDrilledInAlertGroup, schedulePillData, updatePill, }; const hasVisibleCurtailmentPill = activeCurtailmentEvent !== null && canReadCurtailment; const hasVisibleUpdatePill = updatePill !== null; + // Alerts lead: only the first widget stays inline in the phone top bar, and a firing alert outranks the rest. const headerWidgetKinds: HeaderWidgetKind[] = [ + ...(activeAlertsPillData.hasVisiblePill ? (["alerts"] as const) : []), ...(hasVisibleCurtailmentPill ? (["curtailment"] as const) : []), ...(schedulePillData.hasVisibleSchedules ? (["schedule"] as const) : []), ...(hasVisibleUpdatePill ? (["update"] as const) : []), @@ -182,6 +214,7 @@ function PageHeader({ ]; const headerWidgetCount = getVisibleHeaderWidgetCount({ hasDismissedSetup, + hasVisibleAlertsPill: activeAlertsPillData.hasVisiblePill, hasVisibleUpdatePill, hasVisibleCurtailmentPill, hasVisibleSchedules: schedulePillData.hasVisibleSchedules, @@ -255,6 +288,10 @@ function PageHeader({ /> ) : null} + + {drilledInAlertGroup ? ( + setDrilledInAlertGroup(null)} /> + ) : null} ); } diff --git a/client/src/protoFleet/components/PageHeader/PageHeaderPopoverPill.tsx b/client/src/protoFleet/components/PageHeader/PageHeaderPopoverPill.tsx index 2da37d3a16..d1024c3195 100644 --- a/client/src/protoFleet/components/PageHeader/PageHeaderPopoverPill.tsx +++ b/client/src/protoFleet/components/PageHeader/PageHeaderPopoverPill.tsx @@ -6,10 +6,13 @@ import Popover, { PopoverProvider, popoverSizes, useResponsivePopover } from "@/ import { positions } from "@/shared/constants"; import { classNameToSelectors } from "@/shared/utils/cssUtils"; +// Pills that report a status pass `dotClassName`; pills whose subject has its own glyph pass `prefixIcon`. interface PageHeaderPopoverPillProps { ariaLabel: string; children: (props: { closePopover: () => void }) => ReactNode; - dotClassName: string; + dotClassName?: string; + popoverSize?: keyof typeof popoverSizes; + prefixIcon?: ReactNode; triggerClassName: string; triggerLabel: ReactNode; } @@ -18,6 +21,8 @@ function PageHeaderPopoverPillContent({ ariaLabel, children, dotClassName, + popoverSize = popoverSizes.small, + prefixIcon, triggerClassName, triggerLabel, }: PageHeaderPopoverPillProps) { @@ -47,7 +52,7 @@ function PageHeaderPopoverPillContent({ ariaExpanded={isPopoverOpen} ariaLabel={ariaLabel} onClick={handleTriggerClick} - prefixIcon={} + prefixIcon={prefixIcon ?? } > {triggerLabel} @@ -55,7 +60,7 @@ function PageHeaderPopoverPillContent({ {isPopoverOpen ? ( 3) { + return PHONE_HEADER_WIDGET_STACK_FOUR_HEIGHT_CLASS; + } + return widgetCount > 2 ? PHONE_HEADER_WIDGET_STACK_THREE_HEIGHT_CLASS : PHONE_HEADER_WIDGET_STACK_TWO_HEIGHT_CLASS; } @@ -60,5 +70,9 @@ export function getPhoneHeaderWidgetOffsetClass(widgetCount: number, stackWidget return PHONE_HEADER_WIDGET_ROW_OFFSET_CLASS; } + if (widgetCount > 3) { + return PHONE_HEADER_WIDGET_STACK_FOUR_OFFSET_CLASS; + } + return widgetCount > 2 ? PHONE_HEADER_WIDGET_STACK_THREE_OFFSET_CLASS : PHONE_HEADER_WIDGET_STACK_TWO_OFFSET_CLASS; } diff --git a/client/src/protoFleet/components/PageHeader/useActiveAlertsPillData.ts b/client/src/protoFleet/components/PageHeader/useActiveAlertsPillData.ts new file mode 100644 index 0000000000..8d48650db8 --- /dev/null +++ b/client/src/protoFleet/components/PageHeader/useActiveAlertsPillData.ts @@ -0,0 +1,38 @@ +import { useActiveAlertGroups } from "@/protoFleet/features/alerts/api/useActiveAlertGroups"; +import { useAlertsEnabled } from "@/protoFleet/features/alerts/api/useAlertsEnabled"; +import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; +import { useHasPermission } from "@/protoFleet/store"; + +// `hasVisiblePill` is the header's gate: the pill is an alarm, so nothing firing means nothing in the header — +// except a failed poll, which no other surface reports now that the dashboard card is gone. +export interface UseActiveAlertsPillDataResult { + groups: ActiveAlertGroup[]; + error: string | null; + hasMore: boolean; + hasVisiblePill: boolean; +} + +interface UseActiveAlertsPillDataOptions { + enabled?: boolean; +} + +// Gates on the read permission and the runtime feature probe, so the poll never runs without the sidecar. +export function useActiveAlertsPillData({ + enabled = true, +}: UseActiveAlertsPillDataOptions = {}): UseActiveAlertsPillDataResult { + const hasAlertRead = useHasPermission("alert:read"); + const alertsEnabled = useAlertsEnabled(); + const canViewAlerts = hasAlertRead && alertsEnabled; + const { groups, error, hasMore, denied } = useActiveAlertGroups({ enabled: enabled && canViewAlerts }); + // A revoked grant stops the poll but leaves the last response in state, so drop it rather than show it stale. + const visible = canViewAlerts && !denied; + const visibleGroups = visible ? groups : []; + const visibleError = visible ? error : null; + + return { + groups: visibleGroups, + error: visibleError, + hasMore: visible ? hasMore : false, + hasVisiblePill: visibleGroups.length > 0 || visibleError !== null, + }; +} diff --git a/client/src/protoFleet/features/alerts/api/alertsApi.test.ts b/client/src/protoFleet/features/alerts/api/alertsApi.test.ts new file mode 100644 index 0000000000..13b474a310 --- /dev/null +++ b/client/src/protoFleet/features/alerts/api/alertsApi.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +import { listActiveAlertGroups } from "./alertsApi"; +import { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; + +const listActiveAlertGroupsMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/protoFleet/api/clients", () => ({ + alertChannelClient: {}, + alertHistoryClient: { listActiveAlertGroups: listActiveAlertGroupsMock }, + alertMaintenanceWindowClient: {}, + alertRuleClient: {}, +})); + +describe("listActiveAlertGroups", () => { + it("deadlines the request, so a stalled poll fails loudly instead of leaving the pill on an all-clear", async () => { + listActiveAlertGroupsMock.mockResolvedValue({ groups: [], hasMore: false }); + + await listActiveAlertGroups(); + + expect(listActiveAlertGroupsMock).toHaveBeenCalledWith({}, { timeoutMs: POLL_INTERVAL_MS }); + }); +}); diff --git a/client/src/protoFleet/features/alerts/api/alertsApi.ts b/client/src/protoFleet/features/alerts/api/alertsApi.ts index 14f4f61ecc..123eb3e6c1 100644 --- a/client/src/protoFleet/features/alerts/api/alertsApi.ts +++ b/client/src/protoFleet/features/alerts/api/alertsApi.ts @@ -25,6 +25,7 @@ import { RuleTemplate as ProtoRuleTemplate, ValidationState as ProtoValidationState, } from "@/protoFleet/api/generated/alerts/v1/alerts_pb"; +import { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; import type { ActiveAlertGroup, AlertHistoryEntry, @@ -586,9 +587,12 @@ const activeGroupFromProto = (g: ProtoActiveAlertGroup): ActiveAlertGroup => ({ device_count: Number(g.deviceCount), alert_count: Number(g.alertCount), first_started_at: isoFromTs(g.firstStartedAt), + summary: g.summary, }); +// The poll behind the header pill is an alarm, and a request that never settles stalls it with no error to show: +// the pill would then read as a quiet fleet. Deadline it at the interval, past which a reply is superseded anyway. export async function listActiveAlertGroups(): Promise { - const res = await alertHistoryClient.listActiveAlertGroups({}); + const res = await alertHistoryClient.listActiveAlertGroups({}, { timeoutMs: POLL_INTERVAL_MS }); return { groups: res.groups.map(activeGroupFromProto), has_more: res.hasMore }; } diff --git a/client/src/protoFleet/features/alerts/api/useActiveAlertGroups.test.ts b/client/src/protoFleet/features/alerts/api/useActiveAlertGroups.test.ts new file mode 100644 index 0000000000..2e95889b6e --- /dev/null +++ b/client/src/protoFleet/features/alerts/api/useActiveAlertGroups.test.ts @@ -0,0 +1,93 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { Code, ConnectError } from "@connectrpc/connect"; + +import type { ActiveAlertGroupsPage } from "@/protoFleet/features/alerts/api/alertsApi"; +import * as api from "@/protoFleet/features/alerts/api/alertsApi"; +import { useActiveAlertGroups } from "@/protoFleet/features/alerts/api/useActiveAlertGroups"; +import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; + +vi.mock("@/protoFleet/features/alerts/api/alertsApi", () => ({ + listActiveAlertGroups: vi.fn(), +})); + +const listMock = vi.mocked(api.listActiveAlertGroups); + +const buildGroup = (alertName: string): ActiveAlertGroup => ({ + alert_name: alertName, + stored_alert_name: alertName, + rule_group: "miner", + key: JSON.stringify(["miner", alertName]), + device_count: 1, + alert_count: 1, + first_started_at: "2026-07-01T00:00:00Z", + summary: "", +}); + +const deferredPage = () => { + let settle!: (page: ActiveAlertGroupsPage) => void; + const promise = new Promise((resolve) => { + settle = resolve; + }); + return { promise, settle }; +}; + +describe("useActiveAlertGroups", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it("resumes polling when a revoked grant is restored", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + listMock.mockRejectedValue(new ConnectError("forbidden", Code.PermissionDenied)); + + // Nothing remounts this hook — the shell that owns it outlives every route change. + const { result } = renderHook(() => useActiveAlertGroups()); + await waitFor(() => expect(result.current.denied).toBe(true)); + + listMock.mockResolvedValue({ groups: [buildGroup("Hashrate dropped")], has_more: false }); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + await waitFor(() => expect(result.current.denied).toBe(false)); + expect(result.current.groups.map((group) => group.alert_name)).toEqual(["Hashrate dropped"]); + }); + + // A denial hides the pill entirely, so leaving it set on an error that isn't a denial would swallow the error + // too: the header would report neither the alerts nor the failure until some later poll happened to succeed. + it("reports a non-permission failure that follows a denial", async () => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + listMock.mockRejectedValue(new ConnectError("forbidden", Code.PermissionDenied)); + + const { result } = renderHook(() => useActiveAlertGroups()); + await waitFor(() => expect(result.current.denied).toBe(true)); + + listMock.mockRejectedValue(new ConnectError("boom", Code.Internal)); + await vi.advanceTimersByTimeAsync(5 * 60 * 1000); + await waitFor(() => expect(result.current.error).toBe("boom")); + expect(result.current.denied).toBe(false); + }); + + it("ignores a reply that a newer poll already superseded", async () => { + const stale = deferredPage(); + const fresh = deferredPage(); + listMock.mockReturnValueOnce(stale.promise).mockReturnValueOnce(fresh.promise); + + // Leaving a headerless route and returning starts a second poll while the first is still in flight. + const { result, rerender } = renderHook(({ enabled }) => useActiveAlertGroups({ enabled }), { + initialProps: { enabled: true }, + }); + rerender({ enabled: false }); + rerender({ enabled: true }); + + fresh.settle({ groups: [buildGroup("Hashrate dropped")], has_more: false }); + await waitFor(() => expect(result.current.groups).toHaveLength(1)); + + // The older request answers last, describing a fleet state the newer reply has already replaced. + stale.settle({ groups: [], has_more: false }); + await waitFor(() => expect(listMock).toHaveBeenCalledTimes(2)); + expect(result.current.groups.map((group) => group.alert_name)).toEqual(["Hashrate dropped"]); + }); +}); diff --git a/client/src/protoFleet/features/alerts/api/useActiveAlertGroups.ts b/client/src/protoFleet/features/alerts/api/useActiveAlertGroups.ts index b1581460a1..53b93cc3ff 100644 --- a/client/src/protoFleet/features/alerts/api/useActiveAlertGroups.ts +++ b/client/src/protoFleet/features/alerts/api/useActiveAlertGroups.ts @@ -1,4 +1,4 @@ -import { useCallback, useState } from "react"; +import { useCallback, useRef, useState } from "react"; import { getErrorMessage } from "@/protoFleet/api/getErrorMessage"; import { isPermissionDeniedError } from "@/protoFleet/api/requestErrors"; @@ -9,45 +9,63 @@ import { usePoll } from "@/shared/hooks/usePoll"; export interface UseActiveAlertGroupsResult { groups: ActiveAlertGroup[]; - // True until the first response lands; poll ticks after that refresh in place rather than re-rendering the - // card (and its list) through a loading state on every interval. - loading: boolean; error: string | null; - // A site-scoped alert:read grant clears the dashboard's flat permission gate but is denied this - // org-scoped RPC; callers suppress the card on denial rather than surfacing an error. + // A site-scoped alert:read grant clears the shell's flat permission gate but is denied this org-scoped RPC. denied: boolean; hasMore: boolean; } -// Owns the always-on active-alert poll for the dashboard card. The server rolls the firing set up per rule, -// so the response stays a handful of rows however many miners an outage covers. -export function useActiveAlertGroups(): UseActiveAlertGroupsResult { +export interface UseActiveAlertGroupsOptions { + // Off where the alerts surface is hidden or the viewer can't reach the RPC, since this polls on every route. + enabled?: boolean; +} + +// A grant can be restored while the shell stays mounted, and no route change remounts this hook to notice. So a +// denial slows the poll rather than ending it: a grant that can't reach the RPC costs a request every few +// minutes instead of one every interval, and a restored one is picked up without a reload. +const DENIED_RETRY_MS = 5 * 60 * 1000; + +// Owns the active-alert poll behind the header pill. The server rolls the firing set up per rule, so the +// response stays a handful of rows however many miners an outage covers. +export function useActiveAlertGroups({ enabled = true }: UseActiveAlertGroupsOptions = {}): UseActiveAlertGroupsResult { const [groups, setGroups] = useState([]); const [hasMore, setHasMore] = useState(false); - const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [denied, setDenied] = useState(false); + // Disabling the poll stops it scheduling but can't recall the request already in flight, so leaving a + // headerless route and coming straight back can overlap two. Whichever was issued last is the one that + // describes the fleet now, so an earlier reply that lands after it is dropped rather than committed. + const latestRequest = useRef(0); + const fetchData = useCallback(async () => { + const request = ++latestRequest.current; + const superseded = () => request !== latestRequest.current; try { const page = await api.listActiveAlertGroups(); + if (superseded()) return; setGroups(page.groups); setHasMore(page.has_more); setError(null); + setDenied(false); } catch (err) { + // Before the denial check too: a stale denial would stop the poll for good on a grant already replaced. + if (superseded()) return; if (isPermissionDeniedError(err)) { setDenied(true); return; } + // Any other failure says nothing about the grant, so an earlier denial must not outlive it: the pill hides + // itself while denied, which would swallow this error and hold the slow retry interval until a response + // succeeds. A grant that really is denied re-asserts that on the next tick. + setDenied(false); setError(getErrorMessage(err, "Failed to load active alerts")); - } finally { - setLoading(false); } }, []); - // Stop polling once denied: the card unmounts to null but this hook stays mounted, so the poll - // would otherwise keep hitting the org-scoped RPC the grant can't reach. - usePoll({ fetchData, poll: true, pollIntervalMs: POLL_INTERVAL_MS, enabled: !denied }); + // Back off once denied: the pill hides itself but this hook stays mounted, so the poll + // would otherwise keep hitting the org-scoped RPC the grant can't reach at header speed. + usePoll({ fetchData, poll: true, pollIntervalMs: denied ? DENIED_RETRY_MS : POLL_INTERVAL_MS, enabled }); - return { groups, loading, error, denied, hasMore }; + return { groups, error, denied, hasMore }; } diff --git a/client/src/protoFleet/features/alerts/api/useAlertsEnabled.test.ts b/client/src/protoFleet/features/alerts/api/useAlertsEnabled.test.ts new file mode 100644 index 0000000000..2e5584b258 --- /dev/null +++ b/client/src/protoFleet/features/alerts/api/useAlertsEnabled.test.ts @@ -0,0 +1,97 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetAlertsEnabledCache, useAlertsEnabled } from "@/protoFleet/features/alerts/api/useAlertsEnabled"; + +const okResponse = (enabled: boolean) => ({ ok: true, json: async () => ({ enabled }) }) as unknown as Response; + +describe("useAlertsEnabled", () => { + beforeEach(() => { + _resetAlertsEnabledCache(); + vi.useFakeTimers({ shouldAdvanceTime: true }); + }); + + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it("retries an unreachable server rather than reporting alerts disabled", async () => { + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new TypeError("Failed to fetch")) + .mockResolvedValueOnce(okResponse(true)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAlertsEnabled()); + + await vi.advanceTimersByTimeAsync(2_000); + await waitFor(() => expect(result.current).toBe(true)); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("gives a probe a deadline, so a connection that never answers still gets retried", async () => { + const fetchMock = vi + .fn() + .mockImplementationOnce( + (_url: string, init?: { signal?: AbortSignal }) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError"))); + }), + ) + .mockResolvedValueOnce(okResponse(true)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAlertsEnabled()); + + // The first probe hangs: without a deadline it holds the shared promise, and no later probe is ever made. + await vi.advanceTimersByTimeAsync(10_000); + await vi.advanceTimersByTimeAsync(2_000); + await waitFor(() => expect(result.current).toBe(true)); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); + + it("retries a failing status, which the endpoint never uses to mean disabled", async () => { + // Both states come back as a 200 body, so a 503 from a proxy is a failed probe rather than an answer. + const fetchMock = vi + .fn() + .mockResolvedValueOnce({ ok: false, status: 503 } as unknown as Response) + .mockResolvedValueOnce(okResponse(false)); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAlertsEnabled()); + + await vi.advanceTimersByTimeAsync(2_000); + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + expect(result.current).toBe(false); + }); + + it("keeps probing a sustained outage, since the shell that mounts it is never remounted", async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", fetchMock); + + const { result } = renderHook(() => useAlertsEnabled()); + await vi.advanceTimersByTimeAsync(30_000); + expect(result.current).toBe(false); + const attemptsWhileDown = fetchMock.mock.calls.length; + expect(attemptsWhileDown).toBeGreaterThan(1); + + // Recovery reaches the same mount: nothing had to remount or reload for the alerts surface to come back. + fetchMock.mockResolvedValue(okResponse(true)); + await vi.advanceTimersByTimeAsync(120_000); + await waitFor(() => expect(result.current).toBe(true)); + }); + + it("stops probing once unmounted", async () => { + const fetchMock = vi.fn().mockRejectedValue(new TypeError("Failed to fetch")); + vi.stubGlobal("fetch", fetchMock); + + const { unmount } = renderHook(() => useAlertsEnabled()); + await vi.advanceTimersByTimeAsync(10_000); + unmount(); + const attemptsAtUnmount = fetchMock.mock.calls.length; + + await vi.advanceTimersByTimeAsync(120_000); + expect(fetchMock).toHaveBeenCalledTimes(attemptsAtUnmount); + }); +}); diff --git a/client/src/protoFleet/features/alerts/api/useAlertsEnabled.ts b/client/src/protoFleet/features/alerts/api/useAlertsEnabled.ts index eb2424ce0e..0d11ea7364 100644 --- a/client/src/protoFleet/features/alerts/api/useAlertsEnabled.ts +++ b/client/src/protoFleet/features/alerts/api/useAlertsEnabled.ts @@ -7,19 +7,34 @@ const ENABLED_URL = `${API_PROXY_BASE}/api/v1/alerts/enabled`; // Module-level cache so the probe runs once per session regardless of how many // components mount the hook (mirrors the firmware-config fetch pattern). let cache: boolean | null = null; -let inflight: Promise | null = null; +let inflight: Promise | null = null; -async function fetchAlertsEnabled(): Promise { +// A blip while probing would otherwise hide the alerts surface for the session: this hook is mounted by the +// shell, which no route change remounts. So an unanswered probe is retried for as long as it stays mounted, +// backing off to a slow poll that costs one small GET. +const PROBE_RETRY_MS = 2_000; +const PROBE_RETRY_MAX_MS = 60_000; +// A connection held open without ever answering is the one failure the retry loop can't see: it waits on this +// promise, and every later probe joins the same one. A deadline turns that into an ordinary unanswered probe. +const PROBE_TIMEOUT_MS = 10_000; + +// null when nothing was learned — no answer arrived, which is not the same as answering "disabled". The +// endpoint reports both states as a 200 body, so any other status is a failed probe rather than an answer. +async function fetchAlertsEnabled(): Promise { if (cache !== null) return cache; if (inflight) return inflight; inflight = (async () => { + const deadline = new AbortController(); + const deadlineId = setTimeout(() => deadline.abort(), PROBE_TIMEOUT_MS); try { - const response = await fetch(ENABLED_URL, { credentials: "include" }); - cache = response.ok && (await response.json())?.enabled === true; + const response = await fetch(ENABLED_URL, { credentials: "include", signal: deadline.signal }); + if (!response.ok) return null; + cache = (await response.json())?.enabled === true; return cache; } catch { - return false; + return null; } finally { + clearTimeout(deadlineId); inflight = null; } })(); @@ -39,14 +54,31 @@ export function _resetAlertsEnabledCache(): void { * `ALERTS_ENABLED` stays as a build-time override for QA/dogfood. */ export function useAlertsEnabled(): boolean { - const [enabled, setEnabled] = useState(cache ?? ALERTS_ENABLED); + // Same shape as the answered case below, so a build forcing alerts on keeps them on across a remount that + // reads a cached "disabled" rather than re-probing. + const [enabled, setEnabled] = useState((cache ?? false) || ALERTS_ENABLED); + // No short-circuit on an already-cached answer: effects run after the render that seeded state, so another + // consumer's probe can answer in between, and returning on it would leave the initializer's "disabled" to + // outlive the answer and hide the alerts surface for this mount's whole life. A cached answer costs no + // request — fetchAlertsEnabled returns it directly — and re-setting an unchanged value is a no-op in React. useEffect(() => { let active = true; - void fetchAlertsEnabled().then((value) => { - if (active) setEnabled(value || ALERTS_ENABLED); - }); + let retryId: ReturnType | null = null; + + const probe = async (retryMs: number) => { + const answer = await fetchAlertsEnabled(); + if (!active) return; + if (answer !== null) { + setEnabled(answer || ALERTS_ENABLED); + return; + } + retryId = setTimeout(() => void probe(Math.min(retryMs * 2, PROBE_RETRY_MAX_MS)), retryMs); + }; + + void probe(PROBE_RETRY_MS); return () => { active = false; + if (retryId !== null) clearTimeout(retryId); }; }, []); return enabled; diff --git a/client/src/protoFleet/features/alerts/api/useAlertsEnabledOverride.test.ts b/client/src/protoFleet/features/alerts/api/useAlertsEnabledOverride.test.ts new file mode 100644 index 0000000000..6d4364834d --- /dev/null +++ b/client/src/protoFleet/features/alerts/api/useAlertsEnabledOverride.test.ts @@ -0,0 +1,32 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { _resetAlertsEnabledCache, useAlertsEnabled } from "@/protoFleet/features/alerts/api/useAlertsEnabled"; + +// The build-time override is read once at module scope, so forcing it on needs its own file. +vi.mock("@/protoFleet/constants/featureFlags", () => ({ ALERTS_ENABLED: true })); + +describe("useAlertsEnabled with the build-time override on", () => { + beforeEach(() => { + _resetAlertsEnabledCache(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it("keeps alerts on across a remount that reads a cached server 'disabled'", async () => { + vi.stubGlobal( + "fetch", + vi.fn().mockResolvedValue({ ok: true, json: async () => ({ enabled: false }) } as unknown as Response), + ); + + const first = renderHook(() => useAlertsEnabled()); + await waitFor(() => expect(first.result.current).toBe(true)); + first.unmount(); + + // The cached answer short-circuits the probe, so the override has to survive the initializer alone. + const second = renderHook(() => useAlertsEnabled()); + expect(second.result.current).toBe(true); + }); +}); diff --git a/client/src/protoFleet/features/alerts/components/ActiveAlertsCard.test.tsx b/client/src/protoFleet/features/alerts/components/ActiveAlertsCard.test.tsx deleted file mode 100644 index 308fee896d..0000000000 --- a/client/src/protoFleet/features/alerts/components/ActiveAlertsCard.test.tsx +++ /dev/null @@ -1,249 +0,0 @@ -import { render, screen, within } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import userEvent from "@testing-library/user-event"; - -import type { UseActiveAlertGroupsResult } from "@/protoFleet/features/alerts/api/useActiveAlertGroups"; -import type { PagedAlertsFilter, UsePagedAlertsResult } from "@/protoFleet/features/alerts/api/usePagedAlerts"; -import ActiveAlertsCard from "@/protoFleet/features/alerts/components/ActiveAlertsCard"; -import type { ActiveAlertGroup, AlertHistoryEntry } from "@/protoFleet/features/alerts/types"; - -const activeGroupsMock = vi.hoisted(() => vi.fn()); -const pagedAlertsMock = vi.hoisted(() => vi.fn()); - -vi.mock("@/protoFleet/features/alerts/api/useActiveAlertGroups", () => ({ - useActiveAlertGroups: () => activeGroupsMock(), -})); - -vi.mock("@/protoFleet/features/alerts/api/usePagedAlerts", () => ({ - usePagedAlerts: (filter: PagedAlertsFilter) => pagedAlertsMock(filter), -})); - -const buildResult = (overrides: Partial = {}): UseActiveAlertGroupsResult => ({ - groups: [], - loading: false, - error: null, - denied: false, - hasMore: false, - ...overrides, -}); - -const buildGroup = (overrides: Partial = {}): ActiveAlertGroup => { - const group = { - alert_name: "Alert", - // Only a renamed alert differs from what the server stored, so follow alert_name unless a case says otherwise. - stored_alert_name: overrides.alert_name ?? "Alert", - rule_group: "miner", - device_count: 1, - alert_count: 1, - first_started_at: "2026-07-01T00:00:00Z", - ...overrides, - }; - // key is derived by the API mapper, not sent by the server, so the fixture derives it too. - return { ...group, key: JSON.stringify([group.rule_group, group.stored_alert_name]) }; -}; - -const buildMiner = (overrides: Partial = {}): AlertHistoryEntry => ({ - id: "alert-1", - received_at: "2026-07-01T00:00:00Z", - alert_name: "Hashrate dropped", - status: "firing", - severity: "warning", - rule_group: "miner", - fingerprint: "fp-1", - device_id: "device-1", - device_name: "Rig 1", - device_mac: "AA:BB:CC:DD:EE:FF", - template: "", - summary: "Hashrate below expected", - starts_at: null, - ends_at: null, - ...overrides, -}); - -const buildMinersResult = (overrides: Partial = {}): UsePagedAlertsResult => ({ - items: [], - loading: false, - error: null, - hasMore: false, - loadMore: vi.fn(), - ...overrides, -}); - -describe("ActiveAlertsCard", () => { - // Most cases never open the modal; the two that do override this with their own instances. - beforeEach(() => { - pagedAlertsMock.mockReturnValue(buildMinersResult()); - }); - - it("renders one row per alert with its affected-miner count", () => { - activeGroupsMock.mockReturnValue( - buildResult({ - groups: [ - buildGroup({ alert_name: "Miner Offline", device_count: 5000 }), - buildGroup({ alert_name: "Hashrate dropped", device_count: 12 }), - ], - }), - ); - render(); - - expect(screen.getByText("Miner Offline")).toBeVisible(); - // The miner count is the row's blast radius, not a per-miner row of its own. - expect(screen.getByText("5,000 miners")).toBeVisible(); - expect(screen.getByText("Hashrate dropped")).toBeVisible(); - expect(screen.getByText("12 miners")).toBeVisible(); - }); - - it("lists the affected miners when an alert is clicked", async () => { - const group = buildGroup({ - alert_name: "Hashrate dropped", - stored_alert_name: "HashrateDropped", - device_count: 12, - }); - activeGroupsMock.mockReturnValue(buildResult({ groups: [group] })); - pagedAlertsMock.mockReturnValue(buildMinersResult({ items: [buildMiner()] })); - - render(); - await userEvent.click(screen.getByText("Hashrate dropped")); - - // The drill-in filters on the stored rule title, not the display one a retired-rule rename rewrote. - expect(pagedAlertsMock).toHaveBeenCalledWith( - expect.objectContaining({ active_only: true, alert_name: "HashrateDropped", rule_group: "miner" }), - ); - // Modal content asserts on presence: the overlay's transition classes leave it "not visible" in jsdom. - expect(screen.getByText("Rig 1")).toBeInTheDocument(); - expect(screen.getByText("AA:BB:CC:DD:EE:FF")).toBeInTheDocument(); - expect(screen.getByText("12 miners affected")).toBeInTheDocument(); - }); - - it("renders a device-less alert as a callout even when its rule group is unknown to the client", () => { - activeGroupsMock.mockReturnValue( - buildResult({ - groups: [ - buildGroup({ - alert_name: "Curtailment Fan Restore Failed", - rule_group: "proto-fleet-curtailment", - device_count: 0, - }), - ], - }), - ); - render(); - - // No blast radius to rank, so it must not become a rollup row reading "0 miners". - expect(screen.getAllByTestId("callout")).toHaveLength(1); - expect(screen.queryByText("Affected Miners")).not.toBeInTheDocument(); - // The rollup says nothing about the one instance, so the drill-in is still the only place it is described. - expect(screen.getByText("View instances")).toBeVisible(); - }); - - it("counts the instances of a device-less alert firing on more than one source", () => { - activeGroupsMock.mockReturnValue( - buildResult({ - groups: [ - buildGroup({ - alert_name: "Curtailment Source Unreachable", - rule_group: "proto-fleet-curtailment", - device_count: 0, - alert_count: 2, - }), - ], - }), - ); - render(); - - // The callout names the rule, so the count is what tells the operator more than one source is down. - const callout = within(screen.getByTestId("callout")); - expect(callout.getByText("Curtailment Source Unreachable — 2 instances")).toBeVisible(); - }); - - it("drills into a device-less alert's instances, which the rollup alone cannot name", async () => { - const group = buildGroup({ - alert_name: "Curtailment Source Unreachable", - rule_group: "proto-fleet-curtailment", - device_count: 0, - alert_count: 2, - }); - activeGroupsMock.mockReturnValue(buildResult({ groups: [group] })); - pagedAlertsMock.mockReturnValue( - buildMinersResult({ - items: [ - buildMiner({ - id: "src-a", - device_id: "", - device_name: "", - device_mac: "", - summary: "maestro-a is unreachable.", - }), - buildMiner({ - id: "src-b", - device_id: "", - device_name: "", - device_mac: "", - summary: "maestro-b is unreachable.", - }), - ], - }), - ); - - render(); - await userEvent.click(screen.getByText("View instances")); - - expect(screen.getByText("2 instances firing")).toBeInTheDocument(); - expect(screen.getByText("maestro-a is unreachable.")).toBeInTheDocument(); - expect(screen.getByText("maestro-b is unreachable.")).toBeInTheDocument(); - // Device columns would be empty for every row, so the table leaves them out. - expect(screen.queryByText("MAC Address")).not.toBeInTheDocument(); - }); - - it("renders one callout per fleet-wide alert", () => { - activeGroupsMock.mockReturnValue( - buildResult({ - groups: [ - buildGroup({ alert_name: "Metric ingest stalled", rule_group: "proto-fleet-self", device_count: 0 }), - buildGroup({ alert_name: "Host CPU high", rule_group: "proto-fleet-system", device_count: 0 }), - ], - }), - ); - render(); - - const callouts = screen.getAllByTestId("callout"); - expect(callouts).toHaveLength(2); - expect(within(callouts[0]).getByText("Metric ingest stalled")).toBeVisible(); - expect(within(callouts[1]).getByText("Host CPU high")).toBeVisible(); - }); - - it("renders a source-scoped alert as a callout alongside miner alert rows", () => { - activeGroupsMock.mockReturnValue( - buildResult({ - groups: [ - buildGroup({ - alert_name: "Curtailment Source Unreachable", - device_count: 0, - }), - buildGroup({ alert_name: "Hashrate dropped", device_count: 3 }), - ], - }), - ); - render(); - - expect(screen.getAllByTestId("callout")).toHaveLength(1); - expect(screen.getByText("Curtailment Source Unreachable")).toBeVisible(); - expect(screen.getByText("Hashrate dropped")).toBeVisible(); - expect(screen.getByText("3 miners")).toBeVisible(); - }); - - it("renders the empty state when there are no active alerts", () => { - activeGroupsMock.mockReturnValue(buildResult()); - render(); - - expect(screen.getByText("No active alerts.")).toBeVisible(); - expect(screen.queryByTestId("callout")).not.toBeInTheDocument(); - }); - - it("renders nothing when the request is denied", () => { - activeGroupsMock.mockReturnValue(buildResult({ denied: true })); - const { container } = render(); - - expect(container).toBeEmptyDOMElement(); - }); -}); diff --git a/client/src/protoFleet/features/alerts/components/ActiveAlertsCard.tsx b/client/src/protoFleet/features/alerts/components/ActiveAlertsCard.tsx deleted file mode 100644 index 81bc31321b..0000000000 --- a/client/src/protoFleet/features/alerts/components/ActiveAlertsCard.tsx +++ /dev/null @@ -1,122 +0,0 @@ -import { useCallback, useMemo, useState } from "react"; -import { useActiveAlertGroups } from "@/protoFleet/features/alerts/api/useActiveAlertGroups"; -import { TimestampText } from "@/protoFleet/features/alerts/components/alertColumns"; -import AlertInstancesModal from "@/protoFleet/features/alerts/components/AlertInstancesModal"; -import StatusDot from "@/protoFleet/features/alerts/components/StatusDot"; -import { countLabel } from "@/protoFleet/features/alerts/lib/alertCountLabels"; -import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; -import { Alert } from "@/shared/assets/icons"; -import Callout from "@/shared/components/Callout"; -import List from "@/shared/components/List"; -import type { ColConfig, ColTitles } from "@/shared/components/List/types"; -import ProgressCircular from "@/shared/components/ProgressCircular"; - -// A device-less rule can fire on several non-device dimensions at once, and the rollup names the rule rather -// than any one of them, so the count is what says the others are firing too. -const calloutTitle = (group: ActiveAlertGroup) => - group.alert_count > 1 ? `${group.alert_name} — ${countLabel(group.alert_count, "instance")}` : group.alert_name; - -type AlertColumns = "alert" | "miners" | "since"; - -const alertColTitles: ColTitles = { - alert: "Alert", - miners: "Affected Miners", - since: "Firing Since", -}; - -const alertActiveCols: AlertColumns[] = ["alert", "miners", "since"]; - -const alertColConfig: ColConfig = { - alert: { - component: (group) => {group.alert_name}, - width: "w-72", - allowWrap: true, - }, - miners: { - component: (group) => ( - {countLabel(group.device_count, "miner")} - ), - width: "w-44", - }, - since: { - component: (group) => , - width: "w-48", - }, -}; - -const ActiveAlertsCard = () => { - const { groups, loading, error, denied, hasMore } = useActiveAlertGroups(); - // The clicked group is captured, not looked up per poll: the drill-in pages a snapshot, and re-deriving it - // would close the modal (discarding every loaded page) the moment the alert resolved mid-read. - const [selectedGroup, setSelectedGroup] = useState(null); - - // No affected miners means no blast radius to rank or list, so these get a callout rather than a row reading - // "0 miners". Keyed off the server's count, so no client-side list of device-less rule groups to keep in sync. - const { calloutAlerts, alertRows } = useMemo( - () => ({ - calloutAlerts: groups.filter((group) => group.device_count === 0), - alertRows: groups.filter((group) => group.device_count > 0), - }), - [groups], - ); - - const handleClose = useCallback(() => setSelectedGroup(null), []); - - // The dashboard gate is a flat permission union; a site-scoped alert:read grant reaches here but is - // denied the org-scoped history RPC, so drop the card on that denial rather than poll it forever. - if (denied) return null; - - const isInitialLoad = loading && groups.length === 0; - const isEmpty = groups.length === 0; - - return ( -
-

Active alerts

- - {error ? } title={error} /> : null} - - {isInitialLoad ? ( -
- -
- ) : isEmpty ? ( -
No active alerts.
- ) : ( -
- {calloutAlerts.map((group) => ( - } - title={calloutTitle(group)} - // What each instance actually says lives on the drill-in, so it is offered even for a single one. - buttonText="View instances" - buttonOnClick={() => setSelectedGroup(group)} - /> - ))} - {alertRows.length ? ( - - items={alertRows} - itemKey="key" - activeCols={alertActiveCols} - colTitles={alertColTitles} - colConfig={alertColConfig} - onRowClick={setSelectedGroup} - noDataElement={null} - /> - ) : null} -
- )} - - {hasMore ? ( -

- Showing the first {groups.length} alerts; additional firing alerts are not shown. -

- ) : null} - - {selectedGroup ? : null} -
- ); -}; - -export default ActiveAlertsCard; diff --git a/client/src/protoFleet/features/alerts/components/AlertInstancesModal.tsx b/client/src/protoFleet/features/alerts/components/AlertInstancesModal.tsx index 43432503d2..bbaef03dd4 100644 --- a/client/src/protoFleet/features/alerts/components/AlertInstancesModal.tsx +++ b/client/src/protoFleet/features/alerts/components/AlertInstancesModal.tsx @@ -1,5 +1,6 @@ import { usePagedAlerts } from "@/protoFleet/features/alerts/api/usePagedAlerts"; import PagedAlertTable from "@/protoFleet/features/alerts/components/PagedAlertTable"; +import { activeGroupTitle } from "@/protoFleet/features/alerts/lib/activeGroupTitle"; import { countLabel } from "@/protoFleet/features/alerts/lib/alertCountLabels"; import type { AlertInstanceColumns } from "@/protoFleet/features/alerts/lib/alertInstanceColumns"; import { alertInstanceColTitles } from "@/protoFleet/features/alerts/lib/alertInstanceColumns"; @@ -10,10 +11,11 @@ import Modal, { sizes } from "@/shared/components/Modal"; // Every row is currently firing, so the timestamp reads as an ongoing state rather than an arrival. const instanceColTitles = { ...alertInstanceColTitles, received: "Last Seen Firing" }; -// A miner-scoped alert identifies each instance by its device; a device-less one (an unreachable curtailment -// source) is described only by its summary, so it drops the two identity columns rather than show them empty. const minerCols: AlertInstanceColumns[] = ["device", "mac", "received", "summary"]; -const deviceLessCols: AlertInstanceColumns[] = ["received", "summary"]; + +// A device-less rule fires per source rather than per miner, so there is no identity column to show: the summary +// is what names the firing instance ("Curtailment source maestro-b is unreachable"). +const instanceCols: AlertInstanceColumns[] = ["received", "summary"]; interface AlertInstancesModalProps { group: ActiveAlertGroup; @@ -23,22 +25,20 @@ interface AlertInstancesModalProps { // The instances currently firing one alert. Paged rather than fetched whole: an outage's affected set is as // large as the fleet. const AlertInstancesModal = ({ group, onClose }: AlertInstancesModalProps) => { - const hasMiners = group.device_count > 0; + const perMiner = group.device_count > 0; const { items, loading, error, hasMore, loadMore } = usePagedAlerts( // The server filters on the stored rule title, which retired-rule display mapping may have rewritten. { active_only: true, alert_name: group.stored_alert_name, rule_group: group.rule_group }, - "Failed to load affected miners", + perMiner ? "Failed to load affected miners" : "Failed to load firing instances", ); return ( { error={error} hasMore={hasMore} onLoadMore={loadMore} - activeCols={hasMiners ? minerCols : deviceLessCols} + activeCols={perMiner ? minerCols : instanceCols} colTitles={instanceColTitles} + // The modal is an elevated surface, which is a different grey from the page in dark mode. + stickyBgColor="bg-surface-elevated-base" noDataElement={
- {hasMiners ? "No affected miners." : "No firing instances."} + {perMiner ? "No affected miners." : "No firing instances."}
} /> diff --git a/client/src/protoFleet/features/alerts/components/PagedAlertTable.tsx b/client/src/protoFleet/features/alerts/components/PagedAlertTable.tsx index 42f7ed935b..016ef88f98 100644 --- a/client/src/protoFleet/features/alerts/components/PagedAlertTable.tsx +++ b/client/src/protoFleet/features/alerts/components/PagedAlertTable.tsx @@ -19,6 +19,9 @@ interface PagedAlertTableProps { activeCols: AlertInstanceColumns[]; colTitles: ColTitles; noDataElement: ReactNode; + // The surface behind the table: the sticky column paints it opaquely, and in dark mode the page and an + // elevated panel are different greys, so a table in a modal reads as a mismatched band without this. + stickyBgColor?: string; } // The keyset-paged table behind both per-instance views, the history feed and the drill-in: each supplies its @@ -32,6 +35,7 @@ const PagedAlertTable = ({ activeCols, colTitles, noDataElement, + stickyBgColor, }: PagedAlertTableProps) => { const isLoadingMore = loading && items.length > 0; @@ -51,6 +55,7 @@ const PagedAlertTable = ({ colTitles={colTitles} colConfig={alertInstanceColConfig} noDataElement={noDataElement} + stickyBgColor={stickyBgColor} /> )} diff --git a/client/src/protoFleet/features/alerts/lib/activeGroupTitle.ts b/client/src/protoFleet/features/alerts/lib/activeGroupTitle.ts new file mode 100644 index 0000000000..cc80d9b659 --- /dev/null +++ b/client/src/protoFleet/features/alerts/lib/activeGroupTitle.ts @@ -0,0 +1,17 @@ +import type { ActiveAlertGroup } from "@/protoFleet/features/alerts/types"; + +// A rule title names the one thing it fires on ("Curtailment Source Unreachable"), which reads wrong once +// several are firing at once. Only the built-in device-less rules roll up more than one instance — user rules +// fire per miner and are counted as miners — so the set that needs a plural is closed and named here. +const pluralTitles: Record = { + "Curtailment Source Unreachable": "Curtailment Sources Unreachable", +}; + +// The rollup row's heading. The group's own alert_name is the display title, already rewritten where a retired +// rule maps to a new name, so the plural is keyed off that rather than the stored name the server filters on. +export const activeGroupTitle = (group: ActiveAlertGroup) => { + if (group.device_count === 0 && group.alert_count > 1) { + return pluralTitles[group.alert_name] ?? group.alert_name; + } + return group.alert_name; +}; diff --git a/client/src/protoFleet/features/alerts/types/index.ts b/client/src/protoFleet/features/alerts/types/index.ts index ec2b317bf9..7d96282c26 100644 --- a/client/src/protoFleet/features/alerts/types/index.ts +++ b/client/src/protoFleet/features/alerts/types/index.ts @@ -137,8 +137,8 @@ export interface MaintenanceWindowWithActive extends MaintenanceWindow { export type AlertHistoryStatus = "firing" | "resolved"; -// One currently-firing alert and its blast radius. Rule identity and counts only: severity, summary and the -// rest of the per-instance detail come back from the drill-in, which reports them per affected miner. +// One currently-firing alert and its blast radius. Per-instance detail comes back from the drill-in, which +// reports it per affected miner — except on a group with no miners, which has no drill-in and carries a summary. export interface ActiveAlertGroup { // Display title, which retired-rule mapping may rewrite; stored_alert_name is what the server filters on. alert_name: string; @@ -152,6 +152,8 @@ export interface ActiveAlertGroup { // Firing instances, which exceeds device_count only for a rule firing on a non-device dimension (per MQTT source, say). alert_count: number; first_started_at: string; + // The newest instance's summary; empty unless device_count is 0, since only those rows have no drill-in. + summary: string; } export interface AlertHistoryEntry { diff --git a/client/src/protoFleet/features/dashboard/pages/Dashboard.test.tsx b/client/src/protoFleet/features/dashboard/pages/Dashboard.test.tsx index 7c0eff9e9a..ff09c80ec1 100644 --- a/client/src/protoFleet/features/dashboard/pages/Dashboard.test.tsx +++ b/client/src/protoFleet/features/dashboard/pages/Dashboard.test.tsx @@ -29,8 +29,7 @@ vi.mock("@/protoFleet/api/useOnboardedStatus", () => ({ useOnboardedStatus: () => ({ devicePaired: true, statusLoaded: true }), })); -// site:read gates whether the org catalog is fetched (and the picker mounts); -// keep everything else denied so ActiveAlertsCard stays out. +// site:read gates whether the org catalog is fetched (and the picker mounts). const permMock = vi.hoisted(() => ({ current: (_key: string): boolean => true })); vi.mock("@/protoFleet/store", () => ({ useDuration: () => "24h", @@ -38,7 +37,6 @@ vi.mock("@/protoFleet/store", () => ({ useHasPermission: (key: string) => permMock.current(key), })); -vi.mock("@/protoFleet/features/alerts/api/useAlertsEnabled", () => ({ useAlertsEnabled: () => false })); vi.mock("@/shared/hooks/useStickyState", () => ({ useStickyState: () => ({ refs: { vertical: { start: { current: null }, end: { current: null } } } }), })); diff --git a/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx b/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx index 14cff4de07..cf49719200 100644 --- a/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx +++ b/client/src/protoFleet/features/dashboard/pages/Dashboard.tsx @@ -7,8 +7,6 @@ import { useOnboardedStatus } from "@/protoFleet/api/useOnboardedStatus"; import { useTelemetryMetrics } from "@/protoFleet/api/useTelemetryMetrics"; import SitePicker, { siteFilterFromActive, useActiveSite } from "@/protoFleet/components/PageHeader/SitePicker"; import { POLL_INTERVAL_MS } from "@/protoFleet/constants/polling"; -import { useAlertsEnabled } from "@/protoFleet/features/alerts/api/useAlertsEnabled"; -import ActiveAlertsCard from "@/protoFleet/features/alerts/components/ActiveAlertsCard"; import { EfficiencyPanel } from "@/protoFleet/features/dashboard/components/EfficiencyPanel"; import FleetHealthMetrics from "@/protoFleet/features/dashboard/components/FleetHealthMetrics"; import FleetHealthSection from "@/protoFleet/features/dashboard/components/FleetHealthSection"; @@ -42,7 +40,6 @@ const Dashboard = () => { const { devicePaired, statusLoaded } = useOnboardedStatus(); const duration = useDuration(); const setDuration = useSetDuration(); - // Gate on both the read permission and the runtime feature probe so the card is hidden when the alerts sidecar is disabled. // ListSites is org-gated on site:read. Without it the shared catalog is // skipped (empty, never loaded), so — like the topbar picker in PageHeader — // the heading SitePicker is hidden. Mounting it with an empty catalog would @@ -50,9 +47,6 @@ const Dashboard = () => { // and strip the authorized scope. The page-level knownSiteIds guard below is // not enough because SitePicker recomputes its own from the `sites` prop. const canReadSites = useHasPermission("site:read"); - const hasAlertRead = useHasPermission("alert:read"); - const alertsEnabled = useAlertsEnabled(); - const canViewAlerts = hasAlertRead && alertsEnabled; const currentYear = new Date().getFullYear(); const { refs } = useStickyState(); const { bgClass } = usePageBackground(); @@ -194,11 +188,6 @@ const Dashboard = () => { )} - {canViewAlerts ? ( -
- -
- ) : null} {/* Sites Section — All Sites mode only */} diff --git a/client/src/shared/components/Popover/PopoverContent.tsx b/client/src/shared/components/Popover/PopoverContent.tsx index 756241e02b..58e0ba5038 100644 --- a/client/src/shared/components/Popover/PopoverContent.tsx +++ b/client/src/shared/components/Popover/PopoverContent.tsx @@ -83,6 +83,9 @@ const PopoverContent = ({ "w-60": size === popoverSizes.small, "w-72": size === popoverSizes.medium, "w-80": size === popoverSizes.normal, + // Twice the medium panel, for content that reads as rows rather than a paragraph. Capped to the + // viewport: the positioner pins an over-wide panel to the left margin and lets the rest run off-screen. + "w-144 max-w-[calc(100vw-1rem)]": size === popoverSizes.wide, }, className, )} diff --git a/client/src/shared/components/Popover/constants.ts b/client/src/shared/components/Popover/constants.ts index e9fa881005..51c4be28b8 100644 --- a/client/src/shared/components/Popover/constants.ts +++ b/client/src/shared/components/Popover/constants.ts @@ -2,6 +2,7 @@ export const popoverSizes = { small: "small", medium: "medium", normal: "normal", + wide: "wide", } as const; export const minimalMargin = 8; diff --git a/proto/alerts/v1/alerts.proto b/proto/alerts/v1/alerts.proto index 4c70089fee..9a9779a3c1 100644 --- a/proto/alerts/v1/alerts.proto +++ b/proto/alerts/v1/alerts.proto @@ -420,6 +420,9 @@ message ActiveAlertGroup { int64 alert_count = 4; // Earliest start across the group's instances: how long this alert has been firing somewhere in the fleet. google.protobuf.Timestamp first_started_at = 5; + // The newest instance's summary, and only for a group whose device_count is 0: those have no drill-in to + // describe them, and taking it from a device-less instance keeps device identity out of this response. + string summary = 6; } message ListActiveAlertGroupsRequest {} diff --git a/server/cmd/fleetd/config.go b/server/cmd/fleetd/config.go index 4be86c7c35..68da50a3c0 100644 --- a/server/cmd/fleetd/config.go +++ b/server/cmd/fleetd/config.go @@ -48,8 +48,9 @@ type HTTPConfig struct { type Config struct { Mode string `help:"Execution mode" enum:"server,agent,combined" default:"combined" env:"MODE"` - // Operator-facing base URL of this instance, used in outbound alert notification links. - PublicURL string `help:"Base URL of the proto-fleet instance, used in alert notification links (e.g. https://fleet.example.com)" default:"" env:"FLEET_PUBLIC_URL"` + // Operator-facing base URL of this instance, used in outbound alert notification links and, by its host, + // to name the sending instance when several of them post to one Slack channel. + PublicURL string `help:"Base URL of the proto-fleet instance, used in alert notification links and to name this instance in them (e.g. https://fleet.example.com)" default:"" env:"FLEET_PUBLIC_URL"` DB db.Config `embed:"" prefix:"db-" envprefix:"DB_"` Log logging.Config `embed:"" prefix:"logging-" envprefix:"LOG_"` diff --git a/server/generated/grpc/alerts/v1/alerts.pb.go b/server/generated/grpc/alerts/v1/alerts.pb.go index 0258a9921d..b3a908d974 100644 --- a/server/generated/grpc/alerts/v1/alerts.pb.go +++ b/server/generated/grpc/alerts/v1/alerts.pb.go @@ -3420,8 +3420,11 @@ type ActiveAlertGroup struct { AlertCount int64 `protobuf:"varint,4,opt,name=alert_count,json=alertCount,proto3" json:"alert_count,omitempty"` // Earliest start across the group's instances: how long this alert has been firing somewhere in the fleet. FirstStartedAt *timestamppb.Timestamp `protobuf:"bytes,5,opt,name=first_started_at,json=firstStartedAt,proto3" json:"first_started_at,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // The newest instance's summary, and only for a group whose device_count is 0: those have no drill-in to + // describe them, and taking it from a device-less instance keeps device identity out of this response. + Summary string `protobuf:"bytes,6,opt,name=summary,proto3" json:"summary,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *ActiveAlertGroup) Reset() { @@ -3489,6 +3492,13 @@ func (x *ActiveAlertGroup) GetFirstStartedAt() *timestamppb.Timestamp { return nil } +func (x *ActiveAlertGroup) GetSummary() string { + if x != nil { + return x.Summary + } + return "" +} + type ListActiveAlertGroupsRequest struct { state protoimpl.MessageState `protogen:"open.v1"` unknownFields protoimpl.UnknownFields @@ -4020,7 +4030,7 @@ var file_alerts_v1_alerts_proto_rawDesc = string([]byte{ 0x0a, 0x08, 0x68, 0x61, 0x73, 0x5f, 0x6d, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x68, 0x61, 0x73, 0x4d, 0x6f, 0x72, 0x65, 0x12, 0x1f, 0x0a, 0x0b, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x63, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, - 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0xda, 0x01, 0x0a, 0x10, 0x41, + 0x6e, 0x65, 0x78, 0x74, 0x43, 0x75, 0x72, 0x73, 0x6f, 0x72, 0x22, 0xf4, 0x01, 0x0a, 0x10, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x12, 0x1d, 0x0a, 0x0a, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x5f, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x4e, 0x61, 0x6d, 0x65, 0x12, 0x1d, @@ -4034,207 +4044,209 @@ var file_alerts_v1_alerts_proto_rawDesc = string([]byte{ 0x65, 0x64, 0x5f, 0x61, 0x74, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x0e, 0x66, 0x69, 0x72, 0x73, 0x74, 0x53, 0x74, - 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x22, 0x1e, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74, 0x41, - 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x22, 0x6f, 0x0a, 0x1d, 0x4c, 0x69, 0x73, 0x74, 0x41, - 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x33, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, - 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, - 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x19, 0x0a, - 0x08, 0x68, 0x61, 0x73, 0x5f, 0x6d, 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, - 0x07, 0x68, 0x61, 0x73, 0x4d, 0x6f, 0x72, 0x65, 0x2a, 0x74, 0x0a, 0x0b, 0x43, 0x68, 0x61, 0x6e, - 0x6e, 0x65, 0x6c, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x1c, 0x0a, 0x18, 0x43, 0x48, 0x41, 0x4e, 0x4e, - 0x45, 0x4c, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, - 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, - 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x57, 0x45, 0x42, 0x48, 0x4f, 0x4f, 0x4b, 0x10, 0x01, 0x12, - 0x15, 0x0a, 0x11, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, - 0x53, 0x4d, 0x54, 0x50, 0x10, 0x02, 0x12, 0x16, 0x0a, 0x12, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, - 0x4c, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x53, 0x4c, 0x41, 0x43, 0x4b, 0x10, 0x03, 0x2a, 0x87, - 0x01, 0x0a, 0x0f, 0x56, 0x61, 0x6c, 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, - 0x74, 0x65, 0x12, 0x20, 0x0a, 0x1c, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, - 0x45, 0x44, 0x10, 0x00, 0x12, 0x1c, 0x0a, 0x18, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, 0x49, - 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, - 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, 0x49, 0x4f, 0x4e, - 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x4f, 0x4b, 0x10, 0x02, 0x12, 0x1b, 0x0a, 0x17, 0x56, - 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, - 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x03, 0x2a, 0xa9, 0x02, 0x0a, 0x0c, 0x52, 0x75, 0x6c, - 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x55, 0x4c, - 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, - 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x52, 0x55, 0x4c, 0x45, - 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x4f, 0x46, 0x46, 0x4c, 0x49, 0x4e, - 0x45, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, - 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, - 0x1d, 0x0a, 0x19, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, - 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x45, 0x52, 0x41, 0x54, 0x55, 0x52, 0x45, 0x10, 0x03, 0x12, 0x16, - 0x0a, 0x12, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, - 0x50, 0x4f, 0x4f, 0x4c, 0x10, 0x04, 0x12, 0x21, 0x0a, 0x1d, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, - 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x43, 0x4f, 0x4d, 0x4d, 0x41, 0x4e, 0x44, 0x5f, - 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, 0x45, 0x10, 0x05, 0x12, 0x20, 0x0a, 0x1c, 0x52, 0x55, 0x4c, - 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x45, 0x4c, 0x45, 0x4d, - 0x45, 0x54, 0x52, 0x59, 0x5f, 0x50, 0x4f, 0x4c, 0x4c, 0x10, 0x06, 0x12, 0x22, 0x0a, 0x1e, 0x52, - 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x51, 0x54, - 0x54, 0x5f, 0x43, 0x55, 0x52, 0x54, 0x41, 0x49, 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x07, 0x12, - 0x23, 0x0a, 0x1f, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, - 0x5f, 0x4d, 0x51, 0x54, 0x54, 0x5f, 0x44, 0x49, 0x53, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, - 0x45, 0x44, 0x10, 0x08, 0x2a, 0x5c, 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x4f, 0x72, 0x69, 0x67, - 0x69, 0x6e, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x4f, 0x52, 0x49, 0x47, 0x49, - 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, - 0x1b, 0x0a, 0x17, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x4f, 0x52, 0x49, 0x47, 0x49, 0x4e, 0x5f, 0x50, - 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, 0x4f, 0x4e, 0x45, 0x44, 0x10, 0x01, 0x12, 0x14, 0x0a, 0x10, - 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x4f, 0x52, 0x49, 0x47, 0x49, 0x4e, 0x5f, 0x55, 0x53, 0x45, 0x52, - 0x10, 0x02, 0x2a, 0x69, 0x0a, 0x0c, 0x48, 0x61, 0x73, 0x68, 0x72, 0x61, 0x74, 0x65, 0x4d, 0x6f, - 0x64, 0x65, 0x12, 0x1d, 0x0a, 0x19, 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x4d, - 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, - 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x4f, - 0x44, 0x45, 0x5f, 0x50, 0x43, 0x54, 0x5f, 0x45, 0x58, 0x50, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, - 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x4f, - 0x44, 0x45, 0x5f, 0x41, 0x42, 0x53, 0x4f, 0x4c, 0x55, 0x54, 0x45, 0x10, 0x02, 0x2a, 0x65, 0x0a, - 0x0c, 0x48, 0x61, 0x73, 0x68, 0x72, 0x61, 0x74, 0x65, 0x55, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a, - 0x19, 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x49, 0x54, 0x5f, 0x55, - 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1a, 0x0a, 0x16, - 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x49, 0x54, 0x5f, 0x54, 0x45, - 0x52, 0x41, 0x48, 0x41, 0x53, 0x48, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x48, 0x41, 0x53, 0x48, - 0x52, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x49, 0x54, 0x5f, 0x50, 0x45, 0x54, 0x41, 0x48, 0x41, - 0x53, 0x48, 0x10, 0x02, 0x2a, 0x75, 0x0a, 0x0b, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x4d, - 0x6f, 0x64, 0x65, 0x12, 0x1c, 0x0a, 0x18, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x4d, - 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, - 0x00, 0x12, 0x18, 0x0a, 0x14, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x4d, 0x4f, 0x44, - 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, 0x55, 0x4c, 0x54, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x52, - 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x43, 0x55, 0x53, 0x54, - 0x4f, 0x4d, 0x10, 0x02, 0x12, 0x15, 0x0a, 0x11, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x5f, - 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x4e, 0x4f, 0x4e, 0x45, 0x10, 0x03, 0x2a, 0xee, 0x01, 0x0a, 0x1a, - 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, - 0x77, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x2d, 0x0a, 0x29, 0x4d, 0x41, - 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, - 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x55, 0x4e, 0x53, 0x50, - 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x26, 0x0a, 0x22, 0x4d, 0x41, 0x49, - 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x5f, - 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x52, 0x55, 0x4c, 0x45, 0x10, - 0x01, 0x12, 0x27, 0x0a, 0x23, 0x4d, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, 0x43, 0x45, - 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, 0x4b, 0x49, - 0x4e, 0x44, 0x5f, 0x47, 0x52, 0x4f, 0x55, 0x50, 0x10, 0x02, 0x12, 0x26, 0x0a, 0x22, 0x4d, 0x41, - 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, - 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x53, 0x49, 0x54, 0x45, - 0x10, 0x03, 0x12, 0x28, 0x0a, 0x24, 0x4d, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, 0x43, - 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, 0x4b, - 0x49, 0x4e, 0x44, 0x5f, 0x44, 0x45, 0x56, 0x49, 0x43, 0x45, 0x10, 0x04, 0x32, 0xab, 0x03, 0x0a, - 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, - 0x4f, 0x0a, 0x0c, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x12, - 0x1e, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, - 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x1f, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, - 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x52, 0x0a, 0x0d, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, - 0x6c, 0x12, 0x1f, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, - 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, - 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x68, - 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x1f, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, - 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, - 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, - 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, 0x0a, 0x0d, 0x44, 0x65, 0x6c, 0x65, - 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x1f, 0x2e, 0x61, 0x6c, 0x65, 0x72, - 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, - 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x6c, 0x65, - 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x68, 0x61, - 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x0b, - 0x54, 0x65, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x1d, 0x2e, 0x61, 0x6c, - 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, - 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, 0x6c, 0x65, - 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, - 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xa0, 0x04, 0x0a, 0x0b, 0x52, - 0x75, 0x6c, 0x65, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x46, 0x0a, 0x09, 0x4c, 0x69, - 0x73, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x12, 0x1b, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, - 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, - 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x12, 0x46, 0x0a, 0x09, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, - 0x1b, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x61, 0x75, 0x73, - 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, - 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x75, - 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x52, 0x65, - 0x73, 0x75, 0x6d, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, - 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, + 0x61, 0x72, 0x74, 0x65, 0x64, 0x41, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, + 0x72, 0x79, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x73, 0x75, 0x6d, 0x6d, 0x61, 0x72, + 0x79, 0x22, 0x1e, 0x0a, 0x1c, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, + 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x22, 0x6f, 0x0a, 0x1d, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, + 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x33, 0x0a, 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x1b, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x41, + 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x52, + 0x06, 0x67, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x19, 0x0a, 0x08, 0x68, 0x61, 0x73, 0x5f, 0x6d, + 0x6f, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x52, 0x07, 0x68, 0x61, 0x73, 0x4d, 0x6f, + 0x72, 0x65, 0x2a, 0x74, 0x0a, 0x0b, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x4b, 0x69, 0x6e, + 0x64, 0x12, 0x1c, 0x0a, 0x18, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4b, 0x49, 0x4e, + 0x44, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, + 0x18, 0x0a, 0x14, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, + 0x57, 0x45, 0x42, 0x48, 0x4f, 0x4f, 0x4b, 0x10, 0x01, 0x12, 0x15, 0x0a, 0x11, 0x43, 0x48, 0x41, + 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x53, 0x4d, 0x54, 0x50, 0x10, 0x02, + 0x12, 0x16, 0x0a, 0x12, 0x43, 0x48, 0x41, 0x4e, 0x4e, 0x45, 0x4c, 0x5f, 0x4b, 0x49, 0x4e, 0x44, + 0x5f, 0x53, 0x4c, 0x41, 0x43, 0x4b, 0x10, 0x03, 0x2a, 0x87, 0x01, 0x0a, 0x0f, 0x56, 0x61, 0x6c, + 0x69, 0x64, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x20, 0x0a, 0x1c, + 0x56, 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, + 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1c, + 0x0a, 0x18, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, + 0x54, 0x45, 0x5f, 0x50, 0x45, 0x4e, 0x44, 0x49, 0x4e, 0x47, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, + 0x56, 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, + 0x5f, 0x4f, 0x4b, 0x10, 0x02, 0x12, 0x1b, 0x0a, 0x17, 0x56, 0x41, 0x4c, 0x49, 0x44, 0x41, 0x54, + 0x49, 0x4f, 0x4e, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, + 0x10, 0x03, 0x2a, 0xa9, 0x02, 0x0a, 0x0c, 0x52, 0x75, 0x6c, 0x65, 0x54, 0x65, 0x6d, 0x70, 0x6c, + 0x61, 0x74, 0x65, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, + 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, + 0x10, 0x00, 0x12, 0x19, 0x0a, 0x15, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, + 0x41, 0x54, 0x45, 0x5f, 0x4f, 0x46, 0x46, 0x4c, 0x49, 0x4e, 0x45, 0x10, 0x01, 0x12, 0x1a, 0x0a, + 0x16, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x48, + 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x10, 0x02, 0x12, 0x1d, 0x0a, 0x19, 0x52, 0x55, 0x4c, + 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x45, + 0x52, 0x41, 0x54, 0x55, 0x52, 0x45, 0x10, 0x03, 0x12, 0x16, 0x0a, 0x12, 0x52, 0x55, 0x4c, 0x45, + 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x50, 0x4f, 0x4f, 0x4c, 0x10, 0x04, + 0x12, 0x21, 0x0a, 0x1d, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, + 0x45, 0x5f, 0x43, 0x4f, 0x4d, 0x4d, 0x41, 0x4e, 0x44, 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x55, 0x52, + 0x45, 0x10, 0x05, 0x12, 0x20, 0x0a, 0x1c, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, + 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x54, 0x45, 0x4c, 0x45, 0x4d, 0x45, 0x54, 0x52, 0x59, 0x5f, 0x50, + 0x4f, 0x4c, 0x4c, 0x10, 0x06, 0x12, 0x22, 0x0a, 0x1e, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x54, 0x45, + 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x51, 0x54, 0x54, 0x5f, 0x43, 0x55, 0x52, 0x54, + 0x41, 0x49, 0x4c, 0x4d, 0x45, 0x4e, 0x54, 0x10, 0x07, 0x12, 0x23, 0x0a, 0x1f, 0x52, 0x55, 0x4c, + 0x45, 0x5f, 0x54, 0x45, 0x4d, 0x50, 0x4c, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x51, 0x54, 0x54, 0x5f, + 0x44, 0x49, 0x53, 0x43, 0x4f, 0x4e, 0x4e, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, 0x08, 0x2a, 0x5c, + 0x0a, 0x0a, 0x52, 0x75, 0x6c, 0x65, 0x4f, 0x72, 0x69, 0x67, 0x69, 0x6e, 0x12, 0x1b, 0x0a, 0x17, + 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x4f, 0x52, 0x49, 0x47, 0x49, 0x4e, 0x5f, 0x55, 0x4e, 0x53, 0x50, + 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1b, 0x0a, 0x17, 0x52, 0x55, 0x4c, + 0x45, 0x5f, 0x4f, 0x52, 0x49, 0x47, 0x49, 0x4e, 0x5f, 0x50, 0x52, 0x4f, 0x56, 0x49, 0x53, 0x49, + 0x4f, 0x4e, 0x45, 0x44, 0x10, 0x01, 0x12, 0x14, 0x0a, 0x10, 0x52, 0x55, 0x4c, 0x45, 0x5f, 0x4f, + 0x52, 0x49, 0x47, 0x49, 0x4e, 0x5f, 0x55, 0x53, 0x45, 0x52, 0x10, 0x02, 0x2a, 0x69, 0x0a, 0x0c, + 0x48, 0x61, 0x73, 0x68, 0x72, 0x61, 0x74, 0x65, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1d, 0x0a, 0x19, + 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, + 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1e, 0x0a, 0x1a, 0x48, + 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x50, 0x43, 0x54, + 0x5f, 0x45, 0x58, 0x50, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x48, + 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x41, 0x42, 0x53, + 0x4f, 0x4c, 0x55, 0x54, 0x45, 0x10, 0x02, 0x2a, 0x65, 0x0a, 0x0c, 0x48, 0x61, 0x73, 0x68, 0x72, + 0x61, 0x74, 0x65, 0x55, 0x6e, 0x69, 0x74, 0x12, 0x1d, 0x0a, 0x19, 0x48, 0x41, 0x53, 0x48, 0x52, + 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x49, 0x54, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, + 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x1a, 0x0a, 0x16, 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, + 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x49, 0x54, 0x5f, 0x54, 0x45, 0x52, 0x41, 0x48, 0x41, 0x53, 0x48, + 0x10, 0x01, 0x12, 0x1a, 0x0a, 0x16, 0x48, 0x41, 0x53, 0x48, 0x52, 0x41, 0x54, 0x45, 0x5f, 0x55, + 0x4e, 0x49, 0x54, 0x5f, 0x50, 0x45, 0x54, 0x41, 0x48, 0x41, 0x53, 0x48, 0x10, 0x02, 0x2a, 0x75, + 0x0a, 0x0b, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x4d, 0x6f, 0x64, 0x65, 0x12, 0x1c, 0x0a, + 0x18, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x55, 0x4e, + 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x52, + 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x44, 0x45, 0x46, 0x41, + 0x55, 0x4c, 0x54, 0x10, 0x01, 0x12, 0x17, 0x0a, 0x13, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, + 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x43, 0x55, 0x53, 0x54, 0x4f, 0x4d, 0x10, 0x02, 0x12, 0x15, + 0x0a, 0x11, 0x52, 0x4f, 0x55, 0x54, 0x49, 0x4e, 0x47, 0x5f, 0x4d, 0x4f, 0x44, 0x45, 0x5f, 0x4e, + 0x4f, 0x4e, 0x45, 0x10, 0x03, 0x2a, 0xee, 0x01, 0x0a, 0x1a, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, + 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x63, 0x6f, 0x70, 0x65, + 0x4b, 0x69, 0x6e, 0x64, 0x12, 0x2d, 0x0a, 0x29, 0x4d, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, + 0x4e, 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, + 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, 0x46, 0x49, 0x45, + 0x44, 0x10, 0x00, 0x12, 0x26, 0x0a, 0x22, 0x4d, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, + 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, + 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x52, 0x55, 0x4c, 0x45, 0x10, 0x01, 0x12, 0x27, 0x0a, 0x23, 0x4d, + 0x41, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, + 0x57, 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x47, 0x52, 0x4f, + 0x55, 0x50, 0x10, 0x02, 0x12, 0x26, 0x0a, 0x22, 0x4d, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, + 0x4e, 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, 0x4f, 0x57, 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, + 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x53, 0x49, 0x54, 0x45, 0x10, 0x03, 0x12, 0x28, 0x0a, 0x24, + 0x4d, 0x41, 0x49, 0x4e, 0x54, 0x45, 0x4e, 0x41, 0x4e, 0x43, 0x45, 0x5f, 0x57, 0x49, 0x4e, 0x44, + 0x4f, 0x57, 0x5f, 0x53, 0x43, 0x4f, 0x50, 0x45, 0x5f, 0x4b, 0x49, 0x4e, 0x44, 0x5f, 0x44, 0x45, + 0x56, 0x49, 0x43, 0x45, 0x10, 0x04, 0x32, 0xab, 0x03, 0x0a, 0x0e, 0x43, 0x68, 0x61, 0x6e, 0x6e, + 0x65, 0x6c, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x4f, 0x0a, 0x0c, 0x4c, 0x69, 0x73, + 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x73, 0x12, 0x1e, 0x2e, 0x61, 0x6c, 0x65, 0x72, + 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, + 0x6c, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1f, 0x2e, 0x61, 0x6c, 0x65, 0x72, + 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, + 0x6c, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, 0x0a, 0x0d, 0x43, 0x72, + 0x65, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x1f, 0x2e, 0x61, 0x6c, + 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, 0x68, + 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, + 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x43, + 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x52, + 0x0a, 0x0d, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, + 0x1f, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x20, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, + 0x61, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, + 0x73, 0x65, 0x12, 0x52, 0x0a, 0x0d, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, + 0x6e, 0x65, 0x6c, 0x12, 0x1f, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x20, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, + 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, + 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x4c, 0x0a, 0x0b, 0x54, 0x65, 0x73, 0x74, 0x43, 0x68, + 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x12, 0x1d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, + 0x31, 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, + 0x2e, 0x54, 0x65, 0x73, 0x74, 0x43, 0x68, 0x61, 0x6e, 0x6e, 0x65, 0x6c, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xa0, 0x04, 0x0a, 0x0b, 0x52, 0x75, 0x6c, 0x65, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x46, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x52, 0x75, 0x6c, 0x65, + 0x73, 0x12, 0x1b, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, + 0x73, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, + 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x52, + 0x75, 0x6c, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x46, 0x0a, 0x09, + 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1b, 0x2e, 0x61, 0x6c, 0x65, 0x72, + 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, + 0x76, 0x31, 0x2e, 0x50, 0x61, 0x75, 0x73, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x52, 0x75, + 0x6c, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x65, 0x73, 0x75, 0x6d, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x1d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, + 0x75, 0x6d, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, + 0x49, 0x0a, 0x0a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1c, 0x2e, + 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, + 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x6c, + 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x75, + 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x55, 0x70, + 0x64, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, + 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, - 0x76, 0x31, 0x2e, 0x52, 0x65, 0x73, 0x75, 0x6d, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, + 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, - 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, - 0x65, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x12, 0x49, 0x0a, 0x0a, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1c, - 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, - 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, - 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x52, - 0x75, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x44, - 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x12, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, - 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, - 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, - 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, - 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x55, 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x52, 0x75, 0x6c, - 0x65, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x12, 0x20, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, - 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x6f, 0x75, 0x74, - 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x6c, 0x65, - 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x6f, - 0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xdf, 0x03, - 0x0a, 0x18, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x6d, 0x0a, 0x16, 0x4c, 0x69, - 0x73, 0x74, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x73, 0x12, 0x28, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, - 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, + 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, + 0x6c, 0x65, 0x74, 0x65, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x12, 0x55, 0x0a, 0x0e, 0x53, 0x65, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x6f, 0x75, 0x74, 0x69, + 0x6e, 0x67, 0x12, 0x20, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x53, + 0x65, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x65, 0x74, 0x52, 0x75, 0x6c, 0x65, 0x52, 0x6f, 0x75, 0x74, 0x69, 0x6e, 0x67, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xdf, 0x03, 0x0a, 0x18, 0x4d, 0x61, 0x69, 0x6e, + 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x6d, 0x0a, 0x16, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x69, 0x6e, + 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x12, 0x28, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x17, 0x43, 0x72, 0x65, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x29, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, + 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, + 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x17, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x69, + 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x12, 0x29, + 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, + 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, + 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x61, 0x6c, 0x65, 0x72, + 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, + 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x73, + 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x17, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, + 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, + 0x12, 0x29, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, - 0x6e, 0x64, 0x6f, 0x77, 0x12, 0x29, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, - 0x2e, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, - 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x2a, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x72, 0x65, 0x61, - 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, - 0x64, 0x6f, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x17, 0x55, - 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, - 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x12, 0x29, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, - 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, - 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, - 0x74, 0x1a, 0x2a, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, - 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, - 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, - 0x17, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, - 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x12, 0x29, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, - 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, - 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, - 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, - 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, - 0xc7, 0x01, 0x0a, 0x0e, 0x48, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, - 0x63, 0x65, 0x12, 0x49, 0x0a, 0x0a, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, - 0x12, 0x1c, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, - 0x74, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, - 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, - 0x6c, 0x65, 0x72, 0x74, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, - 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, - 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x12, 0x27, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, - 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, - 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, - 0x28, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, - 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, - 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0xa8, 0x01, 0x0a, 0x0d, 0x63, 0x6f, - 0x6d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x42, 0x0b, 0x41, 0x6c, 0x65, - 0x72, 0x74, 0x73, 0x50, 0x72, 0x6f, 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, - 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x2f, 0x70, 0x72, 0x6f, - 0x74, 0x6f, 0x2d, 0x66, 0x6c, 0x65, 0x65, 0x74, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, - 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, 0x74, 0x65, 0x64, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x61, - 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2f, 0x76, 0x31, 0x3b, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x76, - 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, 0x58, 0xaa, 0x02, 0x09, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, - 0x2e, 0x56, 0x31, 0xca, 0x02, 0x09, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x5c, 0x56, 0x31, 0xe2, - 0x02, 0x15, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, - 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0xea, 0x02, 0x0a, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, - 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, 0x61, 0x6c, + 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x4d, 0x61, + 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x70, 0x0a, 0x17, 0x44, 0x65, 0x6c, 0x65, 0x74, + 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, + 0x6f, 0x77, 0x12, 0x29, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, + 0x65, 0x6c, 0x65, 0x74, 0x65, 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, + 0x57, 0x69, 0x6e, 0x64, 0x6f, 0x77, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x2a, 0x2e, + 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, + 0x4d, 0x61, 0x69, 0x6e, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x63, 0x65, 0x57, 0x69, 0x6e, 0x64, 0x6f, + 0x77, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x32, 0xc7, 0x01, 0x0a, 0x0e, 0x48, 0x69, + 0x73, 0x74, 0x6f, 0x72, 0x79, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x49, 0x0a, 0x0a, + 0x4c, 0x69, 0x73, 0x74, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x12, 0x1c, 0x2e, 0x61, 0x6c, 0x65, + 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x6c, 0x65, 0x72, 0x74, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x1d, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, + 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x6a, 0x0a, 0x15, 0x4c, 0x69, 0x73, 0x74, 0x41, + 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, + 0x12, 0x27, 0x2e, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, + 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, + 0x70, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x28, 0x2e, 0x61, 0x6c, 0x65, 0x72, + 0x74, 0x73, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x41, 0x63, 0x74, 0x69, 0x76, 0x65, + 0x41, 0x6c, 0x65, 0x72, 0x74, 0x47, 0x72, 0x6f, 0x75, 0x70, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x42, 0xa8, 0x01, 0x0a, 0x0d, 0x63, 0x6f, 0x6d, 0x2e, 0x61, 0x6c, 0x65, 0x72, + 0x74, 0x73, 0x2e, 0x76, 0x31, 0x42, 0x0b, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x50, 0x72, 0x6f, + 0x74, 0x6f, 0x50, 0x01, 0x5a, 0x45, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, + 0x2f, 0x62, 0x6c, 0x6f, 0x63, 0x6b, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x2d, 0x66, 0x6c, 0x65, + 0x65, 0x74, 0x2f, 0x73, 0x65, 0x72, 0x76, 0x65, 0x72, 0x2f, 0x67, 0x65, 0x6e, 0x65, 0x72, 0x61, + 0x74, 0x65, 0x64, 0x2f, 0x67, 0x72, 0x70, 0x63, 0x2f, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2f, + 0x76, 0x31, 0x3b, 0x61, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x76, 0x31, 0xa2, 0x02, 0x03, 0x41, 0x58, + 0x58, 0xaa, 0x02, 0x09, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x2e, 0x56, 0x31, 0xca, 0x02, 0x09, + 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x5c, 0x56, 0x31, 0xe2, 0x02, 0x15, 0x41, 0x6c, 0x65, 0x72, + 0x74, 0x73, 0x5c, 0x56, 0x31, 0x5c, 0x47, 0x50, 0x42, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0xea, 0x02, 0x0a, 0x41, 0x6c, 0x65, 0x72, 0x74, 0x73, 0x3a, 0x3a, 0x56, 0x31, 0x62, 0x06, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, }) var ( diff --git a/server/generated/sqlc/notification_history.sql.go b/server/generated/sqlc/notification_history.sql.go index 826c95cbb7..4a30d459cb 100644 --- a/server/generated/sqlc/notification_history.sql.go +++ b/server/generated/sqlc/notification_history.sql.go @@ -136,21 +136,52 @@ func (q *Queries) InsertNotificationHistory(ctx context.Context, arg InsertNotif } const listActiveNotificationGroups = `-- name: ListActiveNotificationGroups :many +WITH groups AS ( + SELECT + na.alert_name, + na.rule_group, + COUNT(*)::bigint AS alert_count, + -- FILTER, not COUNT(DISTINCT NULLIF(...)): equivalent, but only the bare column matches + -- idx_notification_active_org_rollup's ordering, so this streams out of the GroupAggregate unsorted. + (COUNT(DISTINCT na.device_id) FILTER (WHERE na.device_id <> ''))::bigint AS device_count, + MIN(COALESCE(na.starts_at, na.received_at))::timestamptz AS first_started_at, + MAX(na.received_at) AS last_received_at + FROM notification_active na + WHERE na.organization_id = $1 + AND na.status = 'firing' + AND na.received_at >= $2 -- drop alerts not re-asserted within the freshness window + GROUP BY na.alert_name, na.rule_group + ORDER BY device_count DESC, alert_count DESC, MAX(na.received_at) DESC, na.alert_name + LIMIT $3 +) SELECT - na.alert_name, - na.rule_group, - COUNT(*)::bigint AS alert_count, - -- FILTER, not COUNT(DISTINCT NULLIF(...)): equivalent, but only the bare column matches - -- idx_notification_active_org_rollup's ordering, so this streams out of the GroupAggregate unsorted. - (COUNT(DISTINCT na.device_id) FILTER (WHERE na.device_id <> ''))::bigint AS device_count, - MIN(COALESCE(na.starts_at, na.received_at))::timestamptz AS first_started_at -FROM notification_active na -WHERE na.organization_id = $1 - AND na.status = 'firing' - AND na.received_at >= $2 -- drop alerts not re-asserted within the freshness window -GROUP BY na.alert_name, na.rule_group -ORDER BY device_count DESC, alert_count DESC, MAX(na.received_at) DESC, na.alert_name -LIMIT $3 + g.alert_name, + g.rule_group, + g.alert_count, + g.device_count, + g.first_started_at, + -- A group with miners is described by its drill-in, so it reports no free text here and the CASE drops what + -- the lateral found. device_id = '' is an index prefix, so a group with no device-less rows costs one descent. + -- The summary is a header-polled one-liner, so it is bounded here rather than shipping a whole TEXT column; + -- the template rides along because the caller decides from it whether the summary may name a miner. Neither + -- is length-checked on insert (000136 bounds only the indexed columns), and a truncated template fails that + -- decision closed: it matches no known template, so the summary is withheld. + (CASE WHEN g.device_count = 0 THEN LEFT(COALESCE(s.summary, ''), 500) ELSE '' END)::text AS summary, + (CASE WHEN g.device_count = 0 THEN LEFT(COALESCE(s.template, ''), 64) ELSE '' END)::text AS template +FROM groups g +LEFT JOIN LATERAL ( + SELECT na.summary, na.template + FROM notification_active na + WHERE na.organization_id = $1 + AND na.status = 'firing' + AND na.received_at >= $2 + AND na.alert_name = g.alert_name + AND na.rule_group = g.rule_group + AND na.device_id = '' + ORDER BY na.received_at DESC, na.history_id DESC + LIMIT 1 +) s ON TRUE +ORDER BY g.device_count DESC, g.alert_count DESC, g.last_received_at DESC, g.alert_name ` type ListActiveNotificationGroupsParams struct { @@ -165,12 +196,14 @@ type ListActiveNotificationGroupsRow struct { AlertCount int64 DeviceCount int64 FirstStartedAt time.Time + Summary string + Template string } // Firing alerts rolled up per rule, worst blast radius first. (alert_name, rule_group) is rule identity: Grafana // keeps titles unique per folder and a rule_group label maps to one folder, so a title repeats only across labels. -// Identity and counts only: per-instance detail would have to be picked off one instance, and the drill-in already -// reports it per row. +// Counts and identity aggregate here; the one piece of per-instance detail is picked off in the lateral below. +// Repeated: the CTE's ordering is not guaranteed to survive the join. func (q *Queries) ListActiveNotificationGroups(ctx context.Context, arg ListActiveNotificationGroupsParams) ([]ListActiveNotificationGroupsRow, error) { rows, err := q.query(ctx, q.listActiveNotificationGroupsStmt, listActiveNotificationGroups, arg.OrganizationID, arg.ActiveSince, arg.PageLimit) if err != nil { @@ -186,6 +219,8 @@ func (q *Queries) ListActiveNotificationGroups(ctx context.Context, arg ListActi &i.AlertCount, &i.DeviceCount, &i.FirstStartedAt, + &i.Summary, + &i.Template, ); err != nil { return nil, err } diff --git a/server/generated/sqlc/querier.go b/server/generated/sqlc/querier.go index 91d36dd0e2..8d568a7b27 100644 --- a/server/generated/sqlc/querier.go +++ b/server/generated/sqlc/querier.go @@ -836,8 +836,8 @@ type Querier interface { ListActiveCurtailmentTargetDevicesByOrg(ctx context.Context, orgID int64) ([]string, error) // Firing alerts rolled up per rule, worst blast radius first. (alert_name, rule_group) is rule identity: Grafana // keeps titles unique per folder and a rule_group label maps to one folder, so a title repeats only across labels. - // Identity and counts only: per-instance detail would have to be picked off one instance, and the drill-in already - // reports it per row. + // Counts and identity aggregate here; the one piece of per-instance detail is picked off in the lateral below. + // Repeated: the CTE's ordering is not guaranteed to survive the join. ListActiveNotificationGroups(ctx context.Context, arg ListActiveNotificationGroupsParams) ([]ListActiveNotificationGroupsRow, error) // Current firing alerts (one row per alert instance), served from the incrementally-maintained // notification_active table, which also retains resolved tombstones; device name/MAC are joined live diff --git a/server/internal/domain/alerts/deliver_test.go b/server/internal/domain/alerts/deliver_test.go index 5cc13eeeea..d0c09126ba 100644 --- a/server/internal/domain/alerts/deliver_test.go +++ b/server/internal/domain/alerts/deliver_test.go @@ -78,7 +78,7 @@ func TestDeliverSlackChannel(t *testing.T) { require.Len(t, *got, 1) body := string((*got)[0].body) assert.Contains(t, body, "blocks") - assert.Contains(t, body, "miner-01 (aa:bb)") + assert.Contains(t, body, "miner-01 (`aa:bb`)") assert.NotContains(t, body, "grafana") assert.Empty(t, (*got)[0].auth, "slack webhook posts carry no bearer") } diff --git a/server/internal/domain/alerts/models.go b/server/internal/domain/alerts/models.go index dd6dd65fd7..8c61810209 100644 --- a/server/internal/domain/alerts/models.go +++ b/server/internal/domain/alerts/models.go @@ -61,6 +61,15 @@ const ( // proto-fleet-rules.yaml. RuleTemplateMQTTCurtailment RuleTemplate = "mqtt-curtailment" RuleTemplateMQTTDisconnected RuleTemplate = "mqtt-disconnected" + + // Facility fan restore failed while curtailment was releasing miners. + // Provisioned-only, like the MQTT pair above, and named here so the + // summary-visibility gate can recognize it as device-less. + RuleTemplateCurtailmentFanRestore RuleTemplate = "curtailment-fan-restore" + + // Self-monitoring: metric ingest has stalled. Operator-owned but fanned out + // to every org's history, so it needs the same device-less recognition. + RuleTemplateMetricIngest RuleTemplate = "metric-ingest" ) // Origin decides mutability: only user rules accept UpdateRule/DeleteRule. diff --git a/server/internal/domain/alerts/render.go b/server/internal/domain/alerts/render.go index e02294bccf..25fedfb3de 100644 --- a/server/internal/domain/alerts/render.go +++ b/server/internal/domain/alerts/render.go @@ -3,47 +3,59 @@ package alerts import ( "cmp" "fmt" + "net/url" "slices" "strings" "unicode/utf8" ) -// Slack limits: header plain_text ≤150 chars, section text ≤3000, ≤50 blocks per message. +// Slack limits: section text ≤3000, ≤50 blocks per message. const ( - slackHeaderMaxRunes = 150 slackSectionMaxRunes = 2900 - // Cap alert sections so header + link + 2 headings + overflow line stay under Slack's 50-block limit. + // Cap alert sections so title + heading + overflow line stay under Slack's 50-block limit. slackMaxAlertSections = 40 + // Keep the instance name from crowding the alert counts out of the truncated title. + slackInstanceMaxRunes = 50 ) +// The title's dot carries the batch's worst severity, worst first. An unrecognized or missing severity ranks +// with the worst: rules can carry any severity label, and a colour is a weak reason to under-signal one. +var slackSeverityDots = []struct{ severity, dot string }{ + {"critical", "🔴"}, + {"warning", "🟡"}, + {"info", "🔵"}, +} + // renderSlack builds a Block Kit message that carries no alerting-engine internals. Alerts are rolled up per // rule, so a fleet-wide outage is a handful of sections with miner counts rather than thousands of miner lines. func renderSlack(publicURL string, alerts []Alert, identities map[string]DeviceIdentity) map[string]any { firing, resolved := partitionAlerts(alerts) firingGroups := groupAlerts(firing) resolvedGroups := groupAlerts(resolved) - title := slackTitle(firingGroups, distinctDevices(firing)) + firingDevices := distinctDevices(firing) + plain, linked := instanceLabels(publicURL) + // The fallback carries the same title without the link markup, which clients that don't render blocks + // would show verbatim. + title := slackTitle(plain, firingGroups, firingDevices) - blocks := []map[string]any{headerBlock(title)} - if publicURL != "" { - blocks = append(blocks, mrkdwnSection(fmt.Sprintf("<%s|Open Proto Fleet>", publicURL))) - } + blocks := []map[string]any{mrkdwnSection("*" + slackTitle(linked, firingGroups, firingDevices) + "*")} remaining := slackMaxAlertSections - appendSection := func(heading string, groups []alertGroup) { - if len(groups) == 0 { - return - } - blocks = append(blocks, mrkdwnSection("*"+heading+"*")) + appendGroups := func(groups []alertGroup) { for _, g := range groups { if remaining <= 0 { - break + return } - blocks = append(blocks, mrkdwnSection(truncate(groupLine(g, identities), slackSectionMaxRunes))) + blocks = append(blocks, mrkdwnSection(groupLine(g, identities))) remaining-- } } - appendSection("Firing", firingGroups) - appendSection("Resolved", resolvedGroups) + // Firing groups need no heading — the title already says how many are firing — but resolved ones do, + // since nothing else separates them from the alerts still up. + appendGroups(firingGroups) + if len(resolvedGroups) > 0 && remaining > 0 { + blocks = append(blocks, mrkdwnSection("*Resolved*")) + appendGroups(resolvedGroups) + } if overflow := len(firingGroups) + len(resolvedGroups) - slackMaxAlertSections; overflow > 0 { blocks = append(blocks, mrkdwnSection(fmt.Sprintf("_…and %d more — open Proto Fleet for the full list._", overflow))) } @@ -52,11 +64,46 @@ func renderSlack(publicURL string, alerts []Alert, identities map[string]DeviceI return map[string]any{"text": title, "blocks": blocks} } -func slackTitle(firing []alertGroup, firingDevices int) string { +// instanceLabels names the sending fleet: several instances can post to one channel, and nothing else in the +// message says which one fired, so the public URL's host names the sender and doubles as the link to it. It +// returns the plain label for the notification fallback and the linked one for the block. A value with no +// host is a misconfigured link, not an instance name. +func instanceLabels(publicURL string) (plain, linked string) { + const product = "Proto Fleet" + u, err := url.Parse(publicURL) + if err != nil || u.Host == "" { + return product, product + } + host := truncate(u.Host, slackInstanceMaxRunes) + // Only the block form is mrkdwn; escaping the fallback would show the entities themselves. + return fmt.Sprintf("%s (%s)", product, host), + fmt.Sprintf("%s (<%s|%s>)", product, publicURL, escapeMrkdwn(host)) +} + +// severityDot picks the dot for the worst severity firing, so a batch of warnings doesn't read as an outage. +func severityDot(firing []alertGroup) string { + worst := len(slackSeverityDots) - 1 + for _, g := range firing { + worst = min(worst, severityRank(g.Severity)) + } + return slackSeverityDots[worst].dot +} + +func severityRank(severity string) int { + severity = strings.TrimSpace(severity) + for i, s := range slackSeverityDots { + if strings.EqualFold(severity, s.severity) { + return i + } + } + return 0 +} + +func slackTitle(instance string, firing []alertGroup, firingDevices int) string { if len(firing) == 0 { - return "✅ Proto Fleet — alerts resolved" + return "✅ " + instance + " — alerts resolved" } - base := fmt.Sprintf("🔴 Proto Fleet — %d alert%s firing", len(firing), plural(len(firing))) + base := fmt.Sprintf("%s %s — %d alert%s firing", severityDot(firing), instance, len(firing), plural(len(firing))) // Fleet- and source-scoped alerts have no miners to count; don't claim "on 0 miners". if firingDevices == 0 { return base @@ -102,25 +149,22 @@ func groupLine(g alertGroup, identities map[string]DeviceIdentity) string { return b.String() } -// escapeMrkdwn escapes Slack's reserved mrkdwn chars so user-controlled text can't break rendering or inject a `` link. +// escapeMrkdwn neutralizes the mrkdwn chars that let user-controlled text escape its own field: the link syntax, +// and the backtick, since a code span opened in one field closes at the next backtick anywhere in the section. +// It does not touch `*`, `_`, `~`, or `:shortcode:`, so user text can still format itself and interpolate emoji. func escapeMrkdwn(s string) string { s = strings.ReplaceAll(s, "&", "&") s = strings.ReplaceAll(s, "<", "<") s = strings.ReplaceAll(s, ">", ">") + s = strings.ReplaceAll(s, "`", "") return s } -func headerBlock(text string) map[string]any { - return map[string]any{ - "type": "header", - "text": map[string]any{"type": "plain_text", "text": truncate(text, slackHeaderMaxRunes), "emoji": true}, - } -} - +// The cap lives here rather than at each call site, so no section can exceed it however it was built. func mrkdwnSection(text string) map[string]any { return map[string]any{ "type": "section", - "text": map[string]any{"type": "mrkdwn", "text": text}, + "text": map[string]any{"type": "mrkdwn", "text": truncate(text, slackSectionMaxRunes)}, } } @@ -217,10 +261,19 @@ func distinctDevices(alerts []Alert) int { return len(seen) } +// macCode renders a MAC as inline code, which is both how an identifier reads best and the only mrkdwn context +// where Slack leaves `:shortcode:` alone — a colon-separated MAC otherwise interpolates `:ab:` as 🆎. +func macCode(mac string) string { + if mac == "" { + return "" + } + return "`" + escapeMrkdwn(mac) + "`" +} + func deviceLabel(id string, identities map[string]DeviceIdentity) string { ident := identities[id] name := escapeMrkdwn(strings.TrimSpace(ident.Name)) - mac := escapeMrkdwn(ident.MAC) + mac := macCode(ident.MAC) switch { case name != "" && mac != "": return fmt.Sprintf("%s (%s)", name, mac) diff --git a/server/internal/domain/alerts/render_test.go b/server/internal/domain/alerts/render_test.go index debb6e9828..f739eaaa21 100644 --- a/server/internal/domain/alerts/render_test.go +++ b/server/internal/domain/alerts/render_test.go @@ -47,36 +47,66 @@ func TestRenderSlackHidesAlertingInternals(t *testing.T) { } } -func TestRenderSlackHeaderLinksToInstance(t *testing.T) { +func TestRenderSlackTitleLinksTheInstanceName(t *testing.T) { ids := map[string]DeviceIdentity{ "dev-a": {Name: "miner-01", MAC: "aa:bb:cc:dd:ee:ff"}, "dev-b": {Name: "miner-02", MAC: "11:22:33:44:55:66"}, } msg := renderSlack("https://fleet.example.com", sampleAlerts(), ids) - assert.Equal(t, "🔴 Proto Fleet — 2 alerts firing on 2 miners", msg["text"]) + // The fallback preview is the same title without link markup, which a blockless client would show raw. + assert.Equal(t, "🟡 Proto Fleet (fleet.example.com) — 2 alerts firing on 2 miners", msg["text"]) blocks, ok := msg["blocks"].([]map[string]any) require.True(t, ok) require.NotEmpty(t, blocks) - assert.Equal(t, "header", blocks[0]["type"]) - // The clickable instance link is a mrkdwn section (Block Kit headers can't hold links). - assert.Equal(t, "", sectionText(t, blocks[1])) + // The instance name is itself the link, so the message carries no separate "open the app" line. + assert.Equal(t, + "*🟡 Proto Fleet () — 2 alerts firing on 2 miners*", + sectionText(t, blocks[0])) body := mustJSON(t, msg) - assert.Contains(t, body, "*Firing*") + // Firing alerts need no heading — the title counts them — but the resolved ones must stay separable. + assert.NotContains(t, body, "*Firing*") assert.Contains(t, body, "*Resolved*") - assert.Contains(t, body, "*Device Temperature High* _(warning)_ — miner-02 (11:22:33:44:55:66)") + assert.Contains(t, body, "*Device Temperature High* _(warning)_ — miner-02 (`11:22:33:44:55:66`)") assert.Contains(t, body, "Max sensor temperature is above 90C.") - assert.Contains(t, body, "*Device Offline* _(warning)_ — miner-01 (aa:bb:cc:dd:ee:ff)") + assert.Contains(t, body, "*Device Offline* _(warning)_ — miner-01 (`aa:bb:cc:dd:ee:ff`)") } func TestRenderSlackOmitsLinkWhenNoPublicURL(t *testing.T) { msg := renderSlack("", sampleAlerts(), nil) - body := mustJSON(t, msg) - assert.NotContains(t, body, "Open Proto Fleet") - // Header is still present. - assert.Contains(t, body, "Proto Fleet — 2 alerts firing") + blocks, ok := msg["blocks"].([]map[string]any) + require.True(t, ok) + // With nothing to link to, the title is the plain product name rather than an empty link. + assert.Equal(t, "*🟡 Proto Fleet — 2 alerts firing on 2 miners*", sectionText(t, blocks[0])) +} + +func TestRenderSlackDotMatchesTheWorstFiringSeverity(t *testing.T) { + alert := func(name, severity string) Alert { + return Alert{Status: "firing", Labels: map[string]string{"alertname": name, "severity": severity}} + } + tests := []struct { + name string + alerts []Alert + want string + }{ + {"info alone is blue", []Alert{alert("A", "info")}, "🔵"}, + {"warning alone is yellow", []Alert{alert("A", "warning")}, "🟡"}, + {"one critical reddens a batch of warnings", []Alert{alert("A", "warning"), alert("B", "critical")}, "🔴"}, + {"case and padding are still the same severity", []Alert{alert("A", " Warning ")}, "🟡"}, + // A rule can carry any severity label; a colour is a weak reason to signal one as less than it is. + {"an unrecognized severity is not downgraded", []Alert{alert("A", "page-me"), alert("B", "info")}, "🔴"}, + {"a missing severity is not downgraded", []Alert{alert("A", "")}, "🔴"}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + title, ok := renderSlack("", tc.alerts, nil)["text"].(string) + require.True(t, ok) + assert.True(t, strings.HasPrefix(title, tc.want), "want %s dot, got %q", tc.want, title) + }) + } } func TestRenderSlackFallsBackToDeviceID(t *testing.T) { @@ -84,9 +114,62 @@ func TestRenderSlackFallsBackToDeviceID(t *testing.T) { assert.Contains(t, body, "— dev-b", "with no identity, the raw device id is shown") } +func TestRenderSlackRendersMACsAsCode(t *testing.T) { + ids := map[string]DeviceIdentity{ + // ":ab:" is a Slack shortcode (🆎) and "ab" is a valid octet, so a bare MAC interpolates mid-address. + "dev-a": {Name: "miner-01", MAC: "12:ab:34:cd:56:ef"}, + // A backtick in any field of the section would close a MAC's span early, name and address alike. + "dev-b": {Name: "miner`02", MAC: "aa:`:bb"}, + } + text := allSectionText(t, renderSlack("", sampleAlerts(), ids)) + + assert.Contains(t, text, "miner-01 (`12:ab:34:cd:56:ef`)") + assert.Contains(t, text, "miner02 (`aa::bb`)") +} + func TestRenderSlackAllResolvedTitle(t *testing.T) { resolvedOnly := []Alert{{Status: "resolved", Labels: map[string]string{"alertname": "Device Offline"}}} assert.Equal(t, "✅ Proto Fleet — alerts resolved", renderSlack("", resolvedOnly, nil)["text"]) + // A quiet channel shared by several fleets still has to say whose alerts cleared. + msg := renderSlack("https://fleet.example.com", resolvedOnly, nil) + assert.Equal(t, "✅ Proto Fleet (fleet.example.com) — alerts resolved", msg["text"]) +} + +func TestRenderSlackNamesTheInstanceInTheTitle(t *testing.T) { + tests := []struct { + name string + publicURL string + want string + }{ + { + name: "host only, so the scheme and path stay out of the title", + publicURL: "https://fleet.rockdale.example.com/miners?tab=all", + want: "🟡 Proto Fleet (fleet.rockdale.example.com) — 2 alerts firing on 2 miners", + }, + { + name: "port kept, since two local instances differ only by it", + publicURL: "http://localhost:8080", + want: "🟡 Proto Fleet (localhost:8080) — 2 alerts firing on 2 miners", + }, + { + name: "no host to name, so the title is left as it was", + publicURL: "fleet.navarro.example.com", + want: "🟡 Proto Fleet — 2 alerts firing on 2 miners", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + assert.Equal(t, tc.want, renderSlack(tc.publicURL, sampleAlerts(), nil)["text"]) + }) + } +} + +func TestRenderSlackTruncatesALongInstanceName(t *testing.T) { + msg := renderSlack("https://"+strings.Repeat("a", 200)+".example.com", sampleAlerts(), nil) + + want := "🟡 Proto Fleet (" + strings.Repeat("a", slackInstanceMaxRunes) + ") — 2 alerts firing on 2 miners" + assert.Equal(t, want, msg["text"], "the counts survive a hostname that would fill the title") } func TestRenderWebhookResolvesDeviceMetadata(t *testing.T) { @@ -133,12 +216,12 @@ func outageAlerts(count int) []Alert { func TestRenderSlackRollsUpOneAlertAcrossManyMiners(t *testing.T) { msg := renderSlack("https://fleet.example.com", outageAlerts(500), nil) - assert.Equal(t, "🔴 Proto Fleet — 1 alert firing on 500 miners", msg["text"]) + assert.Equal(t, "🔴 Proto Fleet (fleet.example.com) — 1 alert firing on 500 miners", msg["text"]) blocks, ok := msg["blocks"].([]map[string]any) require.True(t, ok) - // header + link + "Firing" heading + one rolled-up section: the miner count no longer drives block count. - assert.Len(t, blocks, 4) + // title + one rolled-up section: the miner count no longer drives block count. + assert.Len(t, blocks, 2) text := allSectionText(t, msg) assert.Contains(t, text, "*Device Offline* _(critical)_ — 500 miners") @@ -194,7 +277,7 @@ func TestRenderSlackCountsEachMinerOnceAcrossAlerts(t *testing.T) { } msg := renderSlack("", []Alert{both("Device Offline"), both("Device Hashrate Low")}, nil) // One miner with two alerts is one affected miner, not two. - assert.Equal(t, "🔴 Proto Fleet — 2 alerts firing on 1 miner", msg["text"]) + assert.Equal(t, "🟡 Proto Fleet — 2 alerts firing on 1 miner", msg["text"]) } func TestRenderSlackOrdersGroupsByBlastRadius(t *testing.T) { diff --git a/server/internal/domain/notificationhistory/notificationhistory.go b/server/internal/domain/notificationhistory/notificationhistory.go index 46197575ad..ad2cf16db7 100644 --- a/server/internal/domain/notificationhistory/notificationhistory.go +++ b/server/internal/domain/notificationhistory/notificationhistory.go @@ -38,7 +38,7 @@ type StoredNotification struct { } // ActiveAlertGroup is one firing rule's rollup: its blast radius across the fleet rather than a single instance. -// Identity and counts only; per-instance detail (severity, summary) belongs to the drill-in rows that carry it. +// Per-instance detail belongs to the drill-in rows that carry it, except on a group with no miners to drill into. type ActiveAlertGroup struct { AlertName string RuleGroup string @@ -46,6 +46,10 @@ type ActiveAlertGroup struct { AlertCount int64 DeviceCount int64 FirstStartedAt time.Time + // The newest instance's summary and the template it came from, both empty unless DeviceCount is 0. A + // device-less instance is not on its own proof the text names no miner, so the template gates it. + Summary string + Template string } type ActiveAlertFilter struct { diff --git a/server/internal/domain/stores/sqlstores/notification_history.go b/server/internal/domain/stores/sqlstores/notification_history.go index 68f5bd466e..ef3741f8c0 100644 --- a/server/internal/domain/stores/sqlstores/notification_history.go +++ b/server/internal/domain/stores/sqlstores/notification_history.go @@ -271,6 +271,8 @@ func (s *SQLNotificationHistoryStore) ListActiveGroups(ctx context.Context, orga AlertCount: row.AlertCount, DeviceCount: row.DeviceCount, FirstStartedAt: row.FirstStartedAt, + Summary: row.Summary, + Template: row.Template, }) } return out, nil diff --git a/server/internal/domain/stores/sqlstores/notification_history_test.go b/server/internal/domain/stores/sqlstores/notification_history_test.go index 38cde7f5e8..760d2cb8ed 100644 --- a/server/internal/domain/stores/sqlstores/notification_history_test.go +++ b/server/internal/domain/stores/sqlstores/notification_history_test.go @@ -166,6 +166,49 @@ func TestNotificationHistoryStore_ListActiveGroups_RollsUpPerAlert(t *testing.T) assert.Equal(t, int64(1), groups[1].DeviceCount) } +// A group with no miners has no per-miner drill-in to describe it, so the rollup carries one summary; a group +// with miners leaves its free text on the drill-in rows, which are gated on miner:read. +func TestNotificationHistoryStore_ListActiveGroups_SummarizesOnlyDeviceLessGroups(t *testing.T) { + if testing.Short() { + t.Skip("Skipping database integration test in short mode") + } + + testContext := testutil.InitializeDBServiceInfrastructure(t) + db := testContext.DatabaseService.DB + orgID := testContext.DatabaseService.CreateSuperAdminUser().OrganizationID + store := sqlstores.NewSQLNotificationHistoryStore(db) + now := time.Now() + + insertAlert(t, db, orgID, "Curtailment Source Unreachable", "curtailment", "", "source-a", + "maestro-a is unreachable", now.Add(-10*time.Minute)) + insertAlert(t, db, orgID, "Curtailment Source Unreachable", "curtailment", "", "source-b", + "maestro-b is unreachable", now.Add(-2*time.Minute)) + insertDeviceAlert(t, db, orgID, "Device Offline", "defaults", "device-1", "device-1 is offline", now.Add(-5*time.Minute)) + // The header polls this rollup, so a runaway interpolation must not ship a whole TEXT column per group. + insertAlert(t, db, orgID, "Runaway Summary", "curtailment", "", "source-c", + strings.Repeat("x", 900), now.Add(-time.Minute)) + + groups, err := store.ListActiveGroups(t.Context(), orgID, 50) + require.NoError(t, err) + require.Len(t, groups, 3) + + byName := make(map[string]notificationhistory.ActiveAlertGroup, len(groups)) + for _, g := range groups { + byName[g.AlertName] = g + } + + sourceGroup := byName["Curtailment Source Unreachable"] + assert.Equal(t, int64(0), sourceGroup.DeviceCount) + assert.Equal(t, int64(2), sourceGroup.AlertCount) + // The newest instance, so the summary names the source that most recently went unreachable. + assert.Equal(t, "maestro-b is unreachable", sourceGroup.Summary) + // Carried unredacted: the handler, not the store, decides whether the template lets that text out. + assert.Equal(t, "offline", sourceGroup.Template) + + assert.Empty(t, byName["Device Offline"].Summary, "a group with miners reports no free text here") + assert.Len(t, byName["Runaway Summary"].Summary, 500) +} + // The trigger leaves an oversized label out of the active view (see 000136), and a real alert inserted alongside // it still lands, so a forged label can't hide the fleet's state. func TestNotificationActiveSync_SkipsOversizedIndexedLabels(t *testing.T) { diff --git a/server/internal/handlers/alerts/handler.go b/server/internal/handlers/alerts/handler.go index b2c94cf2e5..dfed67da67 100644 --- a/server/internal/handlers/alerts/handler.go +++ b/server/internal/handlers/alerts/handler.go @@ -464,8 +464,9 @@ func parseBeforeID(s string) (*int64, error) { return &v, nil } -// ListActiveAlertGroups answers "what is firing right now" per rule. Rule identity and counts carry no device -// identity and no rule-annotation free text, so this needs no miner:read gate; the drill-in rows still do. +// ListActiveAlertGroups answers "what is firing right now" per rule. No row carries device identity, and the one +// summary it does carry clears the same template gate the history API applies, so this needs no miner:read gate; +// the drill-in does. func (h *Handler) ListActiveAlertGroups(ctx context.Context, _ *connect.Request[alertsv1.ListActiveAlertGroupsRequest]) (*connect.Response[alertsv1.ListActiveAlertGroupsResponse], error) { orgID, err := h.authorize(ctx, authz.PermAlertRead) if err != nil { @@ -488,6 +489,9 @@ func (h *Handler) ListActiveAlertGroups(ctx context.Context, _ *connect.Request[ AlertCount: g.AlertCount, DeviceCount: g.DeviceCount, FirstStartedAt: timestamppb.New(g.FirstStartedAt), + // Same policy as the history API: a device template's summary names the miner, so it needs + // miner:read, which this endpoint never has. Device-less alone would let a stray offline row through. + Summary: visibleSummary(g.Summary, g.Template, false), }) } return connect.NewResponse(&alertsv1.ListActiveAlertGroupsResponse{ @@ -828,21 +832,34 @@ func historyEntryToProto(n notificationhistory.StoredNotification, includeDevice return out } -// A device template's summary names the miner, so it needs miner:read; a source-level one names only the MQTT -// source, which is not miner identity. +// A device template's summary names the miner, so it needs miner:read; a device-less one names a source, a +// curtailment event, or the fleet, none of which is miner identity. func visibleSummary(summary, template string, includeDevice bool) string { - if includeDevice || isSourceLevelTemplate(template) { + if includeDevice || isDeviceLessTemplate(template) { return summary } return "" } -// isSourceLevelTemplate reports whether the template scopes the alert to an -// MQTT curtailment source rather than a device. The label stays trustworthy: -// user rules only emit offline/hashrate/temperature (compileUserRule). -func isSourceLevelTemplate(t string) bool { - tmpl := alerts.RuleTemplate(t) - return tmpl == alerts.RuleTemplateMQTTCurtailment || tmpl == alerts.RuleTemplateMQTTDisconnected +// The provisioned templates that fire on a source, a curtailment event, or the fleet rather than on a miner, so +// their annotation summaries name no device. Anything absent fails closed, which costs a non-miner reader only +// the free text — counts, rule identity, and timing still come through. A device-less rule added to +// proto-fleet-rules.yaml without a template label lands here too, unnamed and therefore redacted, so a new one +// carries a label (see the metric-ingest rule) rather than relying on this list recognizing its absence. +var deviceLessTemplates = map[alerts.RuleTemplate]struct{}{ + alerts.RuleTemplateMQTTCurtailment: {}, + alerts.RuleTemplateMQTTDisconnected: {}, + alerts.RuleTemplateCurtailmentFanRestore: {}, + alerts.RuleTemplateTelemetryPoll: {}, + alerts.RuleTemplateMetricIngest: {}, +} + +// isDeviceLessTemplate reports whether the template's summary is safe to show without miner:read. The label +// stays trustworthy: user rules only emit offline/hashrate/temperature (compileUserRule), so an org cannot mint +// a miner alert that claims one of these. +func isDeviceLessTemplate(t string) bool { + _, ok := deviceLessTemplates[alerts.RuleTemplate(t)] + return ok } func channelKindToProto(k alerts.ChannelKind) alertsv1.ChannelKind { @@ -907,6 +924,9 @@ func ruleTemplateToProto(t alerts.RuleTemplate) alertsv1.RuleTemplate { return alertsv1.RuleTemplate_RULE_TEMPLATE_MQTT_CURTAILMENT case alerts.RuleTemplateMQTTDisconnected: return alertsv1.RuleTemplate_RULE_TEMPLATE_MQTT_DISCONNECTED + // Provisioned-only and carried as raw labels on alert rows, not as rule shapes any client creates or + // renders, so they have no proto counterpart and report unspecified like any label this build doesn't know. + case alerts.RuleTemplateCurtailmentFanRestore, alerts.RuleTemplateMetricIngest: } return alertsv1.RuleTemplate_RULE_TEMPLATE_UNSPECIFIED } diff --git a/server/internal/handlers/alerts/handler_history_test.go b/server/internal/handlers/alerts/handler_history_test.go index 5bd56723bd..a9d8be2b24 100644 --- a/server/internal/handlers/alerts/handler_history_test.go +++ b/server/internal/handlers/alerts/handler_history_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" alertsv1 "github.com/block/proto-fleet/server/generated/grpc/alerts/v1" + "github.com/block/proto-fleet/server/internal/domain/alerts" "github.com/block/proto-fleet/server/internal/domain/authz" "github.com/block/proto-fleet/server/internal/domain/fleeterror" "github.com/block/proto-fleet/server/internal/domain/notificationhistory" @@ -174,11 +175,15 @@ func TestListActiveAlertGroups_ReturnsRollup(t *testing.T) { require.Equal(t, int32(activeGroupsMaxPageSize+1), lister.lastLimit) } -// The rollup reports rule identity and counts only, so a viewer without miner:read reads the same rows a -// miner reader does — there is no device identity or rule-annotation free text in it to redact. +// The rollup reports rule identity, counts, and — only where there are no miners — a summary read off a +// device-less instance, so a viewer without miner:read reads the same rows a miner reader does. func TestListActiveAlertGroups_NeedsNoMinerRead(t *testing.T) { groups := []notificationhistory.ActiveAlertGroup{ {AlertName: "MinerOffline", DeviceCount: 5_000, AlertCount: 5_000}, + { + AlertName: "CurtailmentSourceUnreachable", AlertCount: 2, + Summary: "maestro-b is unreachable", Template: string(alerts.RuleTemplateMQTTDisconnected), + }, } withoutMiner, err := NewHandler(nil, &stubLister{groups: groups}).ListActiveAlertGroups( @@ -192,10 +197,97 @@ func TestListActiveAlertGroups_NeedsNoMinerRead(t *testing.T) { ) require.NoError(t, err) - require.Len(t, withoutMiner.Msg.Groups, 1) + require.Len(t, withoutMiner.Msg.Groups, 2) require.Equal(t, withMiner.Msg.Groups[0].AlertName, withoutMiner.Msg.Groups[0].AlertName) require.Equal(t, withMiner.Msg.Groups[0].DeviceCount, withoutMiner.Msg.Groups[0].DeviceCount) require.Equal(t, int64(5_000), withoutMiner.Msg.Groups[0].DeviceCount) + // It names a non-device dimension rather than a miner, so it is not redacted from a non-miner reader either. + require.Equal(t, "maestro-b is unreachable", withoutMiner.Msg.Groups[1].Summary) + require.Equal(t, withMiner.Msg.Groups[1].Summary, withoutMiner.Msg.Groups[1].Summary) +} + +// A device rule whose instance carried no device_id still describes a miner in its free text, and this endpoint +// has no miner:read to check, so the template decides — the same call the history API makes. +func TestListActiveAlertGroups_RedactsDeviceTemplateSummaries(t *testing.T) { + groups := []notificationhistory.ActiveAlertGroup{ + { + AlertName: "MinerOffline", AlertCount: 1, + Summary: "rig-14 has been offline for 15 minutes", Template: string(alerts.RuleTemplateOffline), + }, + } + + resp, err := NewHandler(nil, &stubLister{groups: groups}).ListActiveAlertGroups( + ctxWithPerms(authz.PermAlertRead), + connect.NewRequest(&alertsv1.ListActiveAlertGroupsRequest{}), + ) + require.NoError(t, err) + + require.Len(t, resp.Msg.Groups, 1) + require.Equal(t, "MinerOffline", resp.Msg.Groups[0].AlertName) + require.Empty(t, resp.Msg.Groups[0].Summary) +} + +// The device-less provisioned rules beyond the MQTT pair: a facility fan failure and a fleet-wide poll failure. +// Both carry a static annotation summary that names no miner, and a lone instance of either is a rollup row with +// no count to show, so the summary is all the header has — it must clear the gate in the rollup and the drill-in +// alike, or an alert:read viewer sees a row that cannot say what fired. +func TestDeviceLessTemplateSummariesVisibleWithoutMinerRead(t *testing.T) { + cases := []struct { + template string + alertName string + summary string + }{ + { + template: string(alerts.RuleTemplateCurtailmentFanRestore), + alertName: "Curtailment Fan Restore Failed", + summary: "Facility fan restore failed before miners resumed.", + }, + { + template: string(alerts.RuleTemplateTelemetryPoll), + alertName: "Telemetry Poll Failure Rate High", + summary: "Telemetry polling is failing for the last ten minutes.", + }, + { + template: string(alerts.RuleTemplateMetricIngest), + alertName: "Metric Ingest Stalled", + summary: "Proto Fleet metric ingest has stalled.", + }, + } + for _, tc := range cases { + t.Run(tc.template, func(t *testing.T) { + h := NewHandler(nil, &stubLister{ + rows: []notificationhistory.StoredNotification{{ + ID: 1, + ReceivedAt: time.Unix(1_700_000_000, 0), + Notification: notificationhistory.Notification{ + AlertName: tc.alertName, + Status: "firing", + Severity: "critical", + Template: tc.template, + Summary: tc.summary, + }, + }}, + groups: []notificationhistory.ActiveAlertGroup{{ + AlertName: tc.alertName, AlertCount: 1, Summary: tc.summary, Template: tc.template, + }}, + }) + ctx := ctxWithPerms(authz.PermAlertRead) + + rollup, err := h.ListActiveAlertGroups(ctx, connect.NewRequest(&alertsv1.ListActiveAlertGroupsRequest{})) + require.NoError(t, err) + require.Len(t, rollup.Msg.Groups, 1) + require.Equal(t, tc.summary, rollup.Msg.Groups[0].Summary) + + drillIn, err := h.ListAlerts(ctx, connect.NewRequest(&alertsv1.ListAlertsRequest{ + ActiveOnly: true, + AlertName: tc.alertName, + })) + require.NoError(t, err) + require.Len(t, drillIn.Msg.Alerts, 1) + require.Equal(t, tc.summary, drillIn.Msg.Alerts[0].Summary) + require.Empty(t, drillIn.Msg.Alerts[0].DeviceId) + }) + } } func TestListActiveAlertGroups_FlagsMoreBeyondCap(t *testing.T) { diff --git a/server/monitoring/grafana/provisioning/alerting/proto-fleet-rules.yaml b/server/monitoring/grafana/provisioning/alerting/proto-fleet-rules.yaml index 8f5f4c432e..728b7f13e8 100644 --- a/server/monitoring/grafana/provisioning/alerting/proto-fleet-rules.yaml +++ b/server/monitoring/grafana/provisioning/alerting/proto-fleet-rules.yaml @@ -462,6 +462,9 @@ groups: severity: critical rule_group: proto-fleet-self component: metric-ingest + # Fleet-scoped, so the summary names no miner: the label is what lets an alert:read + # viewer without miner:read read it (see deviceLessTemplates). + template: metric-ingest annotations: summary: "Proto Fleet metric ingest has stalled." description: | diff --git a/server/sqlc/queries/notification_history.sql b/server/sqlc/queries/notification_history.sql index c60479084a..f7540c1afb 100644 --- a/server/sqlc/queries/notification_history.sql +++ b/server/sqlc/queries/notification_history.sql @@ -191,20 +191,51 @@ LIMIT sqlc.arg('page_limit'); -- name: ListActiveNotificationGroups :many -- Firing alerts rolled up per rule, worst blast radius first. (alert_name, rule_group) is rule identity: Grafana -- keeps titles unique per folder and a rule_group label maps to one folder, so a title repeats only across labels. --- Identity and counts only: per-instance detail would have to be picked off one instance, and the drill-in already --- reports it per row. +-- Counts and identity aggregate here; the one piece of per-instance detail is picked off in the lateral below. +WITH groups AS ( + SELECT + na.alert_name, + na.rule_group, + COUNT(*)::bigint AS alert_count, + -- FILTER, not COUNT(DISTINCT NULLIF(...)): equivalent, but only the bare column matches + -- idx_notification_active_org_rollup's ordering, so this streams out of the GroupAggregate unsorted. + (COUNT(DISTINCT na.device_id) FILTER (WHERE na.device_id <> ''))::bigint AS device_count, + MIN(COALESCE(na.starts_at, na.received_at))::timestamptz AS first_started_at, + MAX(na.received_at) AS last_received_at + FROM notification_active na + WHERE na.organization_id = sqlc.arg('organization_id') + AND na.status = 'firing' + AND na.received_at >= sqlc.arg('active_since') -- drop alerts not re-asserted within the freshness window + GROUP BY na.alert_name, na.rule_group + ORDER BY device_count DESC, alert_count DESC, MAX(na.received_at) DESC, na.alert_name + LIMIT sqlc.arg('page_limit') +) SELECT - na.alert_name, - na.rule_group, - COUNT(*)::bigint AS alert_count, - -- FILTER, not COUNT(DISTINCT NULLIF(...)): equivalent, but only the bare column matches - -- idx_notification_active_org_rollup's ordering, so this streams out of the GroupAggregate unsorted. - (COUNT(DISTINCT na.device_id) FILTER (WHERE na.device_id <> ''))::bigint AS device_count, - MIN(COALESCE(na.starts_at, na.received_at))::timestamptz AS first_started_at -FROM notification_active na -WHERE na.organization_id = sqlc.arg('organization_id') - AND na.status = 'firing' - AND na.received_at >= sqlc.arg('active_since') -- drop alerts not re-asserted within the freshness window -GROUP BY na.alert_name, na.rule_group -ORDER BY device_count DESC, alert_count DESC, MAX(na.received_at) DESC, na.alert_name -LIMIT sqlc.arg('page_limit'); + g.alert_name, + g.rule_group, + g.alert_count, + g.device_count, + g.first_started_at, + -- A group with miners is described by its drill-in, so it reports no free text here and the CASE drops what + -- the lateral found. device_id = '' is an index prefix, so a group with no device-less rows costs one descent. + -- The summary is a header-polled one-liner, so it is bounded here rather than shipping a whole TEXT column; + -- the template rides along because the caller decides from it whether the summary may name a miner. Neither + -- is length-checked on insert (000136 bounds only the indexed columns), and a truncated template fails that + -- decision closed: it matches no known template, so the summary is withheld. + (CASE WHEN g.device_count = 0 THEN LEFT(COALESCE(s.summary, ''), 500) ELSE '' END)::text AS summary, + (CASE WHEN g.device_count = 0 THEN LEFT(COALESCE(s.template, ''), 64) ELSE '' END)::text AS template +FROM groups g +LEFT JOIN LATERAL ( + SELECT na.summary, na.template + FROM notification_active na + WHERE na.organization_id = sqlc.arg('organization_id') + AND na.status = 'firing' + AND na.received_at >= sqlc.arg('active_since') + AND na.alert_name = g.alert_name + AND na.rule_group = g.rule_group + AND na.device_id = '' + ORDER BY na.received_at DESC, na.history_id DESC + LIMIT 1 +) s ON TRUE +-- Repeated: the CTE's ordering is not guaranteed to survive the join. +ORDER BY g.device_count DESC, g.alert_count DESC, g.last_received_at DESC, g.alert_name;