Skip to content

Commit 507fcbe

Browse files
authored
Merge pull request #89 from samouraiworld/feat-improve-report-format-v2
feat-improve-report-format-v2
2 parents 9052f6f + 850b02a commit 507fcbe

6 files changed

Lines changed: 127 additions & 183 deletions

File tree

backend/internal/database/db_metrics.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -590,6 +590,29 @@ func GetActiveAlertCount(db *gorm.DB, chainID, level string) (int, error) {
590590
return count, nil
591591
}
592592

593+
// MissedBlockCount holds the count of missed blocks per validator in a time window.
594+
type MissedBlockCount struct {
595+
Addr string
596+
Moniker string
597+
Missed int
598+
}
599+
600+
// GetMissedBlocksLast24h returns the count of missed blocks per validator in the
601+
// last 24 hours for the given chain, ordered by missed count descending.
602+
func GetMissedBlocksLast24h(db *gorm.DB, chainID string) ([]MissedBlockCount, error) {
603+
var result []MissedBlockCount
604+
err := db.Raw(`
605+
SELECT addr, MAX(moniker) AS moniker, COUNT(*) AS missed
606+
FROM daily_participations
607+
WHERE chain_id = ?
608+
AND participated = 0
609+
AND date >= datetime('now', '-24 hours')
610+
GROUP BY addr
611+
ORDER BY missed DESC
612+
`, chainID).Scan(&result).Error
613+
return result, err
614+
}
615+
593616
// GetTotalAlertCount returns the total count of alerts with the given level for the given chain.
594617
func GetTotalAlertCount(db *gorm.DB, chainID, level string) (int64, error) {
595618
var count int64

backend/internal/gnovalidator/gnovalidator_report.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -104,7 +104,7 @@ func SendDailyStatsForUser(db *gorm.DB, chainID string, userID *string, chatID *
104104
log.Printf("[report][%s] no participation data for %s, skipping", chainID, yesterday)
105105
return
106106
}
107-
msg = FormatHealthyReport(chainID, yesterday, rates, minBlock, maxBlock, snap.AlertsLast24h)
107+
msg = FormatHealthyReport(chainID, yesterday, snap, rates, minBlock, maxBlock)
108108
}
109109

110110
switch {

backend/internal/gnovalidator/health.go

Lines changed: 89 additions & 168 deletions
Original file line numberDiff line numberDiff line change
@@ -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

3939
func 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

437343
func 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
}

backend/internal/telegram/validator.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ type ChainHealthSnapshot struct {
5252
ValidatorRates map[string]ValidatorRate
5353
MinBlock int64
5454
MaxBlock int64
55-
AlertsLast24h []database.AlertSummary
55+
MissedLast24h []database.MissedBlockCount
5656
}
5757

5858

@@ -73,9 +73,9 @@ var ChainHealthFetcher func(chainID string) ChainHealthSnapshot
7373
var ChainDisabledFormatter func(chainID string, snap ChainHealthSnapshot) string
7474
var ChainStuckFormatter func(chainID string, snap ChainHealthSnapshot) string
7575

76-
// AlertsFormatter formats the last-24h alert section. Set from main.go to
77-
// gnovalidator.FormatAlertsLast24h to avoid a circular import.
78-
var AlertsFormatter func(alerts []database.AlertSummary) string
76+
// MissedBlocksFormatter formats the missed-blocks-last-24h section. Set from main.go to
77+
// gnovalidator.FormatMissedBlocksLast24hHTML to avoid a circular import.
78+
var MissedBlocksFormatter func(missed []database.MissedBlockCount) string
7979

8080
// SetChainHealthFetcher registers the live-health fetch function and its
8181
// format helpers. Called once from main.go.
@@ -2248,8 +2248,8 @@ func formatChainHealthMessage(chainID string, snap ChainHealthSnapshot) string {
22482248
b.WriteString(fmt.Sprintf("Consensus: round %d — %s\n", snap.ConsensusRound, roundLabel))
22492249
}
22502250

2251-
if AlertsFormatter != nil {
2252-
b.WriteString(AlertsFormatter(snap.AlertsLast24h))
2251+
if MissedBlocksFormatter != nil {
2252+
b.WriteString(MissedBlocksFormatter(snap.MissedLast24h))
22532253
}
22542254

22552255
return b.String()

0 commit comments

Comments
 (0)