@@ -31,9 +31,9 @@ type ChainHealthSnapshot struct {
3131 MinBlock int64
3232 MaxBlock int64
3333
34- // Alert events from the last 24 hours (WARNING, CRITICAL, RESOLVED) .
34+ // Missed blocks per validator in the last 24 hours.
3535 // Populated by FetchChainHealthSnapshot for use in daily reports.
36- AlertsLast24h []database.AlertSummary
36+ MissedLast24h []database.MissedBlockCount
3737}
3838
3939func FetchChainHealthSnapshot (db * gorm.DB , chainID string ) ChainHealthSnapshot {
@@ -111,12 +111,12 @@ func FetchChainHealthSnapshot(db *gorm.DB, chainID string) ChainHealthSnapshot {
111111 snap .MinBlock = minBlock
112112 snap .MaxBlock = maxBlock
113113
114- alerts , err := database .GetAlertLogsLast24h (db , chainID )
114+ missed , err := database .GetMissedBlocksLast24h (db , chainID )
115115 if err != nil {
116- log .Printf ("[health][%s] GetAlertLogsLast24h error: %v" , chainID , err )
117- // non-fatal: leave AlertsLast24h nil
116+ log .Printf ("[health][%s] GetMissedBlocksLast24h error: %v" , chainID , err )
117+ // non-fatal: leave MissedLast24h nil
118118 } else {
119- snap .AlertsLast24h = alerts
119+ snap .MissedLast24h = missed
120120 }
121121
122122 return snap
@@ -263,200 +263,121 @@ func formatValidatorRates(rates map[string]ValidatorRate) string {
263263 return sb .String ()
264264}
265265
266- func FormatDisabledReport (chainID string , snap ChainHealthSnapshot ) string {
267- var sb strings.Builder
268- sb .WriteString (fmt .Sprintf ("⚫ [%s] Chain status — MONITORING OFF\n " , chainID ))
269- if snap .LatestBlockHeight > 0 {
270- sb .WriteString (fmt .Sprintf ("Last known block: #%d at %s UTC\n " ,
271- snap .LatestBlockHeight ,
272- snap .LatestBlockTime .UTC ().Format ("2006-01-02 15:04" ),
273- ))
274- } else if snap .MaxBlock > 0 {
275- sb .WriteString (fmt .Sprintf ("Last known block in DB: #%d\n " , snap .MaxBlock ))
276- }
277- return sb .String ()
278- }
266+ const reportSeparator = "---"
279267
280- type validatorAlertSummary struct {
281- Moniker string
282- Addr string
283- WorstLevel string // "CRITICAL" > "WARNING"
284- Count int // total WARNING+CRITICAL events
285- LastSentAt time.Time
286- Resolved bool
287- ResolvedAt int64 // end_height of RESOLVED row
268+ func missedEmoji (missed int ) string {
269+ t := GetThresholds ()
270+ switch {
271+ case missed >= t .CriticalThreshold :
272+ return "🔴"
273+ case missed >= t .WarningThreshold :
274+ return "🟡"
275+ default :
276+ return "🟢"
277+ }
288278}
289279
290- func FormatAlertsLast24h ( alerts []database.AlertSummary ) string {
291- if len (alerts ) == 0 {
280+ func FormatMissedBlocksLast24h ( rows []database.MissedBlockCount ) string {
281+ if len (rows ) == 0 {
292282 return ""
293283 }
294-
295- // Group by addr — alerts are ordered sent_at DESC so first seen = most recent.
296- byAddr := map [string ]* validatorAlertSummary {}
297- var order []string
298- for _ , a := range alerts {
299- entry , exists := byAddr [a .Addr ]
300- if ! exists {
301- entry = & validatorAlertSummary {Moniker : a .Moniker , Addr : a .Addr }
302- byAddr [a .Addr ] = entry
303- order = append (order , a .Addr )
304- }
305- switch a .Level {
306- case "CRITICAL" :
307- entry .Count ++
308- entry .WorstLevel = "CRITICAL"
309- if a .SentAt .After (entry .LastSentAt ) {
310- entry .LastSentAt = a .SentAt
311- }
312- case "WARNING" :
313- entry .Count ++
314- if entry .WorstLevel != "CRITICAL" {
315- entry .WorstLevel = "WARNING"
316- }
317- if a .SentAt .After (entry .LastSentAt ) {
318- entry .LastSentAt = a .SentAt
319- }
320- case "RESOLVED" :
321- entry .Resolved = true
322- entry .ResolvedAt = a .EndHeight
323- }
324- }
325-
326- var b strings.Builder
327- b .WriteString (fmt .Sprintf ("\n ⚠️ Alerts last 24h (%d validator(s)):\n " , len (order )))
328-
329- limit := 10
330- extra := 0
331- if len (order ) > limit {
332- extra = len (order ) - limit
333- order = order [:limit ]
334- }
335-
336- for _ , addr := range order {
337- e := byAddr [addr ]
338- addrShort := addr
339- if len (addrShort ) > 12 {
340- addrShort = addrShort [:12 ] + "..."
341- }
342- var emoji string
343- if e .WorstLevel == "CRITICAL" {
344- emoji = "🚨"
345- } else {
346- emoji = "⚠️ "
284+ var sb strings.Builder
285+
286+ sb .WriteString ("Missed blocks last 24h:\n " )
287+ for _ , r := range rows {
288+ moniker := r .Moniker
289+ if moniker == "" {
290+ moniker = "unknown"
347291 }
348- b .WriteString (fmt .Sprintf (" %s %-8s %-14s (%s) — %d alert(s) — last %s\n " ,
349- emoji , e .WorstLevel , e .Moniker , addrShort ,
350- e .Count , e .LastSentAt .UTC ().Format ("15:04 UTC" )))
351- if e .Resolved {
352- b .WriteString (fmt .Sprintf (" ✅ RESOLVED %-14s (%s) at block #%d\n " ,
353- e .Moniker , addrShort , e .ResolvedAt ))
292+ addrShort := r .Addr
293+ if len (addrShort ) > 10 {
294+ addrShort = addrShort [:10 ] + "..."
354295 }
296+ sb .WriteString (fmt .Sprintf (" %s %-14s (%s) %d missed\n " ,
297+ missedEmoji (r .Missed ), moniker , addrShort , r .Missed ))
355298 }
356- if extra > 0 {
357- b .WriteString (fmt .Sprintf (" ... and %d more.\n " , extra ))
358- }
359- return b .String ()
299+ return sb .String ()
360300}
361301
362- // FormatAlertsLast24hHTML is the HTML-safe variant for Telegram (parse_mode: HTML).
363- // Moniker and address fields are html.EscapeString'd to prevent markup injection.
364- func FormatAlertsLast24hHTML (alerts []database.AlertSummary ) string {
365- if len (alerts ) == 0 {
302+ // FormatMissedBlocksLast24hHTML is the HTML-safe variant for Telegram (parse_mode: HTML).
303+ func FormatMissedBlocksLast24hHTML (rows []database.MissedBlockCount ) string {
304+ if len (rows ) == 0 {
366305 return ""
367306 }
368-
369- byAddr := map [string ]* validatorAlertSummary {}
370- var order []string
371- for _ , a := range alerts {
372- entry , exists := byAddr [a .Addr ]
373- if ! exists {
374- entry = & validatorAlertSummary {Moniker : a .Moniker , Addr : a .Addr }
375- byAddr [a .Addr ] = entry
376- order = append (order , a .Addr )
307+ var sb strings.Builder
308+
309+ sb .WriteString ("Missed blocks last 24h:\n " )
310+ for _ , r := range rows {
311+ moniker := r .Moniker
312+ if moniker == "" {
313+ moniker = "unknown"
377314 }
378- switch a .Level {
379- case "CRITICAL" :
380- entry .Count ++
381- entry .WorstLevel = "CRITICAL"
382- if a .SentAt .After (entry .LastSentAt ) {
383- entry .LastSentAt = a .SentAt
384- }
385- case "WARNING" :
386- entry .Count ++
387- if entry .WorstLevel != "CRITICAL" {
388- entry .WorstLevel = "WARNING"
389- }
390- if a .SentAt .After (entry .LastSentAt ) {
391- entry .LastSentAt = a .SentAt
392- }
393- case "RESOLVED" :
394- entry .Resolved = true
395- entry .ResolvedAt = a .EndHeight
315+ addrShort := r .Addr
316+ if len (addrShort ) > 10 {
317+ addrShort = addrShort [:10 ] + "..."
396318 }
319+ sb .WriteString (fmt .Sprintf (" %s <b>%-14s</b> (<code>%s</code>) %d missed\n " ,
320+ missedEmoji (r .Missed ), html .EscapeString (moniker ), html .EscapeString (addrShort ), r .Missed ))
397321 }
322+ return sb .String ()
323+ }
398324
399- var b strings.Builder
400- b .WriteString (fmt .Sprintf ("\n ⚠️ Alerts last 24h (%d validator(s)):\n " , len (order )))
401-
402- limit := 10
403- extra := 0
404- if len (order ) > limit {
405- extra = len (order ) - limit
406- order = order [:limit ]
407- }
408-
409- for _ , addr := range order {
410- e := byAddr [addr ]
411- addrShort := addr
412- if len (addrShort ) > 12 {
413- addrShort = addrShort [:12 ] + "..."
414- }
415- safeMoniker := html .EscapeString (e .Moniker )
416- safeAddr := html .EscapeString (addrShort )
417- var emoji string
418- if e .WorstLevel == "CRITICAL" {
419- emoji = "🚨"
420- } else {
421- emoji = "⚠️ "
422- }
423- b .WriteString (fmt .Sprintf (" %s %-8s %-14s (%s) — %d alert(s) — last %s\n " ,
424- emoji , e .WorstLevel , safeMoniker , safeAddr ,
425- e .Count , e .LastSentAt .UTC ().Format ("15:04 UTC" )))
426- if e .Resolved {
427- b .WriteString (fmt .Sprintf (" ✅ RESOLVED %-14s (%s) at block #%d\n " ,
428- safeMoniker , safeAddr , e .ResolvedAt ))
429- }
430- }
431- if extra > 0 {
432- b .WriteString (fmt .Sprintf (" ... and %d more.\n " , extra ))
325+ func FormatDisabledReport (chainID string , snap ChainHealthSnapshot ) string {
326+ date := time .Now ().UTC ().Format ("2006-01-02" )
327+ var sb strings.Builder
328+ sb .WriteString (reportSeparator + "\n " )
329+ sb .WriteString (fmt .Sprintf ("📊 [%s] Daily Summary — %s\n " , chainID , date ))
330+
331+ sb .WriteString ("⚫ Monitoring OFF" )
332+ if snap .LatestBlockHeight > 0 {
333+ sb .WriteString (fmt .Sprintf (" — Last known block: #%d at %s UTC" ,
334+ snap .LatestBlockHeight ,
335+ snap .LatestBlockTime .UTC ().Format ("2006-01-02 15:04" )))
336+ } else if snap .MaxBlock > 0 {
337+ sb .WriteString (fmt .Sprintf (" — Last known block in DB: #%d" , snap .MaxBlock ))
433338 }
434- return b .String ()
339+ sb .WriteString ("\n " )
340+ return sb .String ()
435341}
436342
437343func FormatStuckReport (chainID string , snap ChainHealthSnapshot ) string {
344+ date := time .Now ().UTC ().Format ("2006-01-02" )
438345 var sb strings.Builder
346+ sb .WriteString (reportSeparator + "\n " )
347+
348+ sb .WriteString (fmt .Sprintf ("📊 [%s] Daily Summary — %s\n " , chainID , date ))
349+
439350 emoji := chainStatusEmoji (snap )
440351 blockAge := formatBlockAge (snap .LatestBlockTime )
441- sb .WriteString (fmt .Sprintf ("%s [%s] Chain status — block #%d (%s)\n " ,
442- emoji , chainID , snap .LatestBlockHeight , blockAge ))
443-
444352 stuckSince := ""
445353 if ! snap .LatestBlockTime .IsZero () {
446354 stuckSince = fmt .Sprintf (" since %s UTC" , snap .LatestBlockTime .UTC ().Format ("2006-01-02 15:04" ))
447355 }
448- sb .WriteString (fmt .Sprintf ("Consensus: round %d — %s%s\n " ,
356+ sb .WriteString (fmt .Sprintf ("%s Block #%d (%s) — Consensus: round %d — %s%s\n " ,
357+ emoji , snap .LatestBlockHeight , blockAge ,
449358 snap .ConsensusRound , consensusLabel (snap .ConsensusRound ), stuckSince ))
450359
451- sb .WriteString (FormatAlertsLast24h (snap .AlertsLast24h ))
360+ sb .WriteString (FormatMissedBlocksLast24h (snap .MissedLast24h ))
452361 return sb .String ()
453362}
454363
455- func FormatHealthyReport (chainID , date string , rates map [string ]ValidatorRate , minBlock , maxBlock int64 , alerts []database. AlertSummary ) string {
364+ func FormatHealthyReport (chainID , date string , snap ChainHealthSnapshot , rates map [string ]ValidatorRate , minBlock , maxBlock int64 ) string {
456365 var sb strings.Builder
457- sb .WriteString (fmt .Sprintf ("📊 [%s] Daily Summary — %s\n \n " , chainID , date ))
458- sb .WriteString (fmt .Sprintf ("Participation yesterday (Blocks %d → %d):\n " , minBlock , maxBlock ))
459- sb .WriteString (formatValidatorRates (rates ))
460- sb .WriteString (FormatAlertsLast24h (alerts ))
366+ sb .WriteString (reportSeparator + "\n " )
367+
368+ sb .WriteString (fmt .Sprintf ("📊 [%s] Daily Summary — %s\n " , chainID , date ))
369+
370+ if snap .RPCReachable {
371+ emoji := chainStatusEmoji (snap )
372+ blockAge := formatBlockAge (snap .LatestBlockTime )
373+ sb .WriteString (fmt .Sprintf ("%s Block #%d (%s) — Consensus: round %d — %s\n " ,
374+ emoji , snap .LatestBlockHeight , blockAge ,
375+ snap .ConsensusRound , consensusLabel (snap .ConsensusRound )))
376+ // sb.WriteString(reportSeparator + "\n")
377+ }
378+
379+ // sb.WriteString(fmt.Sprintf("Participation yesterday (Blocks %d → %d):\n", minBlock, maxBlock))
380+ // sb.WriteString(formatValidatorRates(rates))
381+ sb .WriteString (FormatMissedBlocksLast24h (snap .MissedLast24h ))
461382 return sb .String ()
462383}
0 commit comments