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 } }) =>
- {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;