diff --git a/internal/monitor/monitor.go b/internal/monitor/monitor.go index 37d9f03..d1c5bc8 100644 --- a/internal/monitor/monitor.go +++ b/internal/monitor/monitor.go @@ -96,6 +96,19 @@ func sanitizeLog(s string) string { return s } +func fmtDurationShort(d time.Duration) string { + if d < time.Minute { + return fmt.Sprintf("%ds", int(d.Seconds())) + } + if d < time.Hour { + return fmt.Sprintf("%dm", int(d.Minutes())) + } + if d < 24*time.Hour { + return fmt.Sprintf("%dh %dm", int(d.Hours()), int(d.Minutes())%60) + } + return fmt.Sprintf("%dd %dh", int(d.Hours())/24, int(d.Hours())%24) +} + func (e *Engine) AddLog(msg string) { e.logMu.Lock() defer e.logMu.Unlock() @@ -206,8 +219,12 @@ func (e *Engine) RecordHeartbeat(token string) bool { case "LATE": e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", site.Name)) case "DOWN": - e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) - go e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) + downDur := "" + if !site.StatusChangedAt.IsZero() { + downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt))) + } + e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered%s", site.Name, downDur)) + go e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.%s", site.Name, downDur)) } if prevStatus != "UP" && prevStatus != "PENDING" { @@ -514,6 +531,11 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int } isBroken := func(s string) bool { return s == "DOWN" || s == "SSL EXP" } + + if site.Status == "UP" && newState.Status == "LATE" { + e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat overdue", site.Name)) + } + if !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" { if inMaint { e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name)) @@ -529,11 +551,17 @@ func (e *Engine) handleStatusChange(site models.Site, rawStatus string, code int } } if isBroken(site.Status) && newState.Status == "UP" { - if !inMaint { - e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name)) - } else { - e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name)) + downDur := "" + if !site.StatusChangedAt.IsZero() { + downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt))) } + e.AddLog(fmt.Sprintf("Monitor '%s' recovered%s", site.Name, downDur)) + if !inMaint { + e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP%s", site.Name, downDur)) + } + } + if site.Status == "LATE" && newState.Status == "UP" && !isBroken(site.Status) { + e.AddLog(fmt.Sprintf("Monitor '%s' heartbeat arrived (was late)", site.Name)) } } diff --git a/internal/tui/tab_logs.go b/internal/tui/tab_logs.go index 0302acd..0633001 100644 --- a/internal/tui/tab_logs.go +++ b/internal/tui/tab_logs.go @@ -5,27 +5,83 @@ import ( "strings" ) -func colorizeLog(line string) string { +type logSeverity int + +const ( + severityInfo logSeverity = iota + severityWarn + severityDown + severityUp + severitySystem +) + +func classifyLog(line string) logSeverity { lower := strings.ToLower(line) switch { case strings.Contains(lower, "confirmed down"), strings.Contains(lower, "is down"), strings.Contains(lower, "missed heartbeat"), - strings.Contains(lower, "failed check"), - strings.Contains(lower, "ssl warning"): - return dangerStyle.Render(line) + strings.Contains(lower, "alert send failed"): + return severityDown case strings.Contains(lower, "recovered"), strings.Contains(lower, "is up"), - strings.Contains(lower, "recovery"): - return specialStyle.Render(line) + strings.Contains(lower, "recovery"), + strings.Contains(lower, "first heartbeat"): + return severityUp + case strings.Contains(lower, "failed check"), + strings.Contains(lower, "ssl warning"), + strings.Contains(lower, "overdue"), + strings.Contains(lower, "was late"): + return severityWarn case strings.Contains(lower, "engine"), - strings.Contains(lower, "cluster"): - return titleStyle.Render(line) + strings.Contains(lower, "cluster"), + strings.Contains(lower, "loaded"), + strings.Contains(lower, "paused"), + strings.Contains(lower, "resumed"): + return severitySystem default: - return line + return severityInfo } } +func isImportantLog(sev logSeverity) bool { + return sev == severityDown || sev == severityUp || sev == severitySystem +} + +func renderLogTag(sev logSeverity) string { + switch sev { + case severityDown: + return dangerStyle.Render(" DOWN ") + case severityUp: + return specialStyle.Render(" UP ") + case severityWarn: + return warnStyle.Render(" WARN ") + case severitySystem: + return titleStyle.Render(" SYS ") + default: + return subtleStyle.Render(" info ") + } +} + +func renderLogLine(line string) string { + sev := classifyLog(line) + tag := renderLogTag(sev) + + ts := "" + msg := line + if len(line) > 10 && line[0] == '[' { + if idx := strings.Index(line, "]"); idx > 0 && idx < 12 { + ts = subtleStyle.Render(line[1:idx]) + msg = strings.TrimSpace(line[idx+1:]) + } + } + + if ts != "" { + return fmt.Sprintf(" %s %s %s", ts, tag, msg) + } + return fmt.Sprintf(" %s %s", tag, msg) +} + func (m Model) viewLogsTab() string { content := m.logViewport.View() if strings.TrimSpace(content) == "" || content == "Waiting for logs..." { @@ -33,22 +89,34 @@ func (m Model) viewLogsTab() string { } lines := strings.Split(content, "\n") - var colored []string + var rendered []string + total := 0 + shown := 0 + for _, line := range lines { - if line == "" { - colored = append(colored, line) + if strings.TrimSpace(line) == "" { continue } - colored = append(colored, colorizeLog(line)) - } - - count := 0 - for _, l := range lines { - if strings.TrimSpace(l) != "" { - count++ + total++ + sev := classifyLog(line) + if m.logFilterImportant && !isImportantLog(sev) { + continue } + shown++ + rendered = append(rendered, renderLogLine(line)) } - header := subtleStyle.Render(fmt.Sprintf(" %d entries [↑/↓] Scroll [PgUp/PgDn] Page", count)) - return "\n" + header + "\n\n" + strings.Join(colored, "\n") + filterLabel := "All" + if m.logFilterImportant { + filterLabel = "Important" + } + + header := subtleStyle.Render(fmt.Sprintf( + " %d entries [↑/↓] Scroll [PgUp/PgDn] Page [f] Filter: %s", shown, filterLabel)) + + if m.logFilterImportant && shown < total { + header += subtleStyle.Render(fmt.Sprintf(" (%d hidden)", total-shown)) + } + + return "\n" + header + "\n\n" + strings.Join(rendered, "\n") } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 664a729..7af8609 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -92,9 +92,10 @@ type Model struct { userFormData *userFormData maintFormData *maintFormData - logViewport viewport.Model - isAdmin bool - zones *zone.Manager + logViewport viewport.Model + logFilterImportant bool + isAdmin bool + zones *zone.Manager deleteID int deleteName string @@ -392,6 +393,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.filterMode = true return m, nil } + case "f": + if m.state == stateLogs { + m.logFilterImportant = !m.logFilterImportant + return m, nil + } case "tab": m.switchTab(m.currentTab + 1) case "pgup", "pgdown": @@ -937,6 +943,8 @@ func (m Model) viewDashboard() string { switch m.currentTab { case 0: keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" + case 2: + keys = "[f]Filter [T]Theme [Tab]Switch [q]Quit" case 4: keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" case 5: