feat: logs tab overhaul — severity tags, filtering, recovery durations #35

Merged
lerko merged 1 commits from feat/logs-overhaul into main 2026-05-28 00:35:24 +00:00
3 changed files with 134 additions and 30 deletions
+34 -6
View File
@@ -96,6 +96,19 @@ func sanitizeLog(s string) string {
return s 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) { func (e *Engine) AddLog(msg string) {
e.logMu.Lock() e.logMu.Lock()
defer e.logMu.Unlock() defer e.logMu.Unlock()
@@ -206,8 +219,12 @@ func (e *Engine) RecordHeartbeat(token string) bool {
case "LATE": case "LATE":
e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", site.Name)) e.AddLog(fmt.Sprintf("Push Monitor '%s' heartbeat arrived (was late)", site.Name))
case "DOWN": case "DOWN":
e.AddLog(fmt.Sprintf("Push Monitor '%s' recovered", site.Name)) downDur := ""
go e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Push Monitor '%s' is receiving heartbeats.", site.Name)) 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" { 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" } 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 !isBroken(site.Status) && isBroken(newState.Status) && newState.Status != "PENDING" {
if inMaint { if inMaint {
e.AddLog(fmt.Sprintf("Monitor '%s' is DOWN (alerts suppressed — maintenance)", site.Name)) 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 isBroken(site.Status) && newState.Status == "UP" {
if !inMaint { downDur := ""
e.triggerAlert(site.AlertID, "✅ RECOVERY", fmt.Sprintf("Monitor '%s' is UP", site.Name)) if !site.StatusChangedAt.IsZero() {
} else { downDur = fmt.Sprintf(" (was down %s)", fmtDurationShort(time.Since(site.StatusChangedAt)))
e.AddLog(fmt.Sprintf("Monitor '%s' recovered (maintenance active, alert suppressed)", site.Name))
} }
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))
} }
} }
+88 -20
View File
@@ -5,27 +5,83 @@ import (
"strings" "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) lower := strings.ToLower(line)
switch { switch {
case strings.Contains(lower, "confirmed down"), case strings.Contains(lower, "confirmed down"),
strings.Contains(lower, "is down"), strings.Contains(lower, "is down"),
strings.Contains(lower, "missed heartbeat"), strings.Contains(lower, "missed heartbeat"),
strings.Contains(lower, "failed check"), strings.Contains(lower, "alert send failed"):
strings.Contains(lower, "ssl warning"): return severityDown
return dangerStyle.Render(line)
case strings.Contains(lower, "recovered"), case strings.Contains(lower, "recovered"),
strings.Contains(lower, "is up"), strings.Contains(lower, "is up"),
strings.Contains(lower, "recovery"): strings.Contains(lower, "recovery"),
return specialStyle.Render(line) 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"), case strings.Contains(lower, "engine"),
strings.Contains(lower, "cluster"): strings.Contains(lower, "cluster"),
return titleStyle.Render(line) strings.Contains(lower, "loaded"),
strings.Contains(lower, "paused"),
strings.Contains(lower, "resumed"):
return severitySystem
default: 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 { func (m Model) viewLogsTab() string {
content := m.logViewport.View() content := m.logViewport.View()
if strings.TrimSpace(content) == "" || content == "Waiting for logs..." { if strings.TrimSpace(content) == "" || content == "Waiting for logs..." {
@@ -33,22 +89,34 @@ func (m Model) viewLogsTab() string {
} }
lines := strings.Split(content, "\n") lines := strings.Split(content, "\n")
var colored []string var rendered []string
total := 0
shown := 0
for _, line := range lines { for _, line := range lines {
if line == "" { if strings.TrimSpace(line) == "" {
colored = append(colored, line)
continue continue
} }
colored = append(colored, colorizeLog(line)) total++
sev := classifyLog(line)
if m.logFilterImportant && !isImportantLog(sev) {
continue
}
shown++
rendered = append(rendered, renderLogLine(line))
} }
count := 0 filterLabel := "All"
for _, l := range lines { if m.logFilterImportant {
if strings.TrimSpace(l) != "" { filterLabel = "Important"
count++
}
} }
header := subtleStyle.Render(fmt.Sprintf(" %d entries [↑/↓] Scroll [PgUp/PgDn] Page", count)) header := subtleStyle.Render(fmt.Sprintf(
return "\n" + header + "\n\n" + strings.Join(colored, "\n") " %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")
} }
+8
View File
@@ -93,6 +93,7 @@ type Model struct {
maintFormData *maintFormData maintFormData *maintFormData
logViewport viewport.Model logViewport viewport.Model
logFilterImportant bool
isAdmin bool isAdmin bool
zones *zone.Manager zones *zone.Manager
@@ -392,6 +393,11 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.filterMode = true m.filterMode = true
return m, nil return m, nil
} }
case "f":
if m.state == stateLogs {
m.logFilterImportant = !m.logFilterImportant
return m, nil
}
case "tab": case "tab":
m.switchTab(m.currentTab + 1) m.switchTab(m.currentTab + 1)
case "pgup", "pgdown": case "pgup", "pgdown":
@@ -937,6 +943,8 @@ func (m Model) viewDashboard() string {
switch m.currentTab { switch m.currentTab {
case 0: case 0:
keys = "[/]Filter [n]New [e]Edit [i]Info [d]Del [p]Pause [T]Theme [Tab]Switch [q]Quit" 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: case 4:
keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit" keys = "[n]New [x]End [d]Del [T]Theme [Tab]Switch [q]Quit"
case 5: case 5: